340 lines
10 KiB
TypeScript
340 lines
10 KiB
TypeScript
/**
|
|
* Secret Masking Logic Tests
|
|
*
|
|
* Unit tests for the env var masking logic used in container inspect endpoints.
|
|
* Tests key-based matching and compose interpolation parsing.
|
|
*
|
|
* Run with: bun test tests/unit/secret-masking.test.ts
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import { parseEnvInterpolation } from '../src/lib/server/env-interpolation';
|
|
|
|
/**
|
|
* Mirrors the masking logic from containers/[id]/inspect/+server.ts
|
|
*/
|
|
function maskEnvVars(envEntries: string[], secretKeys: Set<string>): string[] {
|
|
return envEntries.map((entry) => {
|
|
const eqIdx = entry.indexOf('=');
|
|
if (eqIdx === -1) return entry;
|
|
const key = entry.substring(0, eqIdx);
|
|
if (secretKeys.has(key)) {
|
|
return `${key}=***`;
|
|
}
|
|
return entry;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Simulates getSecretKeysToMask: combines DB secret keys with provider-injected
|
|
* key names and interpolation mappings. Mirrors the real merge order in
|
|
* db.ts::getSecretKeysToMask (DB secrets + injected, THEN interpolation).
|
|
*/
|
|
function buildMaskSet(
|
|
dbSecretKeys: string[],
|
|
composeContent?: string,
|
|
injectedProviderKeys: string[] = []
|
|
): Set<string> {
|
|
const keys = new Set(dbSecretKeys);
|
|
for (const k of injectedProviderKeys) keys.add(k);
|
|
if (composeContent) {
|
|
const interpolated = parseEnvInterpolation(composeContent);
|
|
for (const [containerKey, varName] of interpolated) {
|
|
if (keys.has(varName)) {
|
|
keys.add(containerKey);
|
|
}
|
|
}
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
// ─── parseEnvInterpolation ─────────────────────────────────────────
|
|
|
|
describe('parseEnvInterpolation', () => {
|
|
test('extracts simple interpolation', () => {
|
|
const compose = `services:
|
|
app:
|
|
environment:
|
|
- MYSQL_PASSWORD=\${db_secret}
|
|
`;
|
|
const result = parseEnvInterpolation(compose);
|
|
expect(result).toEqual([['MYSQL_PASSWORD', 'db_secret']]);
|
|
});
|
|
|
|
test('extracts interpolation with default value', () => {
|
|
const compose = `services:
|
|
app:
|
|
environment:
|
|
- DB_HOST=\${database_host:-localhost}
|
|
`;
|
|
const result = parseEnvInterpolation(compose);
|
|
expect(result).toEqual([['DB_HOST', 'database_host']]);
|
|
});
|
|
|
|
test('extracts interpolation with error message', () => {
|
|
const compose = `services:
|
|
app:
|
|
environment:
|
|
- API_KEY=\${secret_key?API key is required}
|
|
`;
|
|
const result = parseEnvInterpolation(compose);
|
|
expect(result).toEqual([['API_KEY', 'secret_key']]);
|
|
});
|
|
|
|
test('extracts interpolation with alternate value', () => {
|
|
const compose = `services:
|
|
app:
|
|
environment:
|
|
- USE_SSL=\${ssl_enabled:+true}
|
|
`;
|
|
const result = parseEnvInterpolation(compose);
|
|
expect(result).toEqual([['USE_SSL', 'ssl_enabled']]);
|
|
});
|
|
|
|
test('extracts interpolation with short fallback (no colon)', () => {
|
|
const compose = `services:
|
|
app:
|
|
environment:
|
|
- DB_HOST=\${database_host-localhost}
|
|
`;
|
|
const result = parseEnvInterpolation(compose);
|
|
expect(result).toEqual([['DB_HOST', 'database_host']]);
|
|
});
|
|
|
|
test('extracts interpolation with required error (colon question)', () => {
|
|
const compose = `services:
|
|
app:
|
|
environment:
|
|
- API_KEY=\${secret_key:?API key must be set}
|
|
`;
|
|
const result = parseEnvInterpolation(compose);
|
|
expect(result).toEqual([['API_KEY', 'secret_key']]);
|
|
});
|
|
|
|
test('skips same-name interpolation', () => {
|
|
const compose = `services:
|
|
app:
|
|
environment:
|
|
- DB_PASS=\${DB_PASS}
|
|
`;
|
|
const result = parseEnvInterpolation(compose);
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
test('extracts multiple interpolations', () => {
|
|
const compose = `services:
|
|
app:
|
|
environment:
|
|
- MYSQL_USER=\${db_user}
|
|
- MYSQL_PASSWORD=\${db_pass}
|
|
- APP_NAME=myapp
|
|
`;
|
|
const result = parseEnvInterpolation(compose);
|
|
expect(result).toEqual([
|
|
['MYSQL_USER', 'db_user'],
|
|
['MYSQL_PASSWORD', 'db_pass']
|
|
]);
|
|
});
|
|
|
|
test('ignores non-environment lines', () => {
|
|
const compose = `services:
|
|
app:
|
|
image: \${IMAGE_NAME}
|
|
command: echo \${GREETING}
|
|
environment:
|
|
- REAL_VAR=\${secret_ref}
|
|
`;
|
|
const result = parseEnvInterpolation(compose);
|
|
expect(result).toEqual([['REAL_VAR', 'secret_ref']]);
|
|
});
|
|
|
|
test('handles multiple services', () => {
|
|
const compose = `services:
|
|
web:
|
|
environment:
|
|
- WEB_SECRET=\${shared_secret}
|
|
worker:
|
|
environment:
|
|
- WORKER_TOKEN=\${api_token}
|
|
`;
|
|
const result = parseEnvInterpolation(compose);
|
|
expect(result).toEqual([
|
|
['WEB_SECRET', 'shared_secret'],
|
|
['WORKER_TOKEN', 'api_token']
|
|
]);
|
|
});
|
|
|
|
test('handles empty compose', () => {
|
|
expect(parseEnvInterpolation('')).toEqual([]);
|
|
});
|
|
|
|
test('handles compose without environment section', () => {
|
|
const compose = `services:
|
|
app:
|
|
image: nginx:alpine
|
|
ports:
|
|
- "80:80"
|
|
`;
|
|
expect(parseEnvInterpolation(compose)).toEqual([]);
|
|
});
|
|
|
|
test('extracts multiple vars from one line', () => {
|
|
const compose = `services:
|
|
app:
|
|
environment:
|
|
- DB_URL=postgres://\${db_user}:\${db_pass}@host/db
|
|
`;
|
|
const result = parseEnvInterpolation(compose);
|
|
expect(result).toEqual([
|
|
['DB_URL', 'db_user'],
|
|
['DB_URL', 'db_pass']
|
|
]);
|
|
});
|
|
});
|
|
|
|
// ─── buildMaskSet (simulates getSecretKeysToMask) ──────────────────
|
|
|
|
describe('buildMaskSet: key + interpolation', () => {
|
|
test('direct key match only (no compose)', () => {
|
|
const keys = buildMaskSet(['DB_PASS', 'API_KEY']);
|
|
expect(keys.has('DB_PASS')).toBe(true);
|
|
expect(keys.has('API_KEY')).toBe(true);
|
|
expect(keys.has('OTHER')).toBe(false);
|
|
});
|
|
|
|
test('adds interpolated container keys from compose', () => {
|
|
const compose = `services:
|
|
app:
|
|
environment:
|
|
- MYSQL_PASSWORD=\${db_secret}
|
|
- APP_TOKEN=\${api_key}
|
|
`;
|
|
const keys = buildMaskSet(['db_secret', 'api_key'], compose);
|
|
// Direct keys
|
|
expect(keys.has('db_secret')).toBe(true);
|
|
expect(keys.has('api_key')).toBe(true);
|
|
// Interpolated container keys
|
|
expect(keys.has('MYSQL_PASSWORD')).toBe(true);
|
|
expect(keys.has('APP_TOKEN')).toBe(true);
|
|
});
|
|
|
|
test('does not add interpolated keys for non-secret vars', () => {
|
|
const compose = `services:
|
|
app:
|
|
environment:
|
|
- HOST_NAME=\${hostname}
|
|
- DB_PASS=\${secret_pw}
|
|
`;
|
|
// Only secret_pw is a secret, not hostname
|
|
const keys = buildMaskSet(['secret_pw'], compose);
|
|
expect(keys.has('DB_PASS')).toBe(true);
|
|
expect(keys.has('HOST_NAME')).toBe(false);
|
|
});
|
|
|
|
test('handles compose without interpolation', () => {
|
|
const compose = `services:
|
|
app:
|
|
environment:
|
|
- APP_NAME=myapp
|
|
- PORT=3000
|
|
`;
|
|
const keys = buildMaskSet(['DB_PASS'], compose);
|
|
expect(keys.has('DB_PASS')).toBe(true);
|
|
expect(keys.size).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ─── Provider-injected keys (H1) ───────────────────────────────────
|
|
// Bulk-pulled keys (Vault/Doppler) and promoted op:// refs live only in the
|
|
// provider, never in stack_env_vars, so getSecretKeysToMask merges the names
|
|
// persisted at deploy. Without that merge they leak plaintext in inspect.
|
|
|
|
describe('buildMaskSet: provider-injected keys', () => {
|
|
test('masks bulk-pulled keys that are NOT in the DB', () => {
|
|
// No DB secret rows at all - keys came entirely from a Vault bulk pull.
|
|
const keys = buildMaskSet([], undefined, ['DB_PASSWORD', 'REDIS_URL']);
|
|
expect(keys.has('DB_PASSWORD')).toBe(true);
|
|
expect(keys.has('REDIS_URL')).toBe(true);
|
|
});
|
|
|
|
test('masks a promoted op:// ref key alongside DB secrets', () => {
|
|
const keys = buildMaskSet(['DB_PASS'], undefined, ['API_KEY']);
|
|
expect(keys.has('DB_PASS')).toBe(true); // DB secret
|
|
expect(keys.has('API_KEY')).toBe(true); // promoted from .env op:// ref
|
|
});
|
|
|
|
test('injected keys still drive compose interpolation masking', () => {
|
|
// A bulk key `db_secret` is referenced by a container var via ${db_secret}.
|
|
const compose = `services:
|
|
app:
|
|
environment:
|
|
- MYSQL_PASSWORD=\${db_secret}
|
|
`;
|
|
const keys = buildMaskSet([], compose, ['db_secret']);
|
|
expect(keys.has('db_secret')).toBe(true);
|
|
expect(keys.has('MYSQL_PASSWORD')).toBe(true);
|
|
});
|
|
|
|
test('the leak case: WITHOUT injected keys a Vault secret is exposed', () => {
|
|
// Same container env, but the injected-keys merge is skipped (old behavior).
|
|
const leaky = buildMaskSet([], undefined, []);
|
|
const masked = maskEnvVars(['DB_PASSWORD=fromVault'], leaky);
|
|
expect(masked).toEqual(['DB_PASSWORD=fromVault']); // plaintext leaks
|
|
// With the merge, it is masked.
|
|
const safe = buildMaskSet([], undefined, ['DB_PASSWORD']);
|
|
expect(maskEnvVars(['DB_PASSWORD=fromVault'], safe)).toEqual(['DB_PASSWORD=***']);
|
|
});
|
|
});
|
|
|
|
// ─── Full masking pipeline ─────────────────────────────────────────
|
|
|
|
describe('maskEnvVars with interpolation-aware keys', () => {
|
|
test('masks direct secret keys', () => {
|
|
const keys = buildMaskSet(['DB_PASS']);
|
|
const result = maskEnvVars(
|
|
['DB_PASS=secret123', 'APP_NAME=myapp'],
|
|
keys
|
|
);
|
|
expect(result).toEqual(['DB_PASS=***', 'APP_NAME=myapp']);
|
|
});
|
|
|
|
test('masks interpolated secret keys', () => {
|
|
const compose = `services:
|
|
app:
|
|
environment:
|
|
- MYSQL_PASSWORD=\${db_secret}
|
|
`;
|
|
const keys = buildMaskSet(['db_secret'], compose);
|
|
const result = maskEnvVars(
|
|
['MYSQL_PASSWORD=hunter2', 'APP_NAME=myapp', 'db_secret=hunter2'],
|
|
keys
|
|
);
|
|
expect(result).toEqual(['MYSQL_PASSWORD=***', 'APP_NAME=myapp', 'db_secret=***']);
|
|
});
|
|
|
|
test('does not mask unrelated vars', () => {
|
|
const compose = `services:
|
|
app:
|
|
environment:
|
|
- MYSQL_PASSWORD=\${db_secret}
|
|
`;
|
|
const keys = buildMaskSet(['db_secret'], compose);
|
|
const result = maskEnvVars(
|
|
['PATH=/usr/bin', 'NODE_ENV=production', 'PORT=3000'],
|
|
keys
|
|
);
|
|
expect(result).toEqual(['PATH=/usr/bin', 'NODE_ENV=production', 'PORT=3000']);
|
|
});
|
|
|
|
test('no false positives on matching values', () => {
|
|
// Even though PORT has value "3000" which could appear elsewhere,
|
|
// it should NOT be masked because there's no key or interpolation match
|
|
const keys = buildMaskSet(['SECRET_KEY']);
|
|
const result = maskEnvVars(
|
|
['PORT=3000', 'SECRET_KEY=3000'],
|
|
keys
|
|
);
|
|
expect(result).toEqual(['PORT=3000', 'SECRET_KEY=***']);
|
|
});
|
|
});
|