-
-
Notifications
You must be signed in to change notification settings - Fork 109
Expand file tree
/
Copy pathSecretResolver.cfc
More file actions
242 lines (228 loc) · 11.7 KB
/
Copy pathSecretResolver.cfc
File metadata and controls
242 lines (228 loc) · 11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
/**
* Reads .kamal/secrets (and .kamal/secrets.<destination>) files and
* expands $(cmd) subshells via local bash, yielding a key→value map.
*
* This is how Kamal integrates with external secret managers: users
* declare `REGISTRY_PASSWORD=$(op read op://Deploy/Registry/password)`
* in .kamal/secrets and on load the `op` CLI actually runs. We do the
* same — no embedded vault, no network adapter, just `bash -c`.
*
* Resolution sources the file in bash (`set -a` exports every assignment
* and bash expands $(cmd) substitutions), then prints each key DECLARED
* IN THE FILE back as a KEY<US>VALUE<RS> record (US = chr(31), RS =
* chr(30)). Reading the declared keys individually — instead of diffing
* `env` output against a baseline capture — means:
* - keys that also exist in the parent environment (user exports, CI
* vars) still resolve to the file's value instead of being dropped,
* - multi-line values (TLS certs, SSH keys) survive intact, because
* records are RS-separated rather than newline-separated.
*
* Destination overlay: .kamal/secrets loads first, then
* .kamal/secrets.<destination> (if destination is set and the file
* exists) overrides keys.
*
* Missing file is a no-op — ALL calls to .get() return empty string.
* A present file on a machine where bash can't run throws
* SecretResolver.BashUnavailable instead of silently resolving zero
* secrets (which would let callers proceed with empty credentials).
* A failing command inside the file (e.g. `$(op read …)` when not signed
* in) throws SecretResolver.ResolutionFailed instead of silently
* exporting the key with an empty value.
*/
component {
public SecretResolver function init(struct opts = {}) {
variables.projectRoot = arguments.opts.projectRoot ?: expandPath("./");
variables.destination = arguments.opts.destination ?: "";
// Override when bash isn't reachable as plain `bash` on PATH
// (e.g. a non-standard Git Bash install on Windows). Also the
// seam the BashUnavailable spec uses.
variables.bashCmd = arguments.opts.bashCmd ?: "bash";
// Upper bound on resolution, in seconds (#2957): a command inside
// the secrets file that blocks on interactive input — `op read`
// prompting for sign-in, `bw get` waiting to unlock — used to hang
// the deploy thread forever on an unbounded waitFor()/stdout read.
variables.timeoutSeconds = arguments.opts.timeoutSeconds ?: 60;
variables.resolved = $loadAll();
return this;
}
public string function get(required string key) {
return variables.resolved[arguments.key] ?: "";
}
public boolean function has(required string key) {
return structKeyExists(variables.resolved, arguments.key);
}
public struct function all() {
return duplicate(variables.resolved);
}
private struct function $loadAll() {
var base = $resolveFile($secretPath(""));
var overlay = len(variables.destination) ? $resolveFile($secretPath(variables.destination)) : {};
var out = duplicate(base);
for (var k in overlay) out[k] = overlay[k];
return out;
}
private string function $secretPath(required string destination) {
var root = variables.projectRoot;
if (right(root, 1) != "/") root &= "/";
if (len(arguments.destination)) {
return root & ".kamal/secrets." & arguments.destination;
}
return root & ".kamal/secrets";
}
/**
* Source the secrets file through bash and read back the value of each
* key the file declares, as RS-terminated KEY<US>VALUE records.
*/
private struct function $resolveFile(required string path) {
if (!fileExists(arguments.path)) return {};
var candidates = $candidateKeys(fileRead(arguments.path, "UTF-8"));
if (!arrayLen(candidates)) return {};
// `set -a` exports every assignment made while sourcing; the file's
// own stdout is discarded so it can't corrupt the record stream.
// `set -e` makes a failing command inside the file — most importantly
// an assignment whose $(cmd) substitution fails, like `$(op read …)`
// when not signed in — abort sourcing with a non-zero exit, so it
// surfaces as ResolutionFailed below instead of silently exporting
// the key with an empty value. (Without -e, bash only reports the
// status of the file's LAST statement, and even that is ignored
// because the script continues into the for-loop.)
// `${!k+x}` (set-check on the indirected name) filters out candidate
// keys bash never actually set — e.g. base64 continuation lines of a
// quoted multi-line value that merely look like assignments.
// \037 = US (key/value separator), \036 = RS (record terminator).
// NUL would be the only byte guaranteed absent from env values, but
// Lucee's chr(0) yields an empty string, so it can't be used as a
// CFML-side delimiter; RS never appears in realistic secret values.
var script = "set -ae; source " & $shellEscape(arguments.path) & " >/dev/null; "
& "for __wheels_key in " & arrayToList(candidates, " ") & "; do "
& "if [ -n ""${!__wheels_key+x}"" ]; then "
& "printf '%s\037%s\036' ""$__wheels_key"" ""${!__wheels_key}""; "
& "fi; done";
var result = $runBash(script);
if (result.exitCode != 0) {
throw(
type = "SecretResolver.ResolutionFailed",
message = "Resolving secrets from [" & arguments.path & "] failed (bash exit code " & result.exitCode & ").",
detail = result.err
);
}
return $parseRecords(result.out);
}
/**
* Scan raw file content for the env keys it declares: lines shaped
* `KEY=...` or `export KEY=...`. Lines inside quoted multi-line values
* can produce false candidates; those are filtered by the set-check in
* the resolution script because bash never defines them as variables.
*/
private array function $candidateKeys(required string content) {
var keys = [];
for (var line in listToArray(arguments.content, chr(10), false)) {
var m = reFind("^[ \t]*(export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)=", line, 1, true);
if (arrayLen(m.pos) >= 3 && m.pos[3] > 0) {
var key = mid(line, m.pos[3], m.len[3]);
// Exact-match dedupe (arrayFind is case-sensitive): FOO and
// foo are distinct bash variables, so both stay candidates.
if (!arrayFind(keys, key)) arrayAppend(keys, key);
}
}
return keys;
}
/**
* Parse the KEY<US>VALUE<RS> records emitted by the resolution script.
* The RS (chr(30)) terminator doesn't appear in realistic secret values,
* so multi-line values pass through intact. The `sep > 1` guard skips
* malformed records and avoids Left(str, 0), which crashes Lucee 7
* (Cross-Engine Invariant 8).
*/
private struct function $parseRecords(required string blob) {
var out = {};
for (var rec in listToArray(arguments.blob, chr(30), false)) {
var sep = find(chr(31), rec);
if (sep <= 1) continue;
out[left(rec, sep - 1)] = mid(rec, sep + 1, len(rec));
}
return out;
}
/**
* Run a command through local bash, capturing stdout and stderr
* separately. Returns {exitCode, out, err}. Throws
* SecretResolver.BashUnavailable when bash can't be started (e.g.
* Windows without WSL/Git Bash) — surfacing the failure beats silently
* yielding zero secrets.
*/
private struct function $runBash(required string cmd) {
// stderr is redirected to a temp file rather than read from a pipe:
// draining stdout to EOF before touching a piped stderr deadlocks
// when the subprocess fills the OS stderr pipe buffer (~64 KB) —
// e.g. a verbose secret-manager CLI error — because bash blocks on
// the stderr write while we block on the stdout read. A file sink
// never fills, so bash always runs to completion. Secret values
// travel on stdout (read in-memory); only diagnostics touch disk,
// and the file is deleted in the finally block even when the drain
// or the throw paths interrupt the happy path.
var errPath = getTempFile(getTempDirectory(), "wheels-secret-err");
try {
var proc = "";
try {
var pb = createObject("java", "java.lang.ProcessBuilder").init([variables.bashCmd, "-c", arguments.cmd]);
pb.redirectError(createObject("java", "java.io.File").init(errPath));
proc = pb.start();
} catch (any e) {
throw(
type = "SecretResolver.BashUnavailable",
message = "Unable to launch bash to resolve .kamal/secrets: " & e.message,
detail = "Secret resolution requires a local bash for $(cmd) expansion. On Windows, run inside WSL or Git Bash."
);
}
var out = $drainWithDeadline(proc);
// The drain only returns once the process has exited, so this
// waitFor() is an immediate exit-code read, not a blocking wait.
var exitCode = proc.waitFor();
var err = fileExists(errPath) ? fileRead(errPath, "UTF-8") : "";
return {exitCode: exitCode, out: out, err: err};
} finally {
if (fileExists(errPath)) fileDelete(errPath);
}
}
/**
* Drain the process's stdout to completion, bounded by
* variables.timeoutSeconds (#2957). The previous shape — a blocking
* read-to-EOF followed by an unbounded waitFor() — hung the deploy
* thread forever when a command inside the secrets file blocked on
* interactive input while holding stdout open. Polling `available()`
* against a deadline bounds both hang shapes (silent never-exits AND
* endless streamers); on expiry the bash process is force-killed and
* a clear ResolutionFailed surfaces.
*/
private string function $drainWithDeadline(required any proc) {
var deadlineAt = getTickCount() + (variables.timeoutSeconds * 1000);
var stdoutStream = arguments.proc.getInputStream();
var buffer = createObject("java", "java.io.ByteArrayOutputStream").init();
while (arguments.proc.isAlive()) {
if (getTickCount() >= deadlineAt) {
arguments.proc.destroyForcibly();
throw(
type = "SecretResolver.ResolutionFailed",
message = "Resolving .kamal/secrets timed out after " & variables.timeoutSeconds
& " second(s) — a command inside the secrets file may be waiting for"
& " interactive input (e.g. a credential manager prompting for sign-in)."
& " The bash process was killed.",
detail = "Sign in to the secret manager in a terminal first, or raise the limit"
& " via SecretResolver init opts.timeoutSeconds for legitimately slow commands."
);
}
var avail = stdoutStream.available();
if (avail > 0) {
buffer.writeBytes(stdoutStream.readNBytes(avail));
} else {
sleep(25);
}
}
// Process exited — anything left in the pipe reads to EOF instantly.
buffer.writeBytes(stdoutStream.readAllBytes());
return buffer.toString("UTF-8");
}
private string function $shellEscape(required string path) {
return "'" & replace(arguments.path, "'", "'\''", "all") & "'";
}
}