Skip to content

Commit 22620cd

Browse files
Cover every shipped policy with a matching and a non-matching sample (#213)
Each shipped policy has a sample it must mask and the nearest thing it must leave alone: a test key beside a live one, a publishable key beside a secret one, the public `anon` role beside `service_role`, a connection string without credentials beside one with them, prose containing " at " beside a stack frame. The non-matching half is what the file is for. A pattern that masks a secret is easy to write and easy to write too broadly, and a policy that masks legitimate content breaks a response it was meant to protect. Each case runs through its own policy alone. Screened through the whole shipped set, "the sample was masked" says only that something masked it, and a case can pass while the policy it names matches nothing. The policy's own matching sample travels in the same response as its benign one, because "the benign value survived" is also what an unreached policy looks like. The same rule, in one body, masks one and leaves the other. A prefilter is a gate — the pattern runs only when one of its anchors is in the body — so each policy is checked to have an anchor present in its own matching sample. An anchor absent from the content the policy is for is a policy that cannot fire, and the body comes back unmasked for a reason that looks like the pattern not matching. The case list is asserted against the shipped set, so a policy without a sample fails here. Prefixed keys are assembled from parts rather than written out: a literal in a provider's live-key shape is what secret scanning exists to find, and it cannot tell a synthetic one from a real one. Every value in the file is synthetic.
1 parent 0c8bd2f commit 22620cd

1 file changed

Lines changed: 220 additions & 0 deletions

File tree

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { createProtection } from '../../src/protect/runtime.js';
3+
import { DEFAULT_EGRESS_RULES, DEFAULT_RESPONSE_RULES } from '../../src/protect/defaults.js';
4+
5+
/**
6+
* One matching and one non-matching sample for every shipped policy.
7+
*
8+
* The non-matching half is the reason this exists. A pattern that masks a secret is easy to write and
9+
* easy to write too broadly, and a policy that masks legitimate content breaks the response it was
10+
* meant to protect. So each case pairs the thing the policy is for with the nearest thing it must
11+
* leave alone: a test key beside a live one, a publishable key beside a secret one, the public `anon`
12+
* role beside `service_role`, a connection string without credentials beside one with them.
13+
*
14+
* Every credential here is SYNTHETIC.
15+
*/
16+
const base64url = (value: unknown) =>
17+
Buffer.from(JSON.stringify(value)).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
18+
19+
/** A JWT is three dot-separated parts; these rules read the payload, so the signature is filler. */
20+
const jwt = (payload: Record<string, unknown>) =>
21+
`${base64url({ alg: 'HS256', typ: 'JWT' })}.${base64url(payload)}.c2lnbmF0dXJl`;
22+
23+
/**
24+
* Prefixed keys are assembled rather than written out.
25+
*
26+
* A literal in a provider's live-key shape is what secret scanning is for, and it cannot tell a
27+
* synthetic one from a real one — so the file would be rejected for carrying exactly the shape these
28+
* policies exist to catch. Assembling the same string from parts keeps the sample and drops the
29+
* literal. Every value in this file is synthetic.
30+
*/
31+
const key = (...parts: string[]) => parts.join('');
32+
33+
const SUPABASE_RANDOM = 'Xk9Lm2Qp7Rt4Vw8ZaB3cD6';
34+
const SUPABASE_CHECKSUM = 'eF7hJ2kM';
35+
36+
/**
37+
* `matches` must not survive a screened response; `benign` must survive it verbatim.
38+
*/
39+
const RESPONSE_CASES: Array<{ policy: string; matches: string; benign: string; why: string }> = [
40+
{
41+
policy: 'resp-private-key',
42+
matches: '-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJBAKj34\n-----END RSA PRIVATE KEY-----',
43+
benign: 'Paste your private key into the field below to continue',
44+
why: 'prose about a private key is not one',
45+
},
46+
{
47+
policy: 'resp-aws-access-key',
48+
matches: key('AKIA', 'IOSFODNN7EXAMPLE'),
49+
benign: key('akia', 'iosfodnn7example'),
50+
why: 'the key id is upper case; a lower-case run is not one',
51+
},
52+
{
53+
policy: 'resp-gcp-api-key',
54+
matches: key('AIza', 'SyD-1234567890abcdefghijklmnopqrstu'),
55+
benign: key('AIza', 'TooShortToBeAKey'),
56+
why: 'the format is a fixed length, and a shorter run is not a key',
57+
},
58+
{
59+
policy: 'resp-vendor-api-key',
60+
matches: key('sk_', 'live_', '4eC39HqLyjWDarjtT1zdp7dc'),
61+
benign: key('sk_', 'test_', '4eC39HqLyjWDarjtT1zdp7dc'),
62+
why: 'a test key is not a live one, and belongs in a response body',
63+
},
64+
{
65+
policy: 'resp-supabase-secret-key',
66+
matches: key('sb_', 'secret_', SUPABASE_RANDOM, '_', SUPABASE_CHECKSUM),
67+
benign: key('sb_', 'publishable_', SUPABASE_RANDOM, '_', SUPABASE_CHECKSUM),
68+
why: 'the publishable key is meant for public clients',
69+
},
70+
{
71+
policy: 'resp-supabase-service-role-key',
72+
matches: jwt({ role: 'service_role', iss: 'supabase' }),
73+
benign: jwt({ role: 'anon', iss: 'supabase' }),
74+
why: 'the anon role is public by design',
75+
},
76+
{
77+
policy: 'resp-db-connection-string',
78+
matches: 'postgres://app:s3cr3tpw@db.internal:5432/main',
79+
benign: 'postgres://db.internal:5432/main',
80+
why: 'a connection string without credentials leaks nothing',
81+
},
82+
{
83+
policy: 'resp-stack-trace',
84+
matches: 'TypeError: x\n at handler (/srv/app/index.js:42:15)',
85+
benign: 'The meeting starts at 10:00 (room 4)',
86+
why: '" at " in prose is not a stack frame',
87+
},
88+
{
89+
policy: 'resp-sql-error',
90+
matches: 'SQLSTATE[42000]: Syntax error or access violation',
91+
benign: 'The request could not be completed',
92+
why: 'an ordinary error message discloses nothing',
93+
},
94+
{
95+
policy: 'resp-exception-trace',
96+
matches: 'Traceback (most recent call last):\n File "app.py", line 3',
97+
benign: 'System.out.println was called during startup',
98+
why: 'a class name that is not an exception is not a trace',
99+
},
100+
];
101+
102+
/** The named policy, alone. */
103+
function policyNamed(id: string): unknown {
104+
const rule = (DEFAULT_RESPONSE_RULES as any[]).find((candidate) => candidate.id === id);
105+
if (!rule) throw new Error(`no shipped policy named ${id}`);
106+
107+
return rule;
108+
}
109+
110+
/**
111+
* Screen a body through ONE policy.
112+
*
113+
* Isolated deliberately. Run through the whole shipped set, "the sample was masked" says only that
114+
* something masked it, and a case could pass while the policy it names matched nothing at all.
115+
*/
116+
async function screen(policy: string, body: string, extra: Record<string, string> = {}): Promise<string> {
117+
const p: any = await createProtection({
118+
rules: { firewall: [], whitelists: [], whitelist_keys: {} },
119+
mode: 'block',
120+
responseRules: [policyNamed(policy)],
121+
});
122+
const out = await p.screenResponse(
123+
new Response(JSON.stringify({ field: body, ...extra }), {
124+
status: 200,
125+
headers: { 'content-type': 'application/json' },
126+
}),
127+
new Request('https://app.test/', { method: 'GET' }),
128+
);
129+
130+
return out.text();
131+
}
132+
133+
describe('every shipped response policy has a sample it matches', () => {
134+
it.each(RESPONSE_CASES.map((c) => [c.policy, c] as const))('%s', async (policy, testCase) => {
135+
expect(await screen(policy, testCase.matches)).not.toContain(testCase.matches);
136+
});
137+
});
138+
139+
describe('and the nearest thing it must leave alone', () => {
140+
it.each(RESPONSE_CASES.map((c) => [c.policy, c] as const))('%s: %o', async (policy, testCase) => {
141+
// The policy's OWN matching sample travels in the same response as the benign one. "The benign
142+
// value survived" is also what an unreached policy looks like, so this is what tells the two
143+
// apart: the same rule, in the same body, masking one and leaving the other.
144+
const out = await screen(policy, testCase.benign, { canary: testCase.matches });
145+
146+
expect(out, 'this policy matched nothing in this response').not.toContain(testCase.matches);
147+
// Verbatim, because a redaction that masks part of it has still changed a response it had no
148+
// business changing.
149+
expect(out, testCase.why).toContain(testCase.benign);
150+
});
151+
});
152+
153+
describe('each policy can reach its own sample', () => {
154+
it('has at least one prefilter anchor present in what it matches', () => {
155+
// A prefilter is a gate: the pattern runs only when one of its anchors is in the body. So an
156+
// anchor absent from the very content the policy is for is a policy that never fires — and
157+
// nothing else here would notice, because the body would simply come back unmasked for a reason
158+
// that looks like the pattern not matching.
159+
const unreachable: string[] = [];
160+
161+
for (const rule of DEFAULT_RESPONSE_RULES as any[]) {
162+
const anchors: string[] = rule.prefilter ?? [];
163+
if (anchors.length === 0) continue;
164+
165+
const sample = RESPONSE_CASES.find((c) => c.policy === rule.id)?.matches ?? '';
166+
const reachable = anchors.some((anchor) => sample.toLowerCase().includes(anchor.toLowerCase()));
167+
168+
if (!reachable) unreachable.push(`${rule.id}: none of [${anchors.join(', ')}] is in its sample`);
169+
}
170+
171+
expect(unreachable).toEqual([]);
172+
});
173+
});
174+
175+
describe('the set is covered', () => {
176+
it('has a case for every shipped response policy', () => {
177+
// So a policy added to the guard without a sample here is a failure rather than a gap nobody sees.
178+
expect(RESPONSE_CASES.map((c) => c.policy).sort()).toEqual(
179+
DEFAULT_RESPONSE_RULES.map((rule: any) => rule.id).sort(),
180+
);
181+
});
182+
183+
it('has one shipped egress policy, covered below', () => {
184+
expect(DEFAULT_EGRESS_RULES.map((rule: any) => rule.id)).toEqual(['egress-internal-address']);
185+
});
186+
});
187+
188+
describe('the shipped egress policy', () => {
189+
const withEgress = async (fn: (fetchStub: typeof globalThis.fetch) => Promise<void>) => {
190+
const origFetch = globalThis.fetch;
191+
globalThis.fetch = (async () => new Response('stub')) as any;
192+
const p: any = await createProtection({ egress: true, mode: 'block' });
193+
try {
194+
await fn(globalThis.fetch);
195+
} finally {
196+
p.uninstallEgress?.();
197+
globalThis.fetch = origFetch;
198+
}
199+
};
200+
201+
it('blocks a call to the loopback interface', async () => {
202+
await withEgress(async (f) => {
203+
await expect(f('http://127.0.0.1/admin')).rejects.toThrow();
204+
});
205+
});
206+
207+
it('blocks a call to the cloud metadata address', async () => {
208+
await withEgress(async (f) => {
209+
await expect(f('http://169.254.169.254/latest/meta-data/')).rejects.toThrow();
210+
});
211+
});
212+
213+
it('allows a call to a public address', async () => {
214+
// An address literal, and a public one: a name that cannot be resolved is screened as internal,
215+
// which is fail-closed and would make this pass for the wrong reason.
216+
await withEgress(async (f) => {
217+
expect(await (await f('http://93.184.216.34/api')).text()).toBe('stub');
218+
});
219+
});
220+
});

0 commit comments

Comments
 (0)