@aictrl/hush 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitlab-ci.yml +59 -0
- package/README.md +150 -3
- package/dist/cli.js +30 -17
- package/dist/cli.js.map +1 -1
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +81 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/redact-hook.d.ts +12 -0
- package/dist/commands/redact-hook.d.ts.map +1 -0
- package/dist/commands/redact-hook.js +89 -0
- package/dist/commands/redact-hook.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/middleware/redactor.d.ts +5 -0
- package/dist/middleware/redactor.d.ts.map +1 -1
- package/dist/middleware/redactor.js +69 -0
- package/dist/middleware/redactor.js.map +1 -1
- package/dist/plugins/opencode-hush.d.ts +21 -0
- package/dist/plugins/opencode-hush.d.ts.map +1 -0
- package/dist/plugins/opencode-hush.js +25 -0
- package/dist/plugins/opencode-hush.js.map +1 -0
- package/dist/plugins/sensitive-patterns.d.ts +15 -0
- package/dist/plugins/sensitive-patterns.d.ts.map +1 -0
- package/dist/plugins/sensitive-patterns.js +69 -0
- package/dist/plugins/sensitive-patterns.js.map +1 -0
- package/dist/vault/token-vault.d.ts.map +1 -1
- package/dist/vault/token-vault.js +16 -3
- package/dist/vault/token-vault.js.map +1 -1
- package/examples/team-config/.claude/settings.json +19 -0
- package/examples/team-config/.codex/config.toml +4 -0
- package/examples/team-config/.opencode/plugins/hush.ts +76 -0
- package/examples/team-config/opencode.json +10 -0
- package/package.json +11 -1
- package/scripts/e2e-plugin-block.sh +142 -0
- package/scripts/e2e-proxy-live.sh +185 -0
- package/src/cli.ts +28 -16
- package/src/commands/init.ts +107 -0
- package/src/commands/redact-hook.ts +124 -0
- package/src/index.ts +1 -1
- package/src/middleware/redactor.ts +75 -0
- package/src/plugins/opencode-hush.ts +30 -0
- package/src/plugins/sensitive-patterns.ts +71 -0
- package/src/vault/token-vault.ts +18 -4
- package/tests/init.test.ts +101 -0
- package/tests/opencode-plugin.test.ts +148 -0
- package/tests/redact-hook.test.ts +142 -0
- package/tests/redaction.test.ts +96 -0
package/src/vault/token-vault.ts
CHANGED
|
@@ -88,7 +88,9 @@ export class TokenVault {
|
|
|
88
88
|
let buffer = '';
|
|
89
89
|
const maxTokenLen = Math.max(...[...this.vault.keys()].map(t => t.length), 0);
|
|
90
90
|
|
|
91
|
-
// Accumulate content fields across SSE events to reassemble split tokens
|
|
91
|
+
// Accumulate content fields across SSE events to reassemble split tokens.
|
|
92
|
+
// Cap buffer size to prevent unbounded memory growth on very long streams.
|
|
93
|
+
const MAX_BUFFER_SIZE = 1024 * 1024; // 1 MB per field
|
|
92
94
|
const contentBuffers: Record<string, string> = {};
|
|
93
95
|
const CONTENT_FIELDS = ['content', 'reasoning_content', 'partial_json'];
|
|
94
96
|
|
|
@@ -186,11 +188,23 @@ export class TokenVault {
|
|
|
186
188
|
const bufKey = actualField;
|
|
187
189
|
contentBuffers[bufKey] = (contentBuffers[bufKey] || '') + target[actualField];
|
|
188
190
|
|
|
189
|
-
|
|
191
|
+
// Cap buffer size: flush everything if it grows too large
|
|
192
|
+
if (contentBuffers[bufKey]!.length > MAX_BUFFER_SIZE) {
|
|
193
|
+
target[actualField] = flushField(bufKey);
|
|
194
|
+
modified = true;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const buf = contentBuffers[bufKey]!;
|
|
190
199
|
const lastBracket = buf.lastIndexOf('[');
|
|
200
|
+
// Only treat as partial token if the text after '[' looks like a
|
|
201
|
+
// token prefix (uppercase letter or underscore), not JSON array content.
|
|
202
|
+
// Also hold back a bare '[' at the end — not enough chars yet to decide.
|
|
203
|
+
const tail = lastBracket >= 0 ? buf.substring(lastBracket) : '';
|
|
191
204
|
const hasPartialToken = maxTokenLen > 0 && lastBracket >= 0 &&
|
|
192
|
-
!
|
|
193
|
-
buf.length - lastBracket < maxTokenLen
|
|
205
|
+
!tail.includes(']') &&
|
|
206
|
+
buf.length - lastBracket < maxTokenLen &&
|
|
207
|
+
(tail === '[' || /^\[[A-Z_]/.test(tail));
|
|
194
208
|
|
|
195
209
|
if (hasPartialToken) {
|
|
196
210
|
const safe = buf.substring(0, lastBracket);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtempSync, readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { execFileSync } from 'child_process';
|
|
5
|
+
import { tmpdir } from 'os';
|
|
6
|
+
|
|
7
|
+
const CLI = join(__dirname, '..', 'dist', 'cli.js');
|
|
8
|
+
|
|
9
|
+
function runInit(cwd: string, ...extraArgs: string[]): { stdout: string; stderr: string; exitCode: number } {
|
|
10
|
+
try {
|
|
11
|
+
const stdout = execFileSync('node', [CLI, 'init', '--hooks', ...extraArgs], {
|
|
12
|
+
encoding: 'utf-8',
|
|
13
|
+
cwd,
|
|
14
|
+
timeout: 5000,
|
|
15
|
+
});
|
|
16
|
+
return { stdout, stderr: '', exitCode: 0 };
|
|
17
|
+
} catch (err: any) {
|
|
18
|
+
return {
|
|
19
|
+
stdout: err.stdout ?? '',
|
|
20
|
+
stderr: err.stderr ?? '',
|
|
21
|
+
exitCode: err.status ?? 1,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('hush init --hooks', () => {
|
|
27
|
+
let tmpDir: string;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'hush-init-'));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should create .claude/settings.json from scratch', () => {
|
|
38
|
+
const { stdout, exitCode } = runInit(tmpDir);
|
|
39
|
+
expect(exitCode).toBe(0);
|
|
40
|
+
expect(stdout).toContain('Wrote hush hooks config');
|
|
41
|
+
|
|
42
|
+
const settings = JSON.parse(readFileSync(join(tmpDir, '.claude', 'settings.json'), 'utf-8'));
|
|
43
|
+
expect(settings.hooks.PostToolUse).toHaveLength(1);
|
|
44
|
+
expect(settings.hooks.PostToolUse[0].matcher).toBe('Bash|Read|Grep|WebFetch');
|
|
45
|
+
expect(settings.hooks.PostToolUse[0].hooks[0].command).toBe('hush redact-hook');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should merge into existing settings preserving other keys', () => {
|
|
49
|
+
const claudeDir = join(tmpDir, '.claude');
|
|
50
|
+
mkdirSync(claudeDir, { recursive: true });
|
|
51
|
+
writeFileSync(
|
|
52
|
+
join(claudeDir, 'settings.json'),
|
|
53
|
+
JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:4000' } }, null, 2),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const { exitCode } = runInit(tmpDir);
|
|
57
|
+
expect(exitCode).toBe(0);
|
|
58
|
+
|
|
59
|
+
const settings = JSON.parse(readFileSync(join(claudeDir, 'settings.json'), 'utf-8'));
|
|
60
|
+
// Preserved existing env
|
|
61
|
+
expect(settings.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:4000');
|
|
62
|
+
// Added hooks
|
|
63
|
+
expect(settings.hooks.PostToolUse).toHaveLength(1);
|
|
64
|
+
expect(settings.hooks.PostToolUse[0].hooks[0].command).toBe('hush redact-hook');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should be idempotent on re-run', () => {
|
|
68
|
+
runInit(tmpDir);
|
|
69
|
+
const { stdout, exitCode } = runInit(tmpDir);
|
|
70
|
+
expect(exitCode).toBe(0);
|
|
71
|
+
expect(stdout).toContain('already configured');
|
|
72
|
+
|
|
73
|
+
const settings = JSON.parse(readFileSync(join(tmpDir, '.claude', 'settings.json'), 'utf-8'));
|
|
74
|
+
expect(settings.hooks.PostToolUse).toHaveLength(1); // Not duplicated
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should write to settings.local.json with --local flag', () => {
|
|
78
|
+
const { stdout, exitCode } = runInit(tmpDir, '--local');
|
|
79
|
+
expect(exitCode).toBe(0);
|
|
80
|
+
expect(stdout).toContain('settings.local.json');
|
|
81
|
+
|
|
82
|
+
const localPath = join(tmpDir, '.claude', 'settings.local.json');
|
|
83
|
+
expect(existsSync(localPath)).toBe(true);
|
|
84
|
+
|
|
85
|
+
const settings = JSON.parse(readFileSync(localPath, 'utf-8'));
|
|
86
|
+
expect(settings.hooks.PostToolUse[0].hooks[0].command).toBe('hush redact-hook');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should show usage without --hooks flag', () => {
|
|
90
|
+
try {
|
|
91
|
+
execFileSync('node', [CLI, 'init'], {
|
|
92
|
+
encoding: 'utf-8',
|
|
93
|
+
cwd: tmpDir,
|
|
94
|
+
timeout: 5000,
|
|
95
|
+
});
|
|
96
|
+
} catch (err: any) {
|
|
97
|
+
expect(err.status).toBe(1);
|
|
98
|
+
expect(err.stderr).toContain('Usage');
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { isSensitivePath, commandReadsSensitiveFile } from '../src/plugins/sensitive-patterns.js';
|
|
3
|
+
import { HushPlugin } from '../src/plugins/opencode-hush.js';
|
|
4
|
+
|
|
5
|
+
describe('isSensitivePath', () => {
|
|
6
|
+
it.each([
|
|
7
|
+
'.env',
|
|
8
|
+
'.env.local',
|
|
9
|
+
'.env.production',
|
|
10
|
+
'.env.development.local',
|
|
11
|
+
'credentials.json',
|
|
12
|
+
'credentials.yaml',
|
|
13
|
+
'db-credentials',
|
|
14
|
+
'secret.txt',
|
|
15
|
+
'secrets.yaml',
|
|
16
|
+
'server.pem',
|
|
17
|
+
'tls.key',
|
|
18
|
+
'id_rsa',
|
|
19
|
+
'id_rsa.pub',
|
|
20
|
+
'.netrc',
|
|
21
|
+
'.pgpass',
|
|
22
|
+
'keystore.p12',
|
|
23
|
+
'cert.pfx',
|
|
24
|
+
'truststore.jks',
|
|
25
|
+
'app.keystore',
|
|
26
|
+
'private.asc',
|
|
27
|
+
])('blocks %s', (path) => {
|
|
28
|
+
expect(isSensitivePath(path)).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it.each([
|
|
32
|
+
'.env',
|
|
33
|
+
'/home/user/project/.env.local',
|
|
34
|
+
'/etc/ssl/private/server.key',
|
|
35
|
+
'config/credentials.json',
|
|
36
|
+
])('blocks absolute/relative path %s', (path) => {
|
|
37
|
+
expect(isSensitivePath(path)).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it.each([
|
|
41
|
+
'package.json',
|
|
42
|
+
'src/index.ts',
|
|
43
|
+
'README.md',
|
|
44
|
+
'tsconfig.json',
|
|
45
|
+
'.gitignore',
|
|
46
|
+
'environment.ts',
|
|
47
|
+
'docker-compose.yml',
|
|
48
|
+
])('allows %s', (path) => {
|
|
49
|
+
expect(isSensitivePath(path)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('commandReadsSensitiveFile', () => {
|
|
54
|
+
it.each([
|
|
55
|
+
'cat .env',
|
|
56
|
+
'cat /app/.env.local',
|
|
57
|
+
'head -5 secrets.yaml',
|
|
58
|
+
'tail -n 20 credentials.json',
|
|
59
|
+
'less .env.production',
|
|
60
|
+
'more secret.txt',
|
|
61
|
+
'bat id_rsa',
|
|
62
|
+
'cat .pgpass',
|
|
63
|
+
'cat foo.txt && cat .env',
|
|
64
|
+
'echo hello | cat .env',
|
|
65
|
+
'cat $HOME/.env',
|
|
66
|
+
'cat ${HOME}/.env',
|
|
67
|
+
'cat ~/secrets/.env',
|
|
68
|
+
'cat ~/.pgpass',
|
|
69
|
+
'batcat .env',
|
|
70
|
+
'batcat id_rsa',
|
|
71
|
+
'cat "$(echo .env)"',
|
|
72
|
+
'cat `.env`',
|
|
73
|
+
'cat <.env',
|
|
74
|
+
"cat '.env'",
|
|
75
|
+
])('blocks: %s', (cmd) => {
|
|
76
|
+
expect(commandReadsSensitiveFile(cmd)).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it.each([
|
|
80
|
+
'cat README.md',
|
|
81
|
+
'ls -la',
|
|
82
|
+
'echo "hello"',
|
|
83
|
+
'grep password src/config.ts',
|
|
84
|
+
'head -5 package.json',
|
|
85
|
+
'cat src/index.ts',
|
|
86
|
+
'npm install',
|
|
87
|
+
'node dist/cli.js',
|
|
88
|
+
])('allows: %s', (cmd) => {
|
|
89
|
+
expect(commandReadsSensitiveFile(cmd)).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('HushPlugin integration', () => {
|
|
94
|
+
it('exports a factory that returns a tool.execute.before hook', async () => {
|
|
95
|
+
const plugin = await HushPlugin();
|
|
96
|
+
expect(plugin['tool.execute.before']).toBeTypeOf('function');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('throws when read targets a sensitive file', async () => {
|
|
100
|
+
const plugin = await HushPlugin();
|
|
101
|
+
await expect(
|
|
102
|
+
plugin['tool.execute.before'](
|
|
103
|
+
{ tool: 'read' },
|
|
104
|
+
{ args: { filePath: '/project/.env' } },
|
|
105
|
+
),
|
|
106
|
+
).rejects.toThrow('[hush] Blocked: sensitive file');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('passes when read targets a normal file', async () => {
|
|
110
|
+
const plugin = await HushPlugin();
|
|
111
|
+
await expect(
|
|
112
|
+
plugin['tool.execute.before'](
|
|
113
|
+
{ tool: 'read' },
|
|
114
|
+
{ args: { filePath: 'src/index.ts' } },
|
|
115
|
+
),
|
|
116
|
+
).resolves.toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('throws when bash command reads a sensitive file', async () => {
|
|
120
|
+
const plugin = await HushPlugin();
|
|
121
|
+
await expect(
|
|
122
|
+
plugin['tool.execute.before'](
|
|
123
|
+
{ tool: 'bash' },
|
|
124
|
+
{ args: { command: 'cat .env' } },
|
|
125
|
+
),
|
|
126
|
+
).rejects.toThrow('[hush] Blocked: command reads sensitive file');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('passes when bash command is harmless', async () => {
|
|
130
|
+
const plugin = await HushPlugin();
|
|
131
|
+
await expect(
|
|
132
|
+
plugin['tool.execute.before'](
|
|
133
|
+
{ tool: 'bash' },
|
|
134
|
+
{ args: { command: 'ls -la' } },
|
|
135
|
+
),
|
|
136
|
+
).resolves.toBeUndefined();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('passes for unrelated tools', async () => {
|
|
140
|
+
const plugin = await HushPlugin();
|
|
141
|
+
await expect(
|
|
142
|
+
plugin['tool.execute.before'](
|
|
143
|
+
{ tool: 'write' },
|
|
144
|
+
{ args: { filePath: '.env', content: 'x' } },
|
|
145
|
+
),
|
|
146
|
+
).resolves.toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { execFileSync } from 'child_process';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Integration tests for `hush redact-hook`.
|
|
7
|
+
* Spawns the CLI as a child process with piped stdin, matching real hook usage.
|
|
8
|
+
*/
|
|
9
|
+
const CLI = join(__dirname, '..', 'dist', 'cli.js');
|
|
10
|
+
|
|
11
|
+
function runHook(input: string): { stdout: string; stderr: string; exitCode: number } {
|
|
12
|
+
try {
|
|
13
|
+
const stdout = execFileSync('node', [CLI, 'redact-hook'], {
|
|
14
|
+
input,
|
|
15
|
+
encoding: 'utf-8',
|
|
16
|
+
timeout: 5000,
|
|
17
|
+
});
|
|
18
|
+
return { stdout, stderr: '', exitCode: 0 };
|
|
19
|
+
} catch (err: any) {
|
|
20
|
+
return {
|
|
21
|
+
stdout: err.stdout ?? '',
|
|
22
|
+
stderr: err.stderr ?? '',
|
|
23
|
+
exitCode: err.status ?? 1,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('hush redact-hook', () => {
|
|
29
|
+
it('should redact email from Bash stdout', () => {
|
|
30
|
+
const payload = {
|
|
31
|
+
tool_name: 'Bash',
|
|
32
|
+
tool_response: { stdout: 'email: test@foo.com' },
|
|
33
|
+
};
|
|
34
|
+
const { stdout, exitCode } = runHook(JSON.stringify(payload));
|
|
35
|
+
expect(exitCode).toBe(0);
|
|
36
|
+
|
|
37
|
+
const result = JSON.parse(stdout);
|
|
38
|
+
expect(result.decision).toBe('block');
|
|
39
|
+
expect(result.reason).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/);
|
|
40
|
+
expect(result.reason).not.toContain('test@foo.com');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should redact email from Read file.content', () => {
|
|
44
|
+
const payload = {
|
|
45
|
+
tool_name: 'Read',
|
|
46
|
+
tool_response: { file: { content: 'Contact: admin@internal.corp', filePath: '/app/config.json' } },
|
|
47
|
+
};
|
|
48
|
+
const { stdout, exitCode } = runHook(JSON.stringify(payload));
|
|
49
|
+
expect(exitCode).toBe(0);
|
|
50
|
+
|
|
51
|
+
const result = JSON.parse(stdout);
|
|
52
|
+
expect(result.decision).toBe('block');
|
|
53
|
+
expect(result.reason).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/);
|
|
54
|
+
expect(result.reason).not.toContain('admin@internal.corp');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should redact IP address from Bash stderr', () => {
|
|
58
|
+
const payload = {
|
|
59
|
+
tool_name: 'Bash',
|
|
60
|
+
tool_response: { stderr: 'connection to 192.168.1.100 failed' },
|
|
61
|
+
};
|
|
62
|
+
const { stdout, exitCode } = runHook(JSON.stringify(payload));
|
|
63
|
+
expect(exitCode).toBe(0);
|
|
64
|
+
|
|
65
|
+
const result = JSON.parse(stdout);
|
|
66
|
+
expect(result.decision).toBe('block');
|
|
67
|
+
expect(result.reason).toMatch(/\[NETWORK_IP_[a-f0-9]{6}\]/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should pass through clean output (no PII) with no output', () => {
|
|
71
|
+
const payload = {
|
|
72
|
+
tool_name: 'Bash',
|
|
73
|
+
tool_response: { stdout: 'hello world' },
|
|
74
|
+
};
|
|
75
|
+
const { stdout, exitCode } = runHook(JSON.stringify(payload));
|
|
76
|
+
expect(exitCode).toBe(0);
|
|
77
|
+
expect(stdout.trim()).toBe('');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should handle empty stdin gracefully', () => {
|
|
81
|
+
const { stdout, exitCode } = runHook('');
|
|
82
|
+
expect(exitCode).toBe(0);
|
|
83
|
+
expect(stdout.trim()).toBe('');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should exit 2 for invalid JSON', () => {
|
|
87
|
+
const { exitCode, stderr } = runHook('not json');
|
|
88
|
+
expect(exitCode).toBe(2);
|
|
89
|
+
expect(stderr).toContain('invalid JSON');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should handle payload with no tool_response', () => {
|
|
93
|
+
const payload = { tool_name: 'Bash' };
|
|
94
|
+
const { stdout, exitCode } = runHook(JSON.stringify(payload));
|
|
95
|
+
expect(exitCode).toBe(0);
|
|
96
|
+
expect(stdout.trim()).toBe('');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should combine stdout and stderr when both have PII', () => {
|
|
100
|
+
const payload = {
|
|
101
|
+
tool_name: 'Bash',
|
|
102
|
+
tool_response: {
|
|
103
|
+
stdout: 'user email: alice@example.com',
|
|
104
|
+
stderr: 'warning: 10.0.0.1 unreachable',
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
const { stdout, exitCode } = runHook(JSON.stringify(payload));
|
|
108
|
+
expect(exitCode).toBe(0);
|
|
109
|
+
|
|
110
|
+
const result = JSON.parse(stdout);
|
|
111
|
+
expect(result.decision).toBe('block');
|
|
112
|
+
expect(result.reason).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/);
|
|
113
|
+
expect(result.reason).toMatch(/\[NETWORK_IP_[a-f0-9]{6}\]/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should redact secrets from tool response', () => {
|
|
117
|
+
const payload = {
|
|
118
|
+
tool_name: 'Bash',
|
|
119
|
+
tool_response: { stdout: 'api_key=sk-1234567890abcdef1234' },
|
|
120
|
+
};
|
|
121
|
+
const { stdout, exitCode } = runHook(JSON.stringify(payload));
|
|
122
|
+
expect(exitCode).toBe(0);
|
|
123
|
+
|
|
124
|
+
const result = JSON.parse(stdout);
|
|
125
|
+
expect(result.decision).toBe('block');
|
|
126
|
+
expect(result.reason).toMatch(/\[SENSITIVE_SECRET_[a-f0-9]{6}\]/);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should handle Grep tool with top-level content field', () => {
|
|
130
|
+
const payload = {
|
|
131
|
+
tool_name: 'Grep',
|
|
132
|
+
tool_response: { content: 'src/config.ts:3: email: "dev@internal.corp"' },
|
|
133
|
+
};
|
|
134
|
+
const { stdout, exitCode } = runHook(JSON.stringify(payload));
|
|
135
|
+
expect(exitCode).toBe(0);
|
|
136
|
+
|
|
137
|
+
const result = JSON.parse(stdout);
|
|
138
|
+
expect(result.decision).toBe('block');
|
|
139
|
+
expect(result.reason).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/);
|
|
140
|
+
expect(result.reason).not.toContain('dev@internal.corp');
|
|
141
|
+
});
|
|
142
|
+
});
|
package/tests/redaction.test.ts
CHANGED
|
@@ -99,4 +99,100 @@ describe('Semantic Security Flow (Redaction + Rehydration)', () => {
|
|
|
99
99
|
expect(hasRedacted).toBe(true);
|
|
100
100
|
expect(content).toMatch(/^Call \[NETWORK_IP_[a-f0-9]{6}\]$/);
|
|
101
101
|
});
|
|
102
|
+
|
|
103
|
+
describe('cloud provider key detection', () => {
|
|
104
|
+
it('should redact AWS access key IDs', () => {
|
|
105
|
+
const input = 'key: AKIAIOSFODNN7EXAMPLE';
|
|
106
|
+
const { content, hasRedacted } = redactor.redact(input);
|
|
107
|
+
expect(hasRedacted).toBe(true);
|
|
108
|
+
expect(content).toMatch(/\[AWS_KEY_[a-f0-9]{6}\]/);
|
|
109
|
+
expect(content).not.toContain('AKIAIOSFODNN7EXAMPLE');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should redact GCP API keys', () => {
|
|
113
|
+
const input = 'key: AIzaSyA1234567890abcdefghijklmnopqrstuv';
|
|
114
|
+
const { content, hasRedacted } = redactor.redact(input);
|
|
115
|
+
expect(hasRedacted).toBe(true);
|
|
116
|
+
expect(content).toMatch(/\[GCP_KEY_[a-f0-9]{6}\]/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should redact GitHub PATs', () => {
|
|
120
|
+
const input = 'token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij';
|
|
121
|
+
const { content, hasRedacted } = redactor.redact(input);
|
|
122
|
+
expect(hasRedacted).toBe(true);
|
|
123
|
+
expect(content).toMatch(/\[GITHUB_PAT_[a-f0-9]{6}\]/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should redact GitHub fine-grained PATs', () => {
|
|
127
|
+
const input = 'github_pat_1234567890abcdefghijkl_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456';
|
|
128
|
+
const { content, hasRedacted } = redactor.redact(input);
|
|
129
|
+
expect(hasRedacted).toBe(true);
|
|
130
|
+
expect(content).toMatch(/\[GITHUB_FINE_PAT_[a-f0-9]{6}\]/);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should redact GitLab PATs', () => {
|
|
134
|
+
const input = 'token: glpat-1234567890abcdefghij';
|
|
135
|
+
const { content, hasRedacted } = redactor.redact(input);
|
|
136
|
+
expect(hasRedacted).toBe(true);
|
|
137
|
+
expect(content).toMatch(/\[GITLAB_PAT_[a-f0-9]{6}\]/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should redact Slack bot tokens', () => {
|
|
141
|
+
const input = 'xoxb-1234567890123-1234567890123-abc';
|
|
142
|
+
const { content, hasRedacted } = redactor.redact(input);
|
|
143
|
+
expect(hasRedacted).toBe(true);
|
|
144
|
+
expect(content).toMatch(/\[SLACK_BOT_[a-f0-9]{6}\]/);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should redact Stripe secret keys', () => {
|
|
148
|
+
// Concatenated to avoid GitHub push-protection false positive
|
|
149
|
+
const input = 'sk_live_' + '1234567890abcdefghijklmnop';
|
|
150
|
+
const { content, hasRedacted } = redactor.redact(input);
|
|
151
|
+
expect(hasRedacted).toBe(true);
|
|
152
|
+
expect(content).toMatch(/\[STRIPE_SECRET_[a-f0-9]{6}\]/);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should redact SendGrid API keys', () => {
|
|
156
|
+
// Concatenated to avoid GitHub push-protection false positive
|
|
157
|
+
const input = 'SG.' + 'abcdefghijklmnopqrstuv.yz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ01234';
|
|
158
|
+
const { content, hasRedacted } = redactor.redact(input);
|
|
159
|
+
expect(hasRedacted).toBe(true);
|
|
160
|
+
expect(content).toMatch(/\[SENDGRID_KEY_[a-f0-9]{6}\]/);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should redact npm tokens', () => {
|
|
164
|
+
const input = 'npm_abcdefghijklmnopqrstuvwxyz0123456789';
|
|
165
|
+
const { content, hasRedacted } = redactor.redact(input);
|
|
166
|
+
expect(hasRedacted).toBe(true);
|
|
167
|
+
expect(content).toMatch(/\[NPM_TOKEN_[a-f0-9]{6}\]/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should redact Anthropic API keys', () => {
|
|
171
|
+
const input = 'sk-ant-api03-abcdefghijklmnopqrstuvwxyz0123456789';
|
|
172
|
+
const { content, hasRedacted } = redactor.redact(input);
|
|
173
|
+
expect(hasRedacted).toBe(true);
|
|
174
|
+
expect(content).toMatch(/\[ANTHROPIC_KEY_[a-f0-9]{6}\]/);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should redact DigitalOcean PATs', () => {
|
|
178
|
+
const input = 'dop_v1_' + 'a'.repeat(64);
|
|
179
|
+
const { content, hasRedacted } = redactor.redact(input);
|
|
180
|
+
expect(hasRedacted).toBe(true);
|
|
181
|
+
expect(content).toMatch(/\[DIGITALOCEAN_TOKEN_[a-f0-9]{6}\]/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should redact PEM private keys', () => {
|
|
185
|
+
const input = '-----BEGIN RSA PRIVATE KEY-----\n' + 'A'.repeat(100) + '\n-----END RSA PRIVATE KEY-----';
|
|
186
|
+
const { content, hasRedacted } = redactor.redact(input);
|
|
187
|
+
expect(hasRedacted).toBe(true);
|
|
188
|
+
expect(content).toMatch(/\[PRIVATE_KEY_[a-f0-9]{6}\]/);
|
|
189
|
+
expect(content).not.toContain('BEGIN RSA PRIVATE KEY');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should not false-positive on normal text', () => {
|
|
193
|
+
const input = 'The package.json file has scripts and dependencies.';
|
|
194
|
+
const { hasRedacted } = redactor.redact(input);
|
|
195
|
+
expect(hasRedacted).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
102
198
|
});
|