Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
21 changes: 21 additions & 0 deletions design/expressions.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,27 @@ attributes:
keyData: ${{ vault.get(aws, machineKeyData) }}
```

### Shell Safety

When vault secrets are injected into the `run` field of a `command/shell` model via CEL
string concatenation, shell metacharacters in the secret value are automatically escaped so
that the value is always treated as **literal data**, never as shell syntax.

Specifically, `$` and `` ` `` are escaped so that `$(cmd)` and `` `cmd` `` in a secret
value do not trigger command substitution:

```yaml
# Secret value: $(cat /etc/passwd)
# Shell receives: \$(cat /etc/passwd) → outputs literally: $(cat /etc/passwd)
attributes:
run: '"echo " + vault.get(''my-vault'', ''SECRET'') + '' done'''
```

This means:
- `$VAR_NAME` in a secret is **not** expanded as a shell variable — it appears literally.
- `$(cmd)` and `` `cmd` `` in a secret are **not** executed — they appear literally.
- Prices, connection strings, and other data containing `$` are safe to store and use.

## Environment Variables

All process environment variables are available in CEL expressions via the `env`
Expand Down
9 changes: 6 additions & 3 deletions integration/vault_cel_integration_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -726,9 +726,12 @@ Deno.test("Vault CEL: handles secrets with special characters", async () => {
]);

// Store secret with special characters
// Note: Avoiding characters that require escaping in CEL strings
// (backslashes, quotes, newlines) as these are escaped for CEL parsing
// Note: $ and ` are escaped with a \\ prefix so that after CEL evaluation
// the shell receives \$ and \` (literal characters, no command substitution).
// Backslashes, quotes, and newlines are also escaped for CEL string safety.
const specialSecret = "p@ssw0rd!#$%^&*()_+-=[]{}|;:.<>?/";
// After escaping, $ becomes \$ so the resolved CEL value includes the backslash
const expectedResolved = "p@ssw0rd!#\\$%^&*()_+-=[]{}|;:.<>?/";
const vaultServiceForPut = await VaultService.fromRepository(repoDir);
await vaultServiceForPut.put(
"special-chars-vault",
Expand Down Expand Up @@ -760,7 +763,7 @@ Deno.test("Vault CEL: handles secrets with special characters", async () => {
result.definition,
);

assertEquals(resolved.globalArguments.password, specialSecret);
assertEquals(resolved.globalArguments.password, expectedResolved);
});
});

Expand Down
8 changes: 6 additions & 2 deletions src/domain/expressions/model_resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -788,12 +788,16 @@ export class ModelResolver {
const [fullMatch, , vaultName, , secretKey] = match;
try {
const secretValue = await vaultService.get(vaultName, secretKey);
// Escape special characters to prevent CEL parsing issues and injection attacks
// Escape special characters to prevent CEL parsing issues and injection attacks.
// For $ and `, we use a \\X prefix (two backslashes + char) so that:
// - CEL sees \\ → produces one \, then the char is a plain literal
// - Shell receives \$ or \` → literal character, no command substitution
const escapedValue = secretValue
.replace(/\\/g, "\\\\")
.replace(/\$/g, "\\\\$") // $ → \\$ so CEL produces \$, shell treats as literal $
.replace(/`/g, "\\\\`") // ` → \\` so CEL produces \`, shell treats as literal `
.replace(/"/g, '\\"')
.replace(/'/g, "\\'")
.replace(/`/g, "\\`")
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t");
Expand Down
55 changes: 42 additions & 13 deletions src/domain/vaults/vault_expression_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ Deno.test("ModelResolver.resolveVaultExpressions", async (t) => {
const result = await resolver.resolveVaultExpressions(
"vault.get(test-vault, backtick-secret)",
);
assertEquals(result, '"value\\`with\\`backticks"');
assertEquals(result, '"value\\\\`with\\\\`backticks"');
});

await t.step(
Expand Down Expand Up @@ -255,80 +255,109 @@ Deno.test("ModelResolver.resolveVaultExpressions", async (t) => {
});

await t.step(
"should preserve $& pattern in secret values",
"should escape $ in secret values to prevent shell variable expansion",
async () => {
const resolver = createResolverWithMockVault({
"dollar-amp": "my$&secret",
});
const result = await resolver.resolveVaultExpressions(
"vault.get(test-vault, dollar-amp)",
);
assertEquals(result, '"my$&secret"');
assertEquals(result, '"my\\\\$&secret"');
},
);

await t.step(
"should preserve $` pattern in secret values",
"should escape $` pattern in secret values",
async () => {
const resolver = createResolverWithMockVault({
"dollar-backtick": "prefix$`suffix",
});
const result = await resolver.resolveVaultExpressions(
"vault.get(test-vault, dollar-backtick)",
);
assertEquals(result, '"prefix$\\`suffix"');
assertEquals(result, '"prefix\\\\$\\\\`suffix"');
},
);

await t.step(
"should preserve $' pattern in secret values",
"should escape $' pattern in secret values",
async () => {
const resolver = createResolverWithMockVault({
"dollar-quote": "prefix$'suffix",
});
const result = await resolver.resolveVaultExpressions(
"vault.get(test-vault, dollar-quote)",
);
assertEquals(result, '"prefix$\\\'suffix"');
assertEquals(result, '"prefix\\\\$\\\'suffix"');
},
);

await t.step(
"should preserve $$ pattern in secret values",
"should escape $$ pattern in secret values",
async () => {
const resolver = createResolverWithMockVault({
"dollar-dollar": "cost: $$100",
});
const result = await resolver.resolveVaultExpressions(
"vault.get(test-vault, dollar-dollar)",
);
assertEquals(result, '"cost: $$100"');
assertEquals(result, '"cost: \\\\$\\\\$100"');
},
);

await t.step(
"should preserve numbered capture group patterns in secret values",
"should escape numbered dollar patterns in secret values",
async () => {
const resolver = createResolverWithMockVault({
"dollar-numbers": "$1$2$3",
});
const result = await resolver.resolveVaultExpressions(
"vault.get(test-vault, dollar-numbers)",
);
assertEquals(result, '"$1$2$3"');
assertEquals(result, '"\\\\$1\\\\$2\\\\$3"');
},
);

await t.step(
"should preserve multiple dollar patterns in same secret",
"should escape multiple dollar and backtick patterns in same secret",
async () => {
const resolver = createResolverWithMockVault({
"multi-dollar": "a]$&b$`c$'d$$e$1f",
});
const result = await resolver.resolveVaultExpressions(
"vault.get(test-vault, multi-dollar)",
);
assertEquals(result, '"a]$&b$\\`c$\\\'d$$e$1f"');
assertEquals(
result,
'"a]\\\\$&b\\\\$\\\\`c\\\\$\\\'d\\\\$\\\\$e\\\\$1f"',
);
},
);

await t.step(
"should escape $ to prevent shell command substitution",
async () => {
const resolver = createResolverWithMockVault({
"cmd-secret": "$(echo injected)",
});
const result = await resolver.resolveVaultExpressions(
"vault.get(test-vault, cmd-secret)",
);
assertEquals(result, '"\\\\$(echo injected)"');
},
);

await t.step(
"should escape backticks to prevent shell command substitution",
async () => {
const resolver = createResolverWithMockVault({
"backtick-cmd": "`echo injected`",
});
const result = await resolver.resolveVaultExpressions(
"vault.get(test-vault, backtick-cmd)",
);
assertEquals(result, '"\\\\`echo injected\\\\`"');
},
);

Expand Down