Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cli/lucli/services/deploy/cli/DeploySecretsCli.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ component {
if (!len(key)) return "";
for (var line in listToArray(text, chr(10), false)) {
var eq = find("=", line);
if (eq > 0 && left(line, eq - 1) == key) {
// eq > 1, not > 0: a line starting with '=' has no key, and
// left(line, 0) crashes Lucee 7 (Cross-Engine Invariant 8).
if (eq > 1 && left(line, eq - 1) == key) {
return mid(line, eq + 1, 99999);
}
}
Expand Down
140 changes: 98 additions & 42 deletions cli/lucli/services/deploy/lib/SecretResolver.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,24 @@
* 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).
*/
component {

Expand Down Expand Up @@ -52,65 +65,108 @@ component {
}

/**
* Run the given secrets file through `bash -c 'set -a; source FILE; env'`
* and parse the resulting env block. The difference between our new env
* and a baseline `env` capture gives us just the keys introduced by the
* file (including $() expansions, since bash resolves those during source).
* 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 {};

// Step 1: capture baseline env so we can subtract it later.
var baseline = $runBash("env");
var baselineKeys = $parseEnvKeys(baseline);

// Step 2: source the file, then emit env. `set -a` exports all vars.
var cmd = "set -a; source " & $shellEscape(arguments.path) & "; env";
var enriched = $runBash(cmd);
var candidates = $candidateKeys(fileRead(arguments.path, "UTF-8"));
if (!arrayLen(candidates)) return {};

var out = {};
for (var line in listToArray(enriched, chr(10), false)) {
var eq = find("=", line);
if (eq < 1) continue;
var key = left(line, eq - 1);
var val = mid(line, eq + 1, 99999);
// Only keep keys introduced by the file (not baseline).
if (!arrayContainsNoCase(baselineKeys, key)) {
out[key] = val;
}
// `set -a` exports every assignment made while sourcing; the file's
// own stdout is discarded so it can't corrupt the record stream.
// `${!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 -a; 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 out;
return $parseRecords(result.out);
}

private array function $parseEnvKeys(required string envBlock) {
/**
* 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.envBlock, chr(10), false)) {
var eq = find("=", line);
if (eq > 0) arrayAppend(keys, left(line, eq - 1));
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;
}

private string function $runBash(required string cmd) {
/**
* 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) {
var proc = "";
try {
var pb = createObject("java", "java.lang.ProcessBuilder").init(["bash", "-c", arguments.cmd]);
pb.redirectErrorStream(true);
var proc = pb.start();
var reader = createObject("java", "java.io.BufferedReader").init(
createObject("java", "java.io.InputStreamReader").init(proc.getInputStream(), "UTF-8")
);
var sb = createObject("java", "java.lang.StringBuilder").init();
var line = reader.readLine();
while (!isNull(line)) {
sb.append(line);
sb.append(chr(10));
line = reader.readLine();
}
proc.waitFor();
return sb.toString();
proc = pb.start();
} catch (any e) {
return "";
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 = $readStream(proc.getInputStream());
var err = $readStream(proc.getErrorStream());
var exitCode = proc.waitFor();
return {exitCode: exitCode, out: out, err: err};
}

private string function $readStream(required any inputStream) {
var scanner = createObject("java", "java.util.Scanner").init(arguments.inputStream, "UTF-8");
scanner.useDelimiter("\A");
var content = scanner.hasNext() ? scanner.next() : "";
scanner.close();
return content;
}

private string function $shellEscape(required string path) {
Expand Down
8 changes: 8 additions & 0 deletions cli/lucli/tests/specs/deploy/cli/DeploySecretsCliSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ component extends="wheels.wheelstest.system.BaseSpec" {
expect(cli.extract({key: "", from: "FOO=bar"})).toBe("");
});

it("extract skips malformed lines that start with '='", () => {
// left(line, 0) on a keyless '=...' line crashes Lucee 7
// (Cross-Engine Invariant 8); such lines must be skipped.
var cli = new cli.lucli.services.deploy.cli.DeploySecretsCli();
var block = "=stray-value" & chr(10) & "FOO=bar";
expect(cli.extract({key: "FOO", from: block})).toBe("bar");
});

it("resolves 1password and op as the same adapter", () => {
var cli = new cli.lucli.services.deploy.cli.DeploySecretsCli();
var stub = new cli.lucli.tests.specs.deploy.secrets._stubs.StubOnePasswordAdapter();
Expand Down
42 changes: 42 additions & 0 deletions cli/lucli/tests/specs/deploy/lib/SecretResolverSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,48 @@ component extends="wheels.wheelstest.system.BaseSpec" {
expect(r.has("PRESENT")).toBeTrue();
expect(r.has("MISSING")).toBeFalse();
});

it("resolves keys that also exist in the parent environment", () => {
// HOME is always set in the parent env; the old baseline
// subtraction dropped such keys, yielding "" for them.
fileWrite(variables.tempRoot & "/.kamal/secrets", "HOME=/tmp/wheels-secret-override");
var r = new cli.lucli.services.deploy.lib.SecretResolver({
projectRoot: variables.tempRoot
});
expect(r.get("HOME")).toBe("/tmp/wheels-secret-override");
});

it("preserves multi-line quoted values like certificates", () => {
var cert = "-----BEGIN CERTIFICATE-----" & chr(10)
& "dGVzdA==" & chr(10)
& "-----END CERTIFICATE-----";
fileWrite(variables.tempRoot & "/.kamal/secrets", 'CERT="' & cert & '"');
var r = new cli.lucli.services.deploy.lib.SecretResolver({
projectRoot: variables.tempRoot
});
expect(r.get("CERT")).toBe(cert);
// base64 continuation lines must not be misparsed as keys
expect(r.has("dGVzdA")).toBeFalse();
});

it("preserves values whose continuation lines begin with '='", () => {
// A continuation line starting with '=' previously reached
// left(line, 0), which crashes Lucee 7 (Cross-Engine Invariant 8).
fileWrite(variables.tempRoot & "/.kamal/secrets",
'WEIRD="line1' & chr(10) & '=line2"');
var r = new cli.lucli.services.deploy.lib.SecretResolver({
projectRoot: variables.tempRoot
});
expect(r.get("WEIRD")).toBe("line1" & chr(10) & "=line2");
});

it("supports export-prefixed declarations", () => {
fileWrite(variables.tempRoot & "/.kamal/secrets", "export TOKEN=abc123");
var r = new cli.lucli.services.deploy.lib.SecretResolver({
projectRoot: variables.tempRoot
});
expect(r.get("TOKEN")).toBe("abc123");
});
});
}
}
Loading