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
13 changes: 13 additions & 0 deletions src/domain/expressions/expression_evaluation_service_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,19 @@ Deno.test("containsVaultExpression returns false for vault-like but not vault.ge
assertEquals(containsVaultExpression("vault_get(foo)"), false);
});

Deno.test("containsVaultExpression returns true for quoted args with spaces", () => {
assertEquals(
containsVaultExpression('vault.get("infra", "Client ID")'),
true,
);
assertEquals(
containsVaultExpression(
'vault.get("infra", "Tailscale K8s Operator/Client ID")',
),
true,
);
});

// ============================================================================
// containsEnvExpression
// ============================================================================
Expand Down
16 changes: 12 additions & 4 deletions src/domain/expressions/model_resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -825,10 +825,14 @@ export class ModelResolver {
redactor?: SecretRedactor,
secretBag?: VaultSecretBag,
): Promise<string> {
// Pattern to match vault.get(vaultName, secretKey) expressions
// Handles both quoted and unquoted arguments
// Pattern to match vault.get(vaultName, secretKey) expressions.
// Handles both quoted and unquoted arguments. Quoted arguments may
// contain spaces (e.g. vault.get("infra", "Client ID")).
// Each argument uses alternation:
// (['"`])(.+?)\1 — quoted: any chars up to the matching close quote
// ([^\s,)]+) — unquoted: non-whitespace, non-comma, non-paren
const vaultPattern =
/vault\.get\(\s*(['"`]?)([^'"`\s,]+)\1\s*,\s*(['"`]?)([^'"`\s,]+)\3\s*\)/g;
/vault\.get\(\s*(?:(['"`])(.+?)\1|([^\s,)]+))\s*,\s*(?:(['"`])(.+?)\4|([^\s,)]+))\s*\)/g;

let resolvedValue = value;
const matches = Array.from(value.matchAll(vaultPattern));
Expand All @@ -841,7 +845,11 @@ export class ModelResolver {
const vaultService = await this.getVaultService();

for (const match of matches) {
const [fullMatch, , vaultName, , secretKey] = match;
// Groups: [1]=quote1, [2]=quoted vault, [3]=unquoted vault,
// [4]=quote2, [5]=quoted key, [6]=unquoted key
const fullMatch = match[0];
const vaultName = match[2] ?? match[3];
const secretKey = match[5] ?? match[6];
try {
const secretValue = await vaultService.get(vaultName, secretKey);
redactor?.addSecret(secretValue);
Expand Down
94 changes: 94 additions & 0 deletions src/domain/vaults/vault_expression_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,18 @@ Deno.test("ModelResolver.resolveVaultExpressions", async (t) => {
return new ModelResolver(definitionRepo, { vaultService });
}

// Helper to create a ModelResolver with multiple named vaults
function createResolverWithNamedVaults(
vaults: Record<string, Record<string, string>>,
): ModelResolver {
const vaultService = new VaultService();
for (const [name, secrets] of Object.entries(vaults)) {
vaultService.registerVault({ name, type: "mock", config: secrets });
}
const definitionRepo = new YamlDefinitionRepository(tempDir);
return new ModelResolver(definitionRepo, { vaultService });
}

await t.step("should resolve basic vault expression", async () => {
const resolver = createResolverWithMockVault({ "api-key": "secret123" });
const result = await resolver.resolveVaultExpressions(
Expand Down Expand Up @@ -365,6 +377,88 @@ Deno.test("ModelResolver.resolveVaultExpressions", async (t) => {
},
);

// ========================================================================
// Spaces in quoted vault names and secret keys (#902)
// ========================================================================

await t.step(
"should resolve vault expression with spaces in double-quoted key",
async () => {
const resolver = createResolverWithMockVault({
"Client ID": "my-client-id",
});
const result = await resolver.resolveVaultExpressions(
'vault.get("test-vault", "Client ID")',
);
assertEquals(result, '"my-client-id"');
},
);

await t.step(
"should resolve vault expression with spaces in single-quoted key",
async () => {
const resolver = createResolverWithMockVault({
"Client Secret": "super-secret",
});
const result = await resolver.resolveVaultExpressions(
"vault.get('test-vault', 'Client Secret')",
);
assertEquals(result, '"super-secret"');
},
);

await t.step(
"should resolve vault expression with 1Password-style path containing spaces",
async () => {
const resolver = createResolverWithMockVault({
"Tailscale K8s Operator/Client ID": "ts-client-id-123",
});
const result = await resolver.resolveVaultExpressions(
'vault.get("test-vault", "Tailscale K8s Operator/Client ID")',
);
assertEquals(result, '"ts-client-id-123"');
},
);

await t.step(
"should resolve vault expression with spaces in quoted vault name",
async () => {
const resolver = createResolverWithNamedVaults({
"my infra vault": { "api-key": "infra-key-456" },
});
const result = await resolver.resolveVaultExpressions(
'vault.get("my infra vault", "api-key")',
);
assertEquals(result, '"infra-key-456"');
},
);

await t.step(
"should resolve vault expression with spaces in both vault name and key",
async () => {
const resolver = createResolverWithNamedVaults({
"my infra vault": { "Client Secret": "both-spaces-val" },
});
const result = await resolver.resolveVaultExpressions(
'vault.get("my infra vault", "Client Secret")',
);
assertEquals(result, '"both-spaces-val"');
},
);

await t.step(
"should resolve mixed quoted vault name with unquoted key",
async () => {
const resolver = createResolverWithNamedVaults({
"my infra vault": { "api-key": "mixed-val" },
});
const result = await resolver.resolveVaultExpressions(
'vault.get("my infra vault", api-key)',
);
assertEquals(result, '"mixed-val"');
},
);

// Cleanup
await Deno.remove(tempDir, { recursive: true });
});
Loading