diff --git a/packages/shared/src/dispatch.test.ts b/packages/shared/src/dispatch.test.ts index d6d9dff..a49fc07 100644 --- a/packages/shared/src/dispatch.test.ts +++ b/packages/shared/src/dispatch.test.ts @@ -276,4 +276,18 @@ describe('redactDeep', () => { expect(redactDeep(null)).toBe(null); expect(redactDeep(true)).toBe(true); }); + + it('masks the ENTIRE subtree when a sensitive key holds an object or array (no nested escape)', () => { + // A bare value nested under a sensitive key has no keyword context, so it + // would slip past the pattern matcher if redactDeep recursed. The whole + // subtree must be masked instead. + const out = redactDeep({ + account: { number: '021000021', branch: 'main' }, + cardNumber: ['4111111111111111', '5500005555555559'], + }) as Record; + expect(out['account']).toBe('[REDACTED]'); + expect(out['cardNumber']).toBe('[REDACTED]'); + expect(JSON.stringify(out)).not.toContain('021000021'); + expect(JSON.stringify(out)).not.toContain('4111111111111111'); + }); }); diff --git a/packages/shared/src/dispatch.ts b/packages/shared/src/dispatch.ts index f1a2da5..9980833 100644 --- a/packages/shared/src/dispatch.ts +++ b/packages/shared/src/dispatch.ts @@ -137,7 +137,13 @@ export function redactDeep(value: unknown): unknown { if (value !== null && typeof value === 'object') { const out: Record = {}; for (const [key, v] of Object.entries(value as Record)) { - if (SENSITIVE_FIELD_RE.test(key) && v !== null && v !== undefined && typeof v !== 'object') { + if (SENSITIVE_FIELD_RE.test(key) && v !== null && v !== undefined) { + // Key looks sensitive → mask the ENTIRE value wholesale, whether it is a + // scalar, an array, or a nested object. Recursing into a non-scalar here + // would lose the keyword context redact() needs, letting a bare nested + // value (e.g. { account: { number: "021000021" } }) escape unmasked. + // Over-masking on the finance tier is the correct trade: a false negative + // is a PII leak (design.md §2.5). out[key] = '[REDACTED]'; } else { out[key] = redactDeep(v); diff --git a/packages/shared/src/local-auth.test.ts b/packages/shared/src/local-auth.test.ts index 813e515..31a0d89 100644 --- a/packages/shared/src/local-auth.test.ts +++ b/packages/shared/src/local-auth.test.ts @@ -17,6 +17,26 @@ function bearer(token: string) { } describe('LocalAuthProvider safety', () => { + it('refuses to construct inside an AWS runtime even when env=local', () => { + for (const v of ['AWS_LAMBDA_FUNCTION_NAME', 'AWS_EXECUTION_ENV']) { + const prev = process.env[v]; + process.env[v] = 'sh-mcp-finance'; + try { + expect( + () => + new LocalAuthProvider({ + audience: OPS_AUDIENCE, + principals: defaultLocalPrincipals(), + env: 'local', + }), + ).toThrow(/refuses to run inside an AWS/); + } finally { + if (prev === undefined) delete process.env[v]; + else process.env[v] = prev; + } + } + }); + it('refuses to construct unless SH_MCP_ENV=local', () => { expect( () => diff --git a/packages/shared/src/local-auth.ts b/packages/shared/src/local-auth.ts index 58015af..482f603 100644 --- a/packages/shared/src/local-auth.ts +++ b/packages/shared/src/local-auth.ts @@ -97,6 +97,17 @@ export class LocalAuthProvider implements AuthProvider { `(got SH_MCP_ENV=${JSON.stringify(config.env)}). It must never run in production.`, ); } + // Defense in depth, independent of the env flag: refuse to run in a real AWS + // runtime. The env check above can be defeated by a misconfiguration that + // resolves env to 'local' in a deployed context; this positive prod signal + // (set by Lambda / the AWS runtime) cannot. Static dev tokens must never + // authenticate anywhere AWS is executing this code. + if (process.env['AWS_LAMBDA_FUNCTION_NAME'] || process.env['AWS_EXECUTION_ENV']) { + throw new Error( + 'LocalAuthProvider refuses to run inside an AWS Lambda/execution context ' + + '(AWS_LAMBDA_FUNCTION_NAME / AWS_EXECUTION_ENV present). Dev auth is local-only.', + ); + } this.audience = config.audience; this.principals = config.principals; } diff --git a/servers/sh-mcp-finance/src/config.ts b/servers/sh-mcp-finance/src/config.ts index 6724187..d5b06c6 100644 --- a/servers/sh-mcp-finance/src/config.ts +++ b/servers/sh-mcp-finance/src/config.ts @@ -28,9 +28,15 @@ function readEnv(name: string): string | undefined { } export function loadFinanceConfig(): FinanceConfig { - const rawEnv = readEnv('SH_MCP_ENV') ?? 'local'; + // Fail CLOSED: SH_MCP_ENV must be set explicitly. An unset value must NEVER + // silently select local mode (LocalAuthProvider + static dev bearer tokens) on + // the finance service. A misconfigured deploy refuses to start. + const rawEnv = readEnv('SH_MCP_ENV'); if (rawEnv !== 'local' && rawEnv !== 'aws') { - throw new Error(`SH_MCP_ENV must be "local" or "aws" (got "${rawEnv}").`); + throw new Error( + `SH_MCP_ENV must be explicitly set to "local" or "aws" ` + + `(got ${rawEnv === undefined ? 'unset' : `"${rawEnv}"`}); refusing to start.`, + ); } const env = rawEnv; const port = Number(readEnv('PORT') ?? '8082'); diff --git a/servers/sh-mcp-ops/src/config.ts b/servers/sh-mcp-ops/src/config.ts index 3ce857b..53b40bc 100644 --- a/servers/sh-mcp-ops/src/config.ts +++ b/servers/sh-mcp-ops/src/config.ts @@ -32,9 +32,16 @@ function readEnv(name: string): string | undefined { /** Parse and validate the process environment into an {@link OpsConfig}. */ export function loadOpsConfig(): OpsConfig { - const rawEnv = readEnv('SH_MCP_ENV') ?? 'local'; + // Fail CLOSED: SH_MCP_ENV must be set explicitly. An unset value must NEVER + // silently select local mode (which wires LocalAuthProvider + static dev + // bearer tokens). A misconfigured deploy should refuse to start, not run dev + // auth on a finance/ops service. + const rawEnv = readEnv('SH_MCP_ENV'); if (rawEnv !== 'local' && rawEnv !== 'aws') { - throw new Error(`SH_MCP_ENV must be "local" or "aws" (got "${rawEnv}").`); + throw new Error( + `SH_MCP_ENV must be explicitly set to "local" or "aws" ` + + `(got ${rawEnv === undefined ? 'unset' : `"${rawEnv}"`}); refusing to start.`, + ); } const env = rawEnv; const port = Number(readEnv('PORT') ?? '8081');