Skip to content

Commit 1b14a0f

Browse files
nawi-25claude
andcommitted
feat: add DLP content scanner + fix Cursor hook + fix shields warning
DLP Engine (src/dlp.ts): - 7 built-in patterns: AWS key, GitHub token, Slack, OpenAI, Stripe, PEM, Bearer - Recursive scanner with depth limit (5) and string length cap (100 KB) - JSON-in-string detection for agents that stringify nested objects - maskSecret() — only redacted sample stored, full secret never leaves dlp.ts - severity: 'block' for known high-confidence patterns, 'review' for Bearer Core integration (src/core.ts): - DLP check runs before ignoredTools fast-path and audit mode - Hard block for 'block' patterns; 'review' falls through to race engine - DLP step 0 in explainPolicy waterfall - dlp config merged per-layer in getConfig() with enabled/scanIgnoredTools CLI (src/cli.ts): - DLP-specific negotiation message (rotate the key, use env vars, don't retry) - chalk.bgRed.white.bold alarm banner when blockedByLabel includes 'DLP' Cursor fix (src/setup.ts): - Remove hooks.json writing — Cursor does not support this format - Print clear warning that native hook mode is pending Cursor support - Only MCP proxy wrapping is configured Shields fix (src/shields.ts): - Treat empty shields.json as missing (suppress spurious parse warnings in tests) Tests: 353 passing (22 new DLP tests, fake secrets split via concatenation) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 41f0632 commit 1b14a0f

File tree

8 files changed

+424
-83
lines changed

8 files changed

+424
-83
lines changed

src/__tests__/dlp.test.ts

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { scanArgs, DLP_PATTERNS } from '../dlp.js';
3+
4+
// NOTE: All fake secret strings are built via concatenation so GitHub's secret
5+
// scanner doesn't flag this test file. The values are obviously fake (sequential
6+
// letters/numbers) and are never used outside of these unit tests.
7+
8+
// ── Helpers ───────────────────────────────────────────────────────────────────
9+
10+
// Fake AWS Access Key ID — split to defeat static secret scanners
11+
const FAKE_AWS_KEY = 'AKIA' + 'IOSFODNN7' + 'EXAMPLE';
12+
13+
// Stripe keys: sk_(live|test)_ + exactly 24 alphanumeric chars
14+
const FAKE_STRIPE_LIVE = 'sk_live_' + 'abcdefghijklmnop' + 'qrstuvwx';
15+
const FAKE_STRIPE_TEST = 'sk_test_' + 'abcdefghijklmnop' + 'qrstuvwx';
16+
17+
// OpenAI key: sk- + 20+ alphanumeric chars
18+
const FAKE_OPENAI_KEY = 'sk-' + 'abcdefghij' + '1234567890klmn';
19+
20+
// Slack bot token
21+
const FAKE_SLACK_TOKEN = 'xoxb-' + '1234-5678-abcdefghij';
22+
23+
// ── Pattern coverage ──────────────────────────────────────────────────────────
24+
25+
describe('DLP_PATTERNS — built-in patterns', () => {
26+
it('detects AWS Access Key ID', () => {
27+
const match = scanArgs({ command: `aws s3 cp --key ${FAKE_AWS_KEY} s3://bucket/` });
28+
expect(match).not.toBeNull();
29+
expect(match!.patternName).toBe('AWS Access Key ID');
30+
expect(match!.severity).toBe('block');
31+
expect(match!.redactedSample).not.toContain(FAKE_AWS_KEY);
32+
expect(match!.redactedSample).toMatch(/AKIA\*+MPLE/);
33+
});
34+
35+
it('detects GitHub personal access token (ghp_)', () => {
36+
const token = 'ghp_' + 'a'.repeat(36);
37+
const match = scanArgs({ command: `git clone https://${token}@github.com/org/repo` });
38+
expect(match).not.toBeNull();
39+
expect(match!.patternName).toBe('GitHub Token');
40+
expect(match!.severity).toBe('block');
41+
expect(match!.redactedSample).not.toContain(token);
42+
});
43+
44+
it('detects GitHub OAuth token (gho_)', () => {
45+
const token = 'gho_' + 'b'.repeat(36);
46+
const match = scanArgs({ env: { TOKEN: token } });
47+
expect(match).not.toBeNull();
48+
expect(match!.patternName).toBe('GitHub Token');
49+
});
50+
51+
it('detects Slack bot token', () => {
52+
const match = scanArgs({ header: `Authorization: ${FAKE_SLACK_TOKEN}` });
53+
expect(match).not.toBeNull();
54+
expect(match!.patternName).toBe('Slack Bot Token');
55+
expect(match!.severity).toBe('block');
56+
});
57+
58+
it('detects OpenAI API key', () => {
59+
const match = scanArgs({ command: `curl -H "Authorization: ${FAKE_OPENAI_KEY}"` });
60+
expect(match).not.toBeNull();
61+
expect(match!.patternName).toBe('OpenAI API Key');
62+
expect(match!.severity).toBe('block');
63+
});
64+
65+
it('detects Stripe live secret key', () => {
66+
const match = scanArgs({ env: `STRIPE_KEY=${FAKE_STRIPE_LIVE}` });
67+
expect(match).not.toBeNull();
68+
expect(match!.patternName).toBe('Stripe Secret Key');
69+
expect(match!.severity).toBe('block');
70+
});
71+
72+
it('detects Stripe test secret key', () => {
73+
const match = scanArgs({ env: `STRIPE_KEY=${FAKE_STRIPE_TEST}` });
74+
expect(match).not.toBeNull();
75+
expect(match!.patternName).toBe('Stripe Secret Key');
76+
});
77+
78+
it('detects PEM private key header', () => {
79+
const pemHeader = '-----BEGIN RSA ' + 'PRIVATE KEY-----';
80+
const match = scanArgs({ content: `${pemHeader}\nMIIEowIBAAK...` });
81+
expect(match).not.toBeNull();
82+
expect(match!.patternName).toBe('Private Key (PEM)');
83+
expect(match!.severity).toBe('block');
84+
});
85+
86+
it('detects Bearer token with review severity', () => {
87+
const match = scanArgs({ header: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.sig' });
88+
expect(match).not.toBeNull();
89+
expect(match!.patternName).toBe('Bearer Token');
90+
expect(match!.severity).toBe('review'); // not a hard block
91+
});
92+
});
93+
94+
// ── Redaction ─────────────────────────────────────────────────────────────────
95+
96+
describe('maskSecret redaction', () => {
97+
it('shows first 4 + last 4 chars of the matched secret', () => {
98+
const match = scanArgs({ key: FAKE_AWS_KEY });
99+
expect(match).not.toBeNull();
100+
// prefix = 'AKIA', suffix = 'MPLE'
101+
expect(match!.redactedSample).toMatch(/^AKIA/);
102+
expect(match!.redactedSample).toMatch(/MPLE$/);
103+
expect(match!.redactedSample).toContain('*');
104+
expect(match!.redactedSample).not.toContain('IOSFODNN7EXA');
105+
});
106+
});
107+
108+
// ── Recursive scanning ────────────────────────────────────────────────────────
109+
110+
describe('scanArgs — recursive object scanning', () => {
111+
it('scans nested objects', () => {
112+
const match = scanArgs({ outer: { inner: { key: FAKE_AWS_KEY } } });
113+
expect(match).not.toBeNull();
114+
expect(match!.fieldPath).toBe('args.outer.inner.key');
115+
});
116+
117+
it('scans arrays', () => {
118+
const match = scanArgs({ envVars: ['SAFE=value', `SECRET=${FAKE_AWS_KEY}`] });
119+
expect(match).not.toBeNull();
120+
expect(match!.fieldPath).toContain('[1]');
121+
});
122+
123+
it('returns null for clean args', () => {
124+
expect(scanArgs({ command: 'ls -la /tmp', options: { verbose: true } })).toBeNull();
125+
});
126+
127+
it('returns null for non-object primitives', () => {
128+
expect(scanArgs(42)).toBeNull();
129+
expect(scanArgs(null)).toBeNull();
130+
expect(scanArgs(undefined)).toBeNull();
131+
});
132+
});
133+
134+
// ── JSON-in-string ────────────────────────────────────────────────────────────
135+
136+
describe('scanArgs — JSON-in-string detection', () => {
137+
it('detects a secret inside a JSON-encoded string field', () => {
138+
const inner = JSON.stringify({ api_key: FAKE_AWS_KEY, region: 'us-east-1' });
139+
const match = scanArgs({ content: inner });
140+
expect(match).not.toBeNull();
141+
expect(match!.patternName).toBe('AWS Access Key ID');
142+
});
143+
144+
it('does not crash on invalid JSON strings', () => {
145+
expect(() => scanArgs({ content: '{not valid json' })).not.toThrow();
146+
});
147+
148+
it('skips JSON parse for strings longer than 10 KB', () => {
149+
const longJson = '{"key": "' + 'x'.repeat(10_001) + '"}';
150+
// Should not throw and should not attempt to parse
151+
expect(() => scanArgs({ content: longJson })).not.toThrow();
152+
});
153+
});
154+
155+
// ── Depth & length limits ─────────────────────────────────────────────────────
156+
157+
describe('scanArgs — performance guards', () => {
158+
it('stops recursion at MAX_DEPTH (5)', () => {
159+
// 6 levels deep — secret at level 6 should not be found
160+
const deep = { a: { b: { c: { d: { e: { f: FAKE_AWS_KEY } } } } } };
161+
const match = scanArgs(deep);
162+
// depth=0 is the top-level object, key 'a' is depth 1, ..., key 'f' is depth 6
163+
// Our MAX_DEPTH=5 guard returns null at depth > 5, so the string at depth 6 is skipped
164+
expect(match).toBeNull();
165+
});
166+
167+
it('only scans the first 100 KB of a long string', () => {
168+
// Secret is beyond the 100 KB limit — should not be found
169+
const padding = 'x'.repeat(100_001);
170+
const match = scanArgs({ content: padding + FAKE_AWS_KEY });
171+
expect(match).toBeNull();
172+
});
173+
174+
it('finds a secret within the first 100 KB', () => {
175+
const padding = 'x'.repeat(50_000);
176+
const match = scanArgs({ content: `${padding} ${FAKE_AWS_KEY} ` });
177+
expect(match).not.toBeNull();
178+
});
179+
});
180+
181+
// ── All patterns export ───────────────────────────────────────────────────────
182+
183+
describe('DLP_PATTERNS export', () => {
184+
it('exports at least 7 built-in patterns', () => {
185+
expect(DLP_PATTERNS.length).toBeGreaterThanOrEqual(7);
186+
});
187+
188+
it('all patterns have name, regex, and severity', () => {
189+
for (const p of DLP_PATTERNS) {
190+
expect(p.name).toBeTruthy();
191+
expect(p.regex).toBeInstanceOf(RegExp);
192+
expect(['block', 'review']).toContain(p.severity);
193+
}
194+
});
195+
});

src/__tests__/setup.test.ts

Lines changed: 3 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -185,31 +185,15 @@ describe('setupGemini', () => {
185185
// ── setupCursor ───────────────────────────────────────────────────────────────
186186

187187
describe('setupCursor', () => {
188-
const hooksPath = '/mock/home/.cursor/hooks.json';
189188
const mcpPath = '/mock/home/.cursor/mcp.json';
190189

191-
it('adds both hooks immediately on a fresh install — no prompt', async () => {
190+
it('does not write hooks.json — Cursor does not support native hooks', async () => {
192191
const confirm = await getConfirm();
193192
await setupCursor();
194193

195194
expect(confirm).not.toHaveBeenCalled();
196-
const written = writtenTo(hooksPath);
197-
expect(written.version).toBe(1);
198-
expect(written.hooks.preToolUse[0].command).toBe('node9 check');
199-
expect(written.hooks.postToolUse[0].command).toBe('node9 log');
200-
});
201-
202-
it('does not add hooks that already exist', async () => {
203-
withExistingFile(hooksPath, {
204-
version: 1,
205-
hooks: {
206-
preToolUse: [{ command: 'node9', args: ['check'] }],
207-
postToolUse: [{ command: 'node9', args: ['log'] }],
208-
},
209-
});
210-
211-
await setupCursor();
212-
expect(writtenTo(hooksPath)).toBeNull();
195+
// hooks.json must never be written
196+
expect(writtenTo('/mock/home/.cursor/hooks.json')).toBeNull();
213197
});
214198

215199
it('prompts before wrapping existing MCP servers', async () => {
@@ -247,19 +231,4 @@ describe('setupCursor', () => {
247231
await setupCursor();
248232
expect(writtenTo(mcpPath)).toBeNull();
249233
});
250-
251-
it('preserves existing hooks from other tools when adding node9', async () => {
252-
withExistingFile(hooksPath, {
253-
version: 1,
254-
hooks: { preToolUse: [{ command: 'some-other-tool' }] },
255-
});
256-
257-
await setupCursor();
258-
259-
const written = writtenTo(hooksPath);
260-
// node9 should be appended, not replace the existing hook
261-
expect(written.hooks.preToolUse).toHaveLength(2);
262-
expect(written.hooks.preToolUse[0].command).toBe('some-other-tool');
263-
expect(written.hooks.preToolUse[1].command).toBe('node9 check');
264-
});
265234
});

src/cli.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,20 @@ INSTRUCTIONS:
8383

8484
const label = blockedByLabel.toLowerCase();
8585

86+
if (
87+
label.includes('dlp') ||
88+
label.includes('secret detected') ||
89+
label.includes('credential review')
90+
) {
91+
return `NODE9 SECURITY ALERT: A sensitive credential (API key, token, or private key) was found in your tool call arguments.
92+
CRITICAL INSTRUCTION: Do NOT retry this action.
93+
REQUIRED ACTIONS:
94+
1. Remove the hardcoded credential from your command or code.
95+
2. Use an environment variable or a dedicated secrets manager instead.
96+
3. Treat the leaked credential as compromised and rotate it immediately.
97+
Do NOT attempt to bypass this check or pass the credential through another tool.`;
98+
}
99+
86100
if (label.includes('sql safety') && label.includes('delete without where')) {
87101
return `NODE9: Blocked — DELETE without WHERE clause would wipe the entire table.
88102
INSTRUCTION: Add a WHERE clause to scope the deletion (e.g. WHERE id = <value>).
@@ -1025,7 +1039,16 @@ program
10251039
blockedByContext.toLowerCase().includes('decision');
10261040

10271041
// 3. Print to the human terminal for visibility
1028-
console.error(chalk.red(`\n🛑 Node9 blocked "${toolName}"`));
1042+
if (
1043+
blockedByContext.includes('DLP') ||
1044+
blockedByContext.includes('Secret Detected') ||
1045+
blockedByContext.includes('Credential Review')
1046+
) {
1047+
console.error(chalk.bgRed.white.bold(`\n 🚨 NODE9 DLP ALERT — CREDENTIAL DETECTED `));
1048+
console.error(chalk.red.bold(` A sensitive secret was found in the tool arguments!`));
1049+
} else {
1050+
console.error(chalk.red(`\n🛑 Node9 blocked "${toolName}"`));
1051+
}
10291052
console.error(chalk.gray(` Triggered by: ${blockedByContext}`));
10301053
if (result?.changeHint) console.error(chalk.cyan(` To change: ${result.changeHint}`));
10311054
console.error('');

src/config-schema.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ export const ConfigFileSchema = z
8989
ignorePaths: z.array(z.string()).optional(),
9090
})
9191
.optional(),
92+
dlp: z
93+
.object({
94+
enabled: z.boolean().optional(),
95+
scanIgnoredTools: z.boolean().optional(),
96+
})
97+
.optional(),
9298
})
9399
.optional(),
94100
environments: z.record(z.object({ requireApproval: z.boolean().optional() })).optional(),

0 commit comments

Comments
 (0)