diff --git a/src/protect/defaults.js b/src/protect/defaults.js index 7836a404..020746d8 100644 --- a/src/protect/defaults.js +++ b/src/protect/defaults.js @@ -10,18 +10,40 @@ // the scan entirely. This cuts CPU/latency and shrinks the regex/ReDoS surface. (Honored by the // response phase once the prefilter mechanism lands; a no-op before that.) -// Response phase — secret / info exposure. Default action `redact` masks only the -// offending span and still serves the page (a legit response that leaks one key gets -// that key masked, not withheld). Use `action: "block"` to withhold the whole response. +// Response phase — secret / info exposure. +// +// `redact` masks the span a pattern matched and serves the rest of the page. It is used only where the +// match is the whole of the disclosure, which holds for a credential with a grammar: a provider token +// has a prefix, an alphabet and a length, so matching it matches all of it. +// +// `block` withholds the response and replaces it with a generic error. It is used where a pattern can +// identify a disclosure but not delimit it — a private key, a stack trace, a database error, an +// exception dump, a connection URI in free text. The material that matters sits after the part the +// pattern can recognise, so a mask would leave file names, line numbers, query text, frames, key +// material or a host and database name in a response that reports itself protected. export const DEFAULT_RESPONSE_RULES = [ { id: 'resp-private-key', title: 'Private key in response body', phase: 'response', category: 'secret-exposure', - action: 'redact', + // Withheld: the key material sits after the marker and this pattern does not delimit it. + action: 'block', prefilter: ['PRIVATE KEY'], - rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/' } }] + // Matches a PEM BEGIN line whose label contains `PRIVATE KEY`, with up to 32 further label + // characters — uppercase letters, digits, spaces and hyphens — on either side of it. That covers + // the enumerated types (`RSA`, `EC`, `OPENSSH`, `DSA`, `ENCRYPTED`), a label carrying words after + // `PRIVATE KEY` (`PGP PRIVATE KEY BLOCK`), a hyphenated or otherwise unlisted type, and a bare + // `-----BEGIN PRIVATE KEY-----`. `PUBLIC KEY` and `CERTIFICATE` do not match, and neither does + // prose that mentions a private key without a BEGIN line. + // + // No footer required: a truncated response or an absent END marker does not make the material + // above it less of a key. Two bounded character classes rather than a repeated group — a + // quantifier inside a quantified group is the shape the engine refuses as a backtracking risk, and + // a refused pattern is a rule that never fires. + // + // A lowercase label, or one longer than 32 characters on either side, is not matched. + rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/-----BEGIN [A-Z0-9 -]{0,32}PRIVATE KEY[A-Z0-9 -]{0,32}-----/' } }] }, { id: 'resp-aws-access-key', @@ -98,26 +120,66 @@ export const DEFAULT_RESPONSE_RULES = [ title: 'Database connection string with credentials in response body', phase: 'response', category: 'secret-exposure', - action: 'redact', + // Withheld. The credentials are only the first half of the disclosure — the host, port, database + // name and query name the system they open — and a URI's own grammar admits commas, parentheses + // and semicolons, so no end-of-URI character class delimits it in free text without either + // stopping inside a real URI or consuming the punctuation around it. The pattern therefore + // identifies the URI and the response is withheld rather than partly rewritten. + action: 'block', prefilter: ['mongodb', 'postgres', 'mysql', 'redis', 'amqp'], - rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[^\\s:@\\/]+:[^\\s:@\\/]+@/i' } }] + // A scheme this application connects with, a user, a password and an `@`. Credentials are + // required, so a URL without them is not matched. + // + // The username is the userinfo grammar without `:`; the password is the same grammar with it. Every + // other character either class admits may appear raw in a real credential, so a narrower one turns + // a live credential into a rule that says nothing — `postgres://user:p;ss@host` is an ordinary DSN. + // + // The first raw colon separates username from password. Excluding it from the username makes the + // separator unambiguous and keeps matching linear; the password continues to admit raw colons. A + // username that contains a colon carries it as `%3A`. + // + // Both classes admitting `:` would leave every colon available as the separator, so a candidate + // run with no `@` is re-split at every position. The screening cap does not bound that: a rule may + // raise it with `max_bytes` or remove it with `bypass_limit`. + // + // What terminates a candidate is everything the grammar excludes: whitespace, `/`, `?`, `#`, `@`, + // quotes, backslashes, angle and square brackets and braces. That is what keeps the run inside one + // value. Expressed as "anything but `:`, `@`, `/` and whitespace" it crosses structure instead: in + // `{"docs":"postgres://db.internal","contact":"user@example.com"}` it consumes the closing quote, + // the comma and the next key, reaching the `:` and `@` of an unrelated property and withholding a + // response that discloses nothing. + // + // The consequence is deliberate: a run of punctuation-joined text that parses as a credential URI + // is treated as one. `postgres://db.internal;contact:admin@example.com` has username + // `db.internal;contact`, password `admin` and host `example.com` — indistinguishable from a leak, + // so it is withheld. Ordinary prose separates with whitespace, which terminates the candidate. + + rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[A-Za-z0-9._~%!$&\'()*+,;=-]+:[A-Za-z0-9._~%!$&\'()*+,;=:-]+@/i' } }] }, { id: 'resp-stack-trace', title: 'Node stack trace leaking in response body', phase: 'response', category: 'info-exposure', - action: 'redact', + // Withheld. A frame is recognised by its shape and the trace has no end the pattern can rely on, + // so masking the frames it happens to match leaves the message, the remaining frames and every + // path and line number in them. + action: 'block', // No prefilter: a Node stack frame has no single distinctive literal (` at ` is too common to // gate on). The pattern is linearly bounded per line, so it runs on every screened body. - rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/\\n\\s+at\\s+.+\\(.+:\\d+:\\d+\\)/' } }] + // + // Accepts a real newline and a JSON-escaped one. Most traces reach a client inside a JSON error + // body, where the newline is the two characters `\` and `n`. + rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/(?:\\n|\\\\n)\\s*at\\s+.+\\(.+:\\d+:\\d+\\)/' } }] }, { id: 'resp-sql-error', title: 'SQL / ORM error disclosure in response body', phase: 'response', category: 'info-exposure', - action: 'redact', + // Withheld. The signature is the start of the disclosure: masking `SQLSTATE[23000]` and serving + // the constraint name, the column and the offending value discloses the schema anyway. + action: 'block', prefilter: ['SQLSTATE', 'Sequelize', 'ER_', 'ORA-', 'PG::', 'SQLITE_ERROR', 'SQL syntax'], rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/(SQLSTATE\\[[0-9A-Z]+\\]|SequelizeDatabaseError|ER_[A-Z_]+|ORA-\\d{5}|PG::[A-Za-z]+Error|SQLITE_ERROR|You have an error in your SQL syntax)/i' } }] }, @@ -126,7 +188,9 @@ export const DEFAULT_RESPONSE_RULES = [ title: 'Backend exception / stack trace disclosure in response body', phase: 'response', category: 'info-exposure', - action: 'redact', + // Withheld, for the same reason as the trace above: the marker opens the dump and the file names, + // line numbers and frames after it are the disclosure. + action: 'block', // Multi-language exception/traceback signatures a normal API response never carries: // Python traceback, Java "Exception in thread", .NET System.*Exception, JVM stack frames, // Go goroutine dumps. (Node `at fn (file:line:col)` frames are handled by resp-stack-trace.) diff --git a/tests/protect/default-policy-fixtures.test.ts b/tests/protect/default-policy-fixtures.test.ts index 34519f1b..ee1fc16d 100644 --- a/tests/protect/default-policy-fixtures.test.ts +++ b/tests/protect/default-policy-fixtures.test.ts @@ -108,16 +108,23 @@ function policyNamed(id: string): unknown { } /** - * Screen a body through ONE policy. + * Screen a body through ONE policy, and report whether that policy fired. * - * Isolated deliberately. Run through the whole shipped set, "the sample was masked" says only that - * something masked it, and a case could pass while the policy it names matched nothing at all. + * Isolated deliberately. Run through the whole shipped set, "the sample was handled" says only that + * something handled it, and a case could pass while the policy it names matched nothing at all. + * + * The answer is whether the policy FIRED, not whether the sample survived. A policy that withholds a + * response removes the sample and so does one that masks it — and a sample the body never contained + * verbatim, because JSON escaped its newlines, is absent without anything having happened. What each + * policy leaves on the wire is asserted in `response-policy-wire-behaviour.test.ts`. */ -async function screen(policy: string, body: string, extra: Record = {}): Promise { +async function screen(policy: string, body: string, extra: Record = {}) { + const fired: string[] = []; const p: any = await createProtection({ rules: { firewall: [], whitelists: [], whitelist_keys: {} }, mode: 'block', responseRules: [policyNamed(policy)], + onDetect: (event: any) => fired.push(String(event.rule?.id)), }); const out = await p.screenResponse( new Response(JSON.stringify({ field: body, ...extra }), { @@ -127,26 +134,25 @@ async function screen(policy: string, body: string, extra: Record 0, status: out.status, body: await out.text() }; } describe('every shipped response policy has a sample it matches', () => { it.each(RESPONSE_CASES.map((c) => [c.policy, c] as const))('%s', async (policy, testCase) => { - expect(await screen(policy, testCase.matches)).not.toContain(testCase.matches); + expect((await screen(policy, testCase.matches)).fired, 'the policy did not fire').toBe(true); }); }); describe('and the nearest thing it must leave alone', () => { it.each(RESPONSE_CASES.map((c) => [c.policy, c] as const))('%s: %o', async (policy, testCase) => { - // The policy's OWN matching sample travels in the same response as the benign one. "The benign - // value survived" is also what an unreached policy looks like, so this is what tells the two - // apart: the same rule, in the same body, masking one and leaving the other. - const out = await screen(policy, testCase.benign, { canary: testCase.matches }); - - expect(out, 'this policy matched nothing in this response').not.toContain(testCase.matches); - // Verbatim, because a redaction that masks part of it has still changed a response it had no - // business changing. - expect(out, testCase.why).toContain(testCase.benign); + // Screened on its own. The policy's own matching sample cannot travel alongside as a control, + // because a policy that withholds would take the benign value with it — the matching case above + // is what proves this policy fires at all. + const result = await screen(policy, testCase.benign); + + expect(result.fired, testCase.why).toBe(false); + expect(result.status).toBe(200); + expect(result.body, testCase.why).toContain(testCase.benign); }); }); diff --git a/tests/protect/response-hardening.test.ts b/tests/protect/response-hardening.test.ts index 0523c7a4..5761fc0a 100644 --- a/tests/protect/response-hardening.test.ts +++ b/tests/protect/response-hardening.test.ts @@ -65,12 +65,17 @@ describe('verbose-error suppression — backend exceptions/tracebacks', () => { }); describe('verbose-error suppression (SQL/ORM disclosure)', () => { - it('redacts a SQL/ORM error signature from the response body', async () => { + it('withholds a response carrying a SQL/ORM error signature', async () => { + // Withheld rather than masked: the signature opens the disclosure and the relation name, the + // column and the offending value come after it, so masking the signature discloses the schema + // anyway. const p = await createProtection({ mode: 'block' }); const res: any = await p.screenResponse(json('{"error":"SequelizeDatabaseError: relation users does not exist"}', {})); const body = await res.text(); - expect(body.includes('SequelizeDatabaseError')).toBe(false); - expect(body).toContain('[REDACTED]'); + expect(res.status).toBe(500); + expect(body).not.toContain('SequelizeDatabaseError'); + expect(body).not.toContain('relation users'); + expect(body).toContain('withheld by Patchstack'); }); it('leaves a benign error body unchanged', async () => { diff --git a/tests/protect/response-policy-wire-behaviour.test.ts b/tests/protect/response-policy-wire-behaviour.test.ts new file mode 100644 index 00000000..54294359 --- /dev/null +++ b/tests/protect/response-policy-wire-behaviour.test.ts @@ -0,0 +1,331 @@ +import { describe, expect, it } from 'vitest'; +import { createProtection } from '../../src/protect/runtime.js'; +import { DEFAULT_RESPONSE_RULES } from '../../src/protect/defaults.js'; + +/** + * What each response policy leaves on the wire. + * + * `redact` masks the span a pattern matched, so the pattern's match has to BE the whole disclosure. + * That holds for a credential with a grammar — prefix, alphabet, length — and not for a disclosure a + * pattern can identify but not delimit: a trace, a database error, an exception dump, a private key + * and a connection URI in free text. + * + * So the assertions here are about the response a client receives, named field by field. "The sample + * is no longer present verbatim" is satisfied by masking one character of it, and by a body that was + * never screened at all. + * + * Every credential is SYNTHETIC and assembled from parts, because a literal in a provider's live-key + * shape is what secret scanning exists to find. + */ +const key = (...parts: string[]) => parts.join(''); +const b64 = (value: unknown) => + Buffer.from(JSON.stringify(value)).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +const jwt = (payload: Record) => `${b64({ alg: 'HS256', typ: 'JWT' })}.${b64(payload)}.c2ln`; + +const policy = (id: string) => { + const rule = (DEFAULT_RESPONSE_RULES as any[]).find((candidate) => candidate.id === id); + if (!rule) throw new Error(`no shipped policy named ${id}`); + + return rule; +}; + +/** Screen one body through one policy and report what the client would receive. */ +async function wire(id: string, body: string, contentType = 'application/json') { + const fired: string[] = []; + const p: any = await createProtection({ + rules: { firewall: [], whitelists: [], whitelist_keys: {} }, + mode: 'block', + responseRules: [policy(id)], + onDetect: (event: any) => fired.push(String(event.rule?.id)), + }); + try { + const out = await p.screenResponse( + new Response(body, { status: 200, headers: { 'content-type': contentType } }), + new Request('https://app.test/', { method: 'GET' }), + ); + + return { matched: fired.length > 0, status: out.status, body: await out.text() }; + } finally { + // The guard's documented lifecycle: a caller that made one releases it. Nothing here depends on + // reporting, so leaving it out passes today — and would keep passing while leaking a timer if + // reporting ever became part of what these guards start. + await p.stop(); + } +} + +const json = (value: unknown) => JSON.stringify(value); + +describe('a credential with a grammar is masked, and the page still serves', () => { + const atomic: Array<{ id: string; secret: string }> = [ + { id: 'resp-aws-access-key', secret: key('AKIA', 'IOSFODNN7EXAMPLE') }, + { id: 'resp-gcp-api-key', secret: key('AIza', 'SyD-1234567890abcdefghijklmnopqrstu') }, + { id: 'resp-vendor-api-key', secret: key('sk_', 'live_', '4eC39HqLyjWDarjtT1zdp7dc') }, + { id: 'resp-supabase-secret-key', secret: key('sb_', 'secret_', 'Xk9Lm2Qp7Rt4Vw8ZaB3cD6', '_', 'eF7hJ2kM') }, + { id: 'resp-supabase-service-role-key', secret: jwt({ role: 'service_role', iss: 'supabase' }) }, + ]; + + it.each(atomic.map((c) => [c.id, c] as const))('%s', async (_id, testCase) => { + const result = await wire(testCase.id, json({ note: 'keep me', secret: testCase.secret, also: 'keep me too' })); + + expect(result.matched, 'the policy did not fire').toBe(true); + expect(result.status, 'the page was withheld rather than masked').toBe(200); + + // The whole credential, not a prefix of it: a mask covering its leading run still serves the rest. + for (let take = 8; take <= testCase.secret.length; take++) { + expect(result.body, `a ${take}-character run survived`).not.toContain(testCase.secret.slice(0, take)); + expect(result.body, `a ${take}-character tail survived`).not.toContain(testCase.secret.slice(-take)); + } + + // And the response around it is still the response. + expect(result.body).toContain('keep me'); + expect(result.body).toContain('keep me too'); + expect(() => JSON.parse(result.body)).not.toThrow(); + }); +}); + +describe('a private key is withheld', () => { + const cases: Array<[string, string]> = [ + ['a complete PEM block', '-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJBAKj34keymaterial\n-----END RSA PRIVATE KEY-----'], + ['CRLF line endings', '-----BEGIN RSA PRIVATE KEY-----\r\nMIIBOgIBAAJBAKj34keymaterial\r\n-----END RSA PRIVATE KEY-----'], + ['no footer at all', '-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJBAKj34keymaterialtruncated'], + ['an unlisted key type', '-----BEGIN ENCRYPTED PRIVATE KEY-----\nMIIBOgIBAAJBAKj34keymaterial'], + ['a bare marker', '-----BEGIN PRIVATE KEY-----'], + // The label carries words AFTER `PRIVATE KEY`, which is the real ASCII-armored PGP form. + ['a PGP private key block', '-----BEGIN PGP PRIVATE KEY BLOCK-----\nlQdGBGKkeymaterial\n-----END PGP PRIVATE KEY BLOCK-----'], + ['a hyphenated unlisted label', '-----BEGIN X-CUSTOM-V2 PRIVATE KEY-----\nAAAAkeymaterial'], + // The longest label the pattern accepts on either side: 32 characters. + [ + 'a label at the accepted boundary', + `-----BEGIN ${'A'.repeat(32)}PRIVATE KEY${'B'.repeat(32)}-----\nMIIBkeymaterial`, + ], + ]; + + it.each(cases)('%s', async (_why, pem) => { + const result = await wire('resp-private-key', json({ pem })); + + expect(result.matched, 'the policy did not fire').toBe(true); + expect(result.status, 'the key was served with the page').toBe(500); + expect(result.body).not.toContain('keymaterial'); + expect(result.body).not.toContain('BEGIN'); + expect(result.body).toContain('withheld by Patchstack'); + }); + + it.each([ + ['prose about private keys', 'Paste your private key into the field below. Private keys are never uploaded.'], + ['a heading without a BEGIN line', 'Section 4: PRIVATE KEY handling'], + ['a public key', '-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkq'], + ['a certificate', '-----BEGIN CERTIFICATE-----\nMIIDXTCCAkWg'], + ])('leaves %s alone', async (_why, text) => { + const body = json({ help: text }); + const result = await wire('resp-private-key', body); + + expect(result.matched).toBe(false); + expect(result.body).toBe(body); + }); + + it('does not match a label longer than the pattern accepts', () => { + // Stated as a limitation rather than a guarantee: past 32 label characters this policy does not + // fire, so a key behind such a label is served. `prefilter` still names `PRIVATE KEY`, so a wider + // bound is a pattern change and nothing else. + const pattern = policy('resp-private-key').rule_v2[0].match.value as string; + + expect(pattern).toContain('{0,32}'); + expect(new RegExp(pattern.slice(1, -1)).test(`-----BEGIN ${'A'.repeat(33)}PRIVATE KEY-----`)).toBe(false); + }); +}); + +describe('a credential-bearing database URL is withheld', () => { + // The credentials are the first half of the disclosure; the host, port, database name and query are + // the rest, and a URI's own grammar admits commas, parentheses and semicolons, so no end-of-URI + // character class delimits one in free text. The response is therefore withheld rather than partly + // rewritten. + it.each([ + ['plain text', 'postgres://user:password@db.internal:5432/app?sslmode=require', 'text/plain'], + ['inside JSON', json({ dsn: 'postgres://user:password@db.internal:5432/app?sslmode=require' }), 'application/json'], + ['in prose inside parentheses', 'connect via (postgres://user:password@db.internal:5432/app) today', 'text/plain'], + ['in prose before a comma', 'use postgres://user:password@db.internal:5432/app, then retry', 'text/plain'], + ['a mongodb+srv URI', 'mongodb+srv://user:password@db.internal/app', 'text/plain'], + ['twice in one body', 'first postgres://u1:p1@host-one/db1 second mysql://u2:p2@host-two/db2 end', 'text/plain'], + // Every character below is legal raw in userinfo, so each of these is an ordinary DSN. A class + // narrow enough to exclude them is a rule that stays silent on a live credential. + ['a password holding a raw colon', json({ dsn: 'postgres://user:p:ss@db.internal/app' }), 'application/json'], + ['a password holding several raw colons', json({ dsn: 'postgres://user:a:b:c:d@db.internal/app' }), 'application/json'], + // A username cannot hold a raw colon — the first one is the separator — so it carries `%3A`. + ['a username holding a percent-encoded colon', json({ dsn: 'postgres://user%3Aname:pw@db.internal/app' }), 'application/json'], + ['a password holding a raw semicolon', json({ dsn: 'postgres://user:p;ss@db.internal/app' }), 'application/json'], + ['a password holding a raw comma', json({ dsn: 'postgres://user:p,ss@db.internal/app' }), 'application/json'], + ['a password holding raw parentheses', json({ dsn: 'postgres://user:p(1)ss@db.internal/app' }), 'application/json'], + ['a password holding raw sub-delimiters', json({ dsn: "postgres://user:p!$&'*+=@db.internal/app" }), 'application/json'], + ['percent-encoded credentials', json({ dsn: 'postgres://user%40corp:p%40ss%21@db.internal/app' }), 'application/json'], + // Punctuation-joined text that parses as a credential URI: username `db.internal;contact`, + // password `admin`, host `example.com`. Nothing distinguishes it from a leak, so it is withheld. + ['text that parses as a credential URI', 'docs at postgres://db.internal;contact:admin@example.com', 'text/plain'], + ] as Array<[string, string, string]>)('%s', async (_why, body, type) => { + const result = await wire('resp-db-connection-string', body, type); + + expect(result.matched, 'the policy did not fire').toBe(true); + expect(result.status, 'the URI was served with the page').toBe(500); + expect(result.body).toBe(json({ error: 'Response withheld by Patchstack (sensitive data detected)' })); + + for (const residue of ['db.internal', '5432', 'sslmode', 'password', 'host-one', 'host-two']) { + if (body.includes(residue)) { + expect(result.body, `"${residue}" survived`).not.toContain(residue); + } + } + }); + + it('leaves a URL carrying no credentials alone', async () => { + const body = json({ dsn: 'postgres://db.internal:5432/app' }); + const result = await wire('resp-db-connection-string', body); + + expect(result.matched).toBe(false); + expect(result.body).toBe(body); + }); + + it('keeps the user/password separator unambiguous', () => { + // The first raw colon separates username from password, so the username class must not admit one. + // Asserted on the class contents, since that is the property — a colon anywhere inside the + // username class makes the separator ambiguous, wherever in the class it sits. The engine's + // expression guard accepts either form, so it does not cover this. + const pattern = policy('resp-db-connection-string').rule_v2[0].match.value as string; + const classes = pattern.match(/\[[^\]]*\]/g) ?? []; + + expect(classes, 'expected a username class and a password class').toHaveLength(2); + + const [username, password] = classes.map((cls) => cls.slice(1, -1)); + expect(username, 'a colon in the username class makes the separator ambiguous').not.toContain(':'); + expect(password, 'a password admits raw colons, and must still match').toContain(':'); + }); + + it('screens a cap-sized adversarial candidate within the bound', () => { + // A body of `a:a:a:…` with no `@` satisfies `prefilter` and reaches the regex. At the default + // screening cap it has to complete well inside a request — and the cap is not the bound, since + // `max_bytes` raises it and `bypass_limit` removes it. + const pattern = policy('resp-db-connection-string').rule_v2[0].match.value as string; + const expression = new RegExp(pattern.slice(1, pattern.lastIndexOf('/')), 'i'); + const body = `postgres://${'a:'.repeat((512 * 1024) / 2)}`; + + const started = process.hrtime.bigint(); + expect(expression.test(body)).toBe(false); + const elapsed = Number(process.hrtime.bigint() - started) / 1e6; + + expect(elapsed, `a 512KB candidate took ${elapsed.toFixed(1)}ms`).toBeLessThan(1000); + }); + + it('leaves the sentence around a public URL intact', async () => { + const body = 'connect via (postgres://db.internal:5432/app) today, then retry'; + const result = await wire('resp-db-connection-string', body, 'text/plain'); + + expect(result.matched).toBe(false); + expect(result.body).toBe(body); + }); + + // A credential-free URL and a `:`/`@` elsewhere in the same body. Each of these carries a JSON + // delimiter between the two, and a delimiter is outside the userinfo grammar — so a candidate cannot + // run past it to reach the punctuation of an unrelated value and withhold a response that discloses + // nothing. + // + // Only bodies whose separator is genuinely outside the grammar belong here. A body joined by a + // semicolon, a comma or parentheses parses as a credential URI and is asserted above as a + // disclosure; pinning one of those as safe would tell the rule to ignore a real one. + it.each([ + [ + 'a docs URL beside an email in another field', + json({ docs: 'postgres://db.internal', contact: 'user@example.com' }), + 'application/json', + ], + [ + 'a scheme in one field and a credential-shaped value in another', + json({ a: 'postgres://user', b: 'password@example.com' }), + 'application/json', + ], + [ + 'a docs URL and a mail address in one array', + json({ links: ['redis://cache.internal', 'admin:root@example.com'] }), + 'application/json', + ], + [ + 'a scheme and an address in one quoted string', + json({ note: 'see postgres://db.internal, then mail user@example.com' }), + 'application/json', + ], + // Whitespace is outside the grammar too, so ordinary prose separates safely without a quote. + ['prose separated by whitespace', 'see postgres://db.internal, then mail user@example.com', 'text/plain'], + ] as Array<[string, string, string]>)('does not bridge %s', async (_why, body, type) => { + const result = await wire('resp-db-connection-string', body, type); + + expect(result.matched, 'the policy fired on a body carrying no credentials').toBe(false); + expect(result.status).toBe(200); + expect(result.body).toBe(body); + }); + + it('still matches credentials carrying percent-encoded characters', async () => { + // The other side of a positive grammar: a password whose special characters are percent-encoded, + // which is how a URI carries them, must still be recognised. + const body = json({ dsn: 'postgres://user%40corp:p%40ss%21@db.internal/app' }); + const result = await wire('resp-db-connection-string', body); + + expect(result.matched, 'the policy did not fire').toBe(true); + expect(result.status).toBe(500); + expect(result.body).not.toContain('db.internal'); + }); +}); + +describe('a diagnostic disclosure is withheld, whole', () => { + const cases: Array<{ id: string; why: string; body: string; type: string; residues: string[] }> = [ + { + id: 'resp-stack-trace', + why: 'a Node trace in a JSON error response', + body: json({ error: 'TypeError: bad\n at handler (/srv/app/index.js:42:15)\n at next (/srv/app/router.js:9:3)' }), + type: 'application/json', + residues: ['index.js', 'router.js', '42:15', 'srv/app', 'TypeError'], + }, + { + id: 'resp-stack-trace', + why: 'a Node trace in a text response', + body: 'TypeError: bad\n at handler (/srv/app/index.js:42:15)', + type: 'text/plain', + residues: ['index.js', '42:15', 'srv/app', 'TypeError'], + }, + { + id: 'resp-sql-error', + why: 'a constraint violation', + body: json({ error: 'SQLSTATE[23000]: duplicate key value violates unique constraint "users_email_unique"' }), + type: 'application/json', + residues: ['users_email_unique', 'duplicate key', 'SQLSTATE'], + }, + { + id: 'resp-exception-trace', + why: 'a Python traceback', + body: json({ error: 'Traceback (most recent call last):\n File "app.py", line 42, in handler\n raise ValueError("boom")' }), + type: 'application/json', + residues: ['app.py', 'line 42', 'ValueError', 'Traceback'], + }, + ]; + + it.each(cases.map((c) => [`${c.id}: ${c.why}`, c] as const))('%s', async (_label, testCase) => { + const result = await wire(testCase.id, testCase.body, testCase.type); + + expect(result.matched, 'the policy did not fire').toBe(true); + expect(result.status, 'the disclosure was served with the page').toBe(500); + + // Nothing of the disclosure: not the message, the file, the line, the query or a trailing frame. + for (const residue of testCase.residues) { + expect(result.body, `"${residue}" survived`).not.toContain(residue); + } + + expect(result.body).toContain('withheld by Patchstack'); + }); + + it.each([ + ['resp-stack-trace', json({ note: 'The meeting starts at 10:00 (room 4)' })], + ['resp-sql-error', json({ error: 'The request could not be completed' })], + ['resp-exception-trace', json({ note: 'System.out.println was called during startup' })], + ] as Array<[string, string]>)('%s serves an ordinary response untouched', async (id, body) => { + const result = await wire(id, body); + + expect(result.matched).toBe(false); + expect(result.status).toBe(200); + expect(result.body).toBe(body); + }); +}); diff --git a/tests/protect/tier3.test.ts b/tests/protect/tier3.test.ts index 764f114a..7d9045e3 100644 --- a/tests/protect/tier3.test.ts +++ b/tests/protect/tier3.test.ts @@ -29,24 +29,47 @@ async function withEgress(opts: any, fn: (p: any) => Promise) { } describe('response phase — redaction depth', () => { - it('redacts every default secret type, still serving the page', async () => { + it('masks every atomic secret type, still serving the page', async () => { + // Each of these patterns matches the whole secret, so masking the match removes the whole secret + // and the response around it stays usable. Where the match is only the OPENING of a disclosure — + // a private key, a stack trace — masking it would leave the rest, so those withhold instead; see + // the case below. const p = await createProtection({ mode: 'block' }); const cases: Array<[string, string]> = [ - ['private-key', '-----BEGIN RSA PRIVATE KEY-----\nMIIabc'], ['aws', 'key=AKIAIOSFODNN7EXAMPLE'], ['gcp', 'k=AIzaSyD1234567890abcdefghij1234567890xy'], // A `service_role` JWT, not any JWT. This case used to be the generic token below, which the // default rule masked on shape alone — and that masked the Supabase anon key and users' own // access tokens along with it. Synthetic payload: {"role":"service_role"}. ['supabase-service-role', 'eyJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIn0.c2ln'], - ['db', 'mongodb://user:secret@db.host:27017/app'], - ['stack', 'oops\n at handler (/srv/app/index.js:42:13)'], ]; for (const [label, secret] of cases) { const r: any = await p.fetch(resp(`{"x":"${secret}"}`, 'text/plain'))(req()); const body = await bodyOf(r); expect(r.status, label).toBe(200); expect(body.includes('[REDACTED]'), `${label} masked`).toBe(true); + expect(body.includes(secret.slice(-8)), `${label} tail survived`).toBe(false); + } + }); + + it('withholds the page for a disclosure masking cannot bound', async () => { + const p = await createProtection({ mode: 'block' }); + const cases: Array<[string, string, string]> = [ + // The marker establishes the leak; the key material follows it and has no bound the pattern + // can reach, so masking the marker alone would publish the key. + ['private-key', '-----BEGIN RSA PRIVATE KEY-----\nMIIabc', 'MIIabc'], + // Likewise a frame: the path, line and the frames under it are the disclosure. + ['stack', 'oops\n at handler (/srv/app/index.js:42:13)', 'index.js'], + // And a connection URI: the credentials are followed by the host, port and database name, and a + // URI in free text has no end character that is not also ordinary punctuation. + ['db', 'mongodb://user:secret@db.host:27017/app', 'db.host'], + ]; + for (const [label, secret, residue] of cases) { + const r: any = await p.fetch(resp(`{"x":"${secret}"}`, 'text/plain'))(req()); + const body = await bodyOf(r); + expect(r.status, label).toBe(500); + expect(body.includes(residue), `${label} residue survived`).toBe(false); + expect(body, label).toContain('withheld by Patchstack'); } });