@coo-quack/sensitive-canary 0.4.6 → 0.5.0
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +29 -0
- package/README.md +8 -6
- package/package.json +1 -1
- package/src/__tests__/pre-tool-use-hook.test.ts +67 -0
- package/src/lib/__tests__/rules.test.ts +30 -0
- package/src/lib/rules.ts +16 -2
- package/src/pre-tool-use-hook.ts +42 -10
- package/src/user-prompt-submit-hook.ts +3 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.5.0 (2026-03-14)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- Add Google Cloud API Key (`gcp-api-key`) detection rule
|
|
8
|
+
- Add npm Access Token (`npm-token`) detection rule
|
|
9
|
+
|
|
10
|
+
### Fixes
|
|
11
|
+
|
|
12
|
+
- Prevent `openai-key` (legacy) rule from overlapping with `openai-project-key` and `anthropic-key` via negative lookahead
|
|
13
|
+
- Use nullish coalescing (`??`) in `entropy()` for correct semantics under `noUncheckedIndexedAccess`
|
|
14
|
+
- Remove unreachable `unique` filter in `user-prompt-submit-hook`
|
|
15
|
+
- Consolidate `randomBird()` calls in `block()` for consistent emoji across terminal and JSON output
|
|
16
|
+
- Fix fd leaks in file read and `/dev/tty` write paths with `try/finally`
|
|
17
|
+
- Use `bytesRead` return value from `fs.readSync` to avoid NUL-filled buffer tails
|
|
18
|
+
- Scan text prefix before first NUL byte in binary files instead of skipping entirely
|
|
19
|
+
|
|
20
|
+
### Performance
|
|
21
|
+
|
|
22
|
+
- Read only the last 64 KB of transcript files for allow-tag resolution
|
|
23
|
+
- Skip binary content after first NUL byte to avoid pointless regex scanning
|
|
24
|
+
|
|
25
|
+
### Documentation
|
|
26
|
+
|
|
27
|
+
- Unify documentation site structure with Getting Started and Troubleshooting pages
|
|
28
|
+
- Symlink `docs/contributing.md` to root `CONTRIBUTING.md`
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
3
32
|
## v0.4.6 (2026-03-12)
|
|
4
33
|
|
|
5
34
|
### Security
|
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ Claude Code is a powerful development tool, but file reads and command execution
|
|
|
25
25
|
| `echo $API_KEY` with live key ❌ | Env var value scanned and blocked ✅ |
|
|
26
26
|
|
|
27
27
|
- **Two hooks** — `UserPromptSubmit` and `PreToolUse` cover both directions of risk
|
|
28
|
-
- **
|
|
28
|
+
- **31 detection rules** — sourced from gitleaks and TruffleHog detector definitions
|
|
29
29
|
- **Entropy filtering** — reduces false positives on low-entropy values
|
|
30
30
|
- **Luhn validation** — credit card numbers are validated, not just pattern-matched
|
|
31
31
|
- **Local only** — all scanning runs in your terminal; nothing is sent anywhere
|
|
@@ -188,9 +188,9 @@ To allow it through, add the suggested tag:
|
|
|
188
188
|
```
|
|
189
189
|
> Read .env
|
|
190
190
|
|
|
191
|
-
|
|
191
|
+
🐤 sensitive-canary: blocked — /path/to/.env
|
|
192
192
|
|
|
193
|
-
|
|
193
|
+
🚫 Blocked: .env and .env.* files contain secrets and must not be read into the conversation.
|
|
194
194
|
|
|
195
195
|
To allow this, the user must add an allow tag to their next prompt:
|
|
196
196
|
[allow-secret] — allow secrets
|
|
@@ -207,9 +207,9 @@ Non-`.env` files are also blocked if their contents contain secrets or PII.
|
|
|
207
207
|
```
|
|
208
208
|
> Read config.yaml
|
|
209
209
|
|
|
210
|
-
|
|
210
|
+
🐤 sensitive-canary: blocked — /path/to/config.yaml
|
|
211
211
|
|
|
212
|
-
|
|
212
|
+
🚫 Blocked: file contains sensitive data
|
|
213
213
|
|
|
214
214
|
[Secret] AWS Access Key ID (aws-access-key): AKIA****MPLE
|
|
215
215
|
```
|
|
@@ -230,15 +230,17 @@ To intentionally bypass a block, include the appropriate tag in your **current p
|
|
|
230
230
|
|
|
231
231
|
## Detection rules
|
|
232
232
|
|
|
233
|
-
### Secrets (
|
|
233
|
+
### Secrets (24 rules)
|
|
234
234
|
|
|
235
235
|
| Rule ID | Description |
|
|
236
236
|
|---|---|
|
|
237
237
|
| `aws-access-key` | AWS Access Key ID |
|
|
238
|
+
| `gcp-api-key` | Google Cloud API Key |
|
|
238
239
|
| `private-key` | PEM Private Key (RSA / EC / DSA / PGP / OpenSSH) |
|
|
239
240
|
| `github-pat` | GitHub Personal Access Token |
|
|
240
241
|
| `github-fine-grained` | GitHub Fine-Grained Token |
|
|
241
242
|
| `gitlab-pat` | GitLab Personal Access Token |
|
|
243
|
+
| `npm-token` | npm Access Token |
|
|
242
244
|
| `slack-token` | Slack Token |
|
|
243
245
|
| `slack-webhook` | Slack Webhook URL |
|
|
244
246
|
| `discord-webhook` | Discord Webhook URL |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coo-quack/sensitive-canary",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Claude Code hooks that block secrets and PII before they reach the Anthropic API",
|
|
5
5
|
"homepage": "https://coo-quack.github.io/sensitive-canary/",
|
|
6
6
|
"type": "module",
|
|
@@ -245,6 +245,73 @@ describe("pre-tool-use-hook — sensitive content blocking", () => {
|
|
|
245
245
|
});
|
|
246
246
|
});
|
|
247
247
|
|
|
248
|
+
// ── binary file handling ─────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
describe("pre-tool-use-hook — binary file handling", () => {
|
|
251
|
+
it("blocks when a secret appears before the first NUL byte", () => {
|
|
252
|
+
const content = Buffer.concat([
|
|
253
|
+
Buffer.from("key=AKIAIOSFODNN7EXAMPLE\n"),
|
|
254
|
+
Buffer.from([0x00]),
|
|
255
|
+
Buffer.from("binary data"),
|
|
256
|
+
]);
|
|
257
|
+
const p = join(tmpDir, "binary-secret-before-nul.bin");
|
|
258
|
+
writeFileSync(p, content);
|
|
259
|
+
const { exitCode, decision } = runHook("Read", p);
|
|
260
|
+
expect(exitCode).toBe(2);
|
|
261
|
+
expect(decision).toBe("block");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("allows a binary file when no secret appears before the first NUL byte", () => {
|
|
265
|
+
const content = Buffer.concat([
|
|
266
|
+
Buffer.from("clean text\n"),
|
|
267
|
+
Buffer.from([0x00]),
|
|
268
|
+
Buffer.from("AKIAIOSFODNN7EXAMPLE"),
|
|
269
|
+
]);
|
|
270
|
+
const p = join(tmpDir, "binary-secret-after-nul.bin");
|
|
271
|
+
writeFileSync(p, content);
|
|
272
|
+
const { exitCode } = runHook("Read", p);
|
|
273
|
+
expect(exitCode).toBe(0);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("allows a binary file that starts with NUL", () => {
|
|
277
|
+
const content = Buffer.concat([
|
|
278
|
+
Buffer.from([0x00]),
|
|
279
|
+
Buffer.from("AKIAIOSFODNN7EXAMPLE"),
|
|
280
|
+
]);
|
|
281
|
+
const p = join(tmpDir, "binary-nul-start.bin");
|
|
282
|
+
writeFileSync(p, content);
|
|
283
|
+
const { exitCode } = runHook("Read", p);
|
|
284
|
+
expect(exitCode).toBe(0);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ── transcript tail read ────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
describe("pre-tool-use-hook — transcript tail read (64 KB)", () => {
|
|
291
|
+
it("[allow-all] in a large transcript (>64KB) is respected when near the end", () => {
|
|
292
|
+
// Build a transcript larger than 64KB with the allow tag in the last message
|
|
293
|
+
const filler = JSON.stringify({
|
|
294
|
+
type: "user",
|
|
295
|
+
message: { role: "user", content: "x".repeat(1024) },
|
|
296
|
+
});
|
|
297
|
+
const fillerLines = Array.from({ length: 70 }, () => filler).join("\n");
|
|
298
|
+
const allowLine = JSON.stringify({
|
|
299
|
+
type: "user",
|
|
300
|
+
message: { role: "user", content: "[allow-all] read everything" },
|
|
301
|
+
});
|
|
302
|
+
const transcriptContent = `${fillerLines}\n${allowLine}\n`;
|
|
303
|
+
const tp = join(tmpDir, "large-transcript.jsonl");
|
|
304
|
+
writeFileSync(tp, transcriptContent, "utf8");
|
|
305
|
+
|
|
306
|
+
const p = writeFixture(
|
|
307
|
+
"large-transcript-test.txt",
|
|
308
|
+
"key=AKIAIOSFODNN7EXAMPLE\n",
|
|
309
|
+
);
|
|
310
|
+
const { exitCode } = runHook("Read", p, { transcriptPath: tp });
|
|
311
|
+
expect(exitCode).toBe(0);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
248
315
|
// ── Bash tool — env var expansion ────────────────────────────────────────────
|
|
249
316
|
|
|
250
317
|
describe("pre-tool-use-hook — Bash tool (env var expansion)", () => {
|
|
@@ -68,6 +68,26 @@ describe("scan — secrets", () => {
|
|
|
68
68
|
expect(findings.some((f) => f.ruleId === "aws-access-key")).toBe(true);
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
+
it("detects a GCP API key", () => {
|
|
72
|
+
const findings = scan(`key=AIzaSyC${"A".repeat(32)}`);
|
|
73
|
+
expect(findings.some((f) => f.ruleId === "gcp-api-key")).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("does not flag a string starting with AIza but too short", () => {
|
|
77
|
+
const findings = scan("AIzaSyC_short");
|
|
78
|
+
expect(findings.some((f) => f.ruleId === "gcp-api-key")).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("detects an npm access token", () => {
|
|
82
|
+
const findings = scan(`npm_${"A".repeat(36)}`);
|
|
83
|
+
expect(findings.some((f) => f.ruleId === "npm-token")).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("does not flag npm_ with insufficient length", () => {
|
|
87
|
+
const findings = scan("npm_shorttoken");
|
|
88
|
+
expect(findings.some((f) => f.ruleId === "npm-token")).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
71
91
|
it("detects a PEM private key header (RSA)", () => {
|
|
72
92
|
const findings = scan("-----BEGIN RSA PRIVATE KEY-----");
|
|
73
93
|
expect(findings.some((f) => f.ruleId === "private-key")).toBe(true);
|
|
@@ -154,6 +174,16 @@ describe("scan — secrets", () => {
|
|
|
154
174
|
expect(findings.some((f) => f.ruleId === "openai-key")).toBe(true);
|
|
155
175
|
});
|
|
156
176
|
|
|
177
|
+
it("does not flag sk-proj-* as openai-key (legacy)", () => {
|
|
178
|
+
const findings = scan("sk-proj-Xk9mP2qR7vL4nW1sYj3cBz8dEf5gHiKoNpQuTxMn");
|
|
179
|
+
expect(findings.some((f) => f.ruleId === "openai-key")).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("does not flag sk-ant-* as openai-key (legacy)", () => {
|
|
183
|
+
const findings = scan(`sk-ant-${"A".repeat(95)}`);
|
|
184
|
+
expect(findings.some((f) => f.ruleId === "openai-key")).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
157
187
|
it("detects an OpenAI project API key", () => {
|
|
158
188
|
const findings = scan("sk-proj-Xk9mP2qR7vL4nW1sYj3cBz8dEf5gHiKoNpQuTxMn");
|
|
159
189
|
expect(findings.some((f) => f.ruleId === "openai-project-key")).toBe(true);
|
package/src/lib/rules.ts
CHANGED
|
@@ -37,7 +37,7 @@ export function luhn(str: string): boolean {
|
|
|
37
37
|
export function entropy(str: string): number {
|
|
38
38
|
if (str.length === 0) return 0;
|
|
39
39
|
const freq: Record<string, number> = {};
|
|
40
|
-
for (const ch of str) freq[ch] = (freq[ch]
|
|
40
|
+
for (const ch of str) freq[ch] = (freq[ch] ?? 0) + 1;
|
|
41
41
|
let h = 0;
|
|
42
42
|
const n = str.length;
|
|
43
43
|
for (const count of Object.values(freq)) {
|
|
@@ -64,6 +64,12 @@ const SECRET_RULES: Rule[] = [
|
|
|
64
64
|
/\b(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}\b/g,
|
|
65
65
|
category: "secret",
|
|
66
66
|
},
|
|
67
|
+
{
|
|
68
|
+
id: "gcp-api-key",
|
|
69
|
+
description: "Google Cloud API Key",
|
|
70
|
+
regex: /AIza[0-9A-Za-z_-]{35}/g,
|
|
71
|
+
category: "secret",
|
|
72
|
+
},
|
|
67
73
|
{
|
|
68
74
|
id: "private-key",
|
|
69
75
|
description: "PEM Private Key",
|
|
@@ -92,6 +98,14 @@ const SECRET_RULES: Rule[] = [
|
|
|
92
98
|
category: "secret",
|
|
93
99
|
},
|
|
94
100
|
|
|
101
|
+
// Package registries
|
|
102
|
+
{
|
|
103
|
+
id: "npm-token",
|
|
104
|
+
description: "npm Access Token",
|
|
105
|
+
regex: /npm_[A-Za-z0-9]{36}/g,
|
|
106
|
+
category: "secret",
|
|
107
|
+
},
|
|
108
|
+
|
|
95
109
|
// Communication
|
|
96
110
|
{
|
|
97
111
|
id: "slack-token",
|
|
@@ -164,7 +178,7 @@ const SECRET_RULES: Rule[] = [
|
|
|
164
178
|
{
|
|
165
179
|
id: "openai-key",
|
|
166
180
|
description: "OpenAI API Key (legacy)",
|
|
167
|
-
regex: /sk-[A-Za-z0-9]{48}/g,
|
|
181
|
+
regex: /sk-(?!proj-|ant-)[A-Za-z0-9]{48}/g,
|
|
168
182
|
category: "secret",
|
|
169
183
|
},
|
|
170
184
|
{
|
package/src/pre-tool-use-hook.ts
CHANGED
|
@@ -25,6 +25,11 @@ interface TranscriptLine {
|
|
|
25
25
|
message?: Message;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
// Maximum bytes to read from the tail of a transcript file.
|
|
31
|
+
const MAX_TRANSCRIPT_TAIL_BYTES = 65_536; // 64 KB
|
|
32
|
+
|
|
28
33
|
// ── Transcript ────────────────────────────────────────────────────────────────
|
|
29
34
|
|
|
30
35
|
// Returns true when the message contains at least one text content block
|
|
@@ -42,7 +47,25 @@ function hasTextContent(msg: Message): boolean {
|
|
|
42
47
|
function loadAllowTagsFromTranscript(transcriptPath: string): Set<string> {
|
|
43
48
|
let raw: string;
|
|
44
49
|
try {
|
|
45
|
-
|
|
50
|
+
const stat = fs.statSync(transcriptPath);
|
|
51
|
+
if (stat.size <= MAX_TRANSCRIPT_TAIL_BYTES) {
|
|
52
|
+
raw = fs.readFileSync(transcriptPath, "utf8");
|
|
53
|
+
} else {
|
|
54
|
+
const buf = Buffer.alloc(MAX_TRANSCRIPT_TAIL_BYTES);
|
|
55
|
+
const fd = fs.openSync(transcriptPath, "r");
|
|
56
|
+
try {
|
|
57
|
+
const bytesRead = fs.readSync(
|
|
58
|
+
fd,
|
|
59
|
+
buf,
|
|
60
|
+
0,
|
|
61
|
+
MAX_TRANSCRIPT_TAIL_BYTES,
|
|
62
|
+
stat.size - MAX_TRANSCRIPT_TAIL_BYTES,
|
|
63
|
+
);
|
|
64
|
+
raw = buf.subarray(0, bytesRead).toString("utf8");
|
|
65
|
+
} finally {
|
|
66
|
+
fs.closeSync(fd);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
46
69
|
} catch {
|
|
47
70
|
return new Set();
|
|
48
71
|
}
|
|
@@ -173,9 +196,10 @@ function block(
|
|
|
173
196
|
detectionLines: string[],
|
|
174
197
|
allowHints: string[],
|
|
175
198
|
): never {
|
|
199
|
+
const bird = randomBird();
|
|
176
200
|
const terminalMessage = [
|
|
177
201
|
"",
|
|
178
|
-
`${
|
|
202
|
+
`${bird} sensitive-canary: blocked — ${source}`,
|
|
179
203
|
"",
|
|
180
204
|
...detectionLines,
|
|
181
205
|
"",
|
|
@@ -183,13 +207,15 @@ function block(
|
|
|
183
207
|
|
|
184
208
|
try {
|
|
185
209
|
const fd = fs.openSync("/dev/tty", "w");
|
|
186
|
-
|
|
187
|
-
|
|
210
|
+
try {
|
|
211
|
+
fs.writeSync(fd, terminalMessage);
|
|
212
|
+
} finally {
|
|
213
|
+
fs.closeSync(fd);
|
|
214
|
+
}
|
|
188
215
|
} catch {
|
|
189
216
|
process.stderr.write(terminalMessage);
|
|
190
217
|
}
|
|
191
218
|
|
|
192
|
-
const bird = randomBird();
|
|
193
219
|
const reasonLines = [
|
|
194
220
|
`${bird} sensitive-canary blocked: ${source}`,
|
|
195
221
|
"",
|
|
@@ -218,7 +244,7 @@ function scanFile(filePath: string, allowTags: Set<string>): void {
|
|
|
218
244
|
block(
|
|
219
245
|
filePath,
|
|
220
246
|
[
|
|
221
|
-
|
|
247
|
+
"🚫 Blocked: .env and .env.* files contain secrets and must not be read into the conversation.",
|
|
222
248
|
],
|
|
223
249
|
buildAllowHints(`please read ${filePath}`, [], true),
|
|
224
250
|
);
|
|
@@ -226,7 +252,13 @@ function scanFile(filePath: string, allowTags: Set<string>): void {
|
|
|
226
252
|
|
|
227
253
|
let content: string;
|
|
228
254
|
try {
|
|
229
|
-
|
|
255
|
+
const raw = fs.readFileSync(filePath);
|
|
256
|
+
// Binary files: scan only the text prefix before the first NUL byte
|
|
257
|
+
const nulIndex = raw.indexOf(0);
|
|
258
|
+
content = (nulIndex === -1 ? raw : raw.subarray(0, nulIndex)).toString(
|
|
259
|
+
"utf8",
|
|
260
|
+
);
|
|
261
|
+
if (content.length === 0) return;
|
|
230
262
|
} catch {
|
|
231
263
|
return;
|
|
232
264
|
}
|
|
@@ -237,7 +269,7 @@ function scanFile(filePath: string, allowTags: Set<string>): void {
|
|
|
237
269
|
block(
|
|
238
270
|
filePath,
|
|
239
271
|
[
|
|
240
|
-
|
|
272
|
+
"🚫 Blocked: file contains sensitive data",
|
|
241
273
|
"",
|
|
242
274
|
...findingsToLines(findings),
|
|
243
275
|
],
|
|
@@ -281,7 +313,7 @@ process.stdin.on("end", () => {
|
|
|
281
313
|
block(
|
|
282
314
|
`bash command: ${command.slice(0, 80)}`,
|
|
283
315
|
[
|
|
284
|
-
|
|
316
|
+
`🚫 Blocked: environment variable $${varName} contains sensitive data`,
|
|
285
317
|
"",
|
|
286
318
|
...findingsToLines(findings),
|
|
287
319
|
],
|
|
@@ -297,7 +329,7 @@ process.stdin.on("end", () => {
|
|
|
297
329
|
block(
|
|
298
330
|
`bash command: ${command.slice(0, 80)}`,
|
|
299
331
|
[
|
|
300
|
-
|
|
332
|
+
"🚫 Blocked: bash command contains sensitive data",
|
|
301
333
|
"",
|
|
302
334
|
...findingsToLines(cmdFindings),
|
|
303
335
|
],
|
|
@@ -76,22 +76,14 @@ process.stdin.on("end", () => {
|
|
|
76
76
|
process.exit(2);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
!(f.category === "secret" && effectiveMask.has("secret")) &&
|
|
82
|
-
!(f.category === "pii" && effectiveMask.has("pii")),
|
|
83
|
-
);
|
|
84
|
-
|
|
85
|
-
if (unique.length === 0) process.exit(0);
|
|
86
|
-
|
|
87
|
-
const hasSecret = unique.some((f) => f.category === "secret");
|
|
88
|
-
const hasPii = unique.some((f) => f.category === "pii");
|
|
79
|
+
const hasSecret = afterAllow.some((f) => f.category === "secret");
|
|
80
|
+
const hasPii = afterAllow.some((f) => f.category === "pii");
|
|
89
81
|
|
|
90
82
|
const blockLines = [
|
|
91
83
|
"",
|
|
92
84
|
`${randomBird()} sensitive-canary: sensitive data detected — blocked`,
|
|
93
85
|
"",
|
|
94
|
-
...findingsToLines(
|
|
86
|
+
...findingsToLines(afterAllow),
|
|
95
87
|
"",
|
|
96
88
|
"To allow, add a tag to your prompt:",
|
|
97
89
|
...(hasSecret ? [" [allow-secret] — allow secrets"] : []),
|