mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-10-07 16:18:58 +00:00
Harden auth + finance redaction (sh-security-review confirmed mediums)
Two confirmed medium findings from the agentic security review:
- Fail-open SH_MCP_ENV: config defaulted to 'local' when the var was unset,
so a deploy that forgot SH_MCP_ENV=aws would silently run LocalAuthProvider
and accept static dev bearer tokens (dev-finance-admin -> finance:admin).
Now fail-closed: SH_MCP_ENV must be explicitly 'local' or 'aws' or the
server refuses to start. Plus an independent guard in LocalAuthProvider
that refuses to construct in an AWS runtime (AWS_LAMBDA_FUNCTION_NAME /
AWS_EXECUTION_ENV present), regardless of the env flag.
- Finance egress redaction gap: redactDeep only wholesale-masked a sensitive
key when its value was a scalar; an object/array under a sensitive key was
recursed into, letting a bare nested value (e.g. {account:{number:...}})
escape the keyword-gated pattern matcher. Now the entire subtree under a
sensitive key is masked. No current finance tool emitted such shapes (all
flat strings), so this closes a latent hole in the universal safety net.
+4 tests (subtree redaction, AWS-runtime guard). 411 pass; coverage gate green.
Review also produced lows (memo free-text digits, unsalted argsHash,
unauth /openapi.json by-design, session-cap no-reset by-design) tracked
separately; 0 confirmed critical/high — review verdict PASS.
This commit is contained in:
parent
f4111e4e17
commit
c5b7550eaa
6 changed files with 69 additions and 5 deletions
|
|
@ -276,4 +276,18 @@ describe('redactDeep', () => {
|
||||||
expect(redactDeep(null)).toBe(null);
|
expect(redactDeep(null)).toBe(null);
|
||||||
expect(redactDeep(true)).toBe(true);
|
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<string, unknown>;
|
||||||
|
expect(out['account']).toBe('[REDACTED]');
|
||||||
|
expect(out['cardNumber']).toBe('[REDACTED]');
|
||||||
|
expect(JSON.stringify(out)).not.toContain('021000021');
|
||||||
|
expect(JSON.stringify(out)).not.toContain('4111111111111111');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,13 @@ export function redactDeep(value: unknown): unknown {
|
||||||
if (value !== null && typeof value === 'object') {
|
if (value !== null && typeof value === 'object') {
|
||||||
const out: Record<string, unknown> = {};
|
const out: Record<string, unknown> = {};
|
||||||
for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
|
for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
|
||||||
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]';
|
out[key] = '[REDACTED]';
|
||||||
} else {
|
} else {
|
||||||
out[key] = redactDeep(v);
|
out[key] = redactDeep(v);
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,26 @@ function bearer(token: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('LocalAuthProvider safety', () => {
|
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', () => {
|
it('refuses to construct unless SH_MCP_ENV=local', () => {
|
||||||
expect(
|
expect(
|
||||||
() =>
|
() =>
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,17 @@ export class LocalAuthProvider implements AuthProvider {
|
||||||
`(got SH_MCP_ENV=${JSON.stringify(config.env)}). It must never run in production.`,
|
`(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.audience = config.audience;
|
||||||
this.principals = config.principals;
|
this.principals = config.principals;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,15 @@ function readEnv(name: string): string | undefined {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadFinanceConfig(): FinanceConfig {
|
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') {
|
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 env = rawEnv;
|
||||||
const port = Number(readEnv('PORT') ?? '8082');
|
const port = Number(readEnv('PORT') ?? '8082');
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,16 @@ function readEnv(name: string): string | undefined {
|
||||||
|
|
||||||
/** Parse and validate the process environment into an {@link OpsConfig}. */
|
/** Parse and validate the process environment into an {@link OpsConfig}. */
|
||||||
export function loadOpsConfig(): 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') {
|
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 env = rawEnv;
|
||||||
const port = Number(readEnv('PORT') ?? '8081');
|
const port = Number(readEnv('PORT') ?? '8081');
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue