@coo-quack/sensitive-canary 0.4.6 → 0.5.1

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sensitive-canary",
3
3
  "description": "Blocks secrets and PII before they reach the Anthropic API",
4
- "version": "0.4.6",
4
+ "version": "0.5.1",
5
5
  "author": {
6
6
  "name": "coo-quack"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.5.1 (2026-03-15)
4
+
5
+ ### Fixes
6
+
7
+ - Remove `marketplace.json` and sync marketplace via `repository_dispatch` on release
8
+ - Gate marketplace sync on actual release creation to prevent duplicate dispatches
9
+ - Update marketplace registration commands across README and docs to point to `coo-quack/claude-code-marketplace`
10
+ - Remove stale `marketplace.json` references from `CONTRIBUTING.md` and `README.md`
11
+ - Simplify backport workflow to direct main-to-develop merge
12
+
13
+ ---
14
+
15
+ ## v0.5.0 (2026-03-14)
16
+
17
+ ### Features
18
+
19
+ - Add Google Cloud API Key (`gcp-api-key`) detection rule
20
+ - Add npm Access Token (`npm-token`) detection rule
21
+
22
+ ### Fixes
23
+
24
+ - Prevent `openai-key` (legacy) rule from overlapping with `openai-project-key` and `anthropic-key` via negative lookahead
25
+ - Use nullish coalescing (`??`) in `entropy()` for correct semantics under `noUncheckedIndexedAccess`
26
+ - Remove unreachable `unique` filter in `user-prompt-submit-hook`
27
+ - Consolidate `randomBird()` calls in `block()` for consistent emoji across terminal and JSON output
28
+ - Fix fd leaks in file read and `/dev/tty` write paths with `try/finally`
29
+ - Use `bytesRead` return value from `fs.readSync` to avoid NUL-filled buffer tails
30
+ - Scan text prefix before first NUL byte in binary files instead of skipping entirely
31
+
32
+ ### Performance
33
+
34
+ - Read only the last 64 KB of transcript files for allow-tag resolution
35
+ - Skip binary content after first NUL byte to avoid pointless regex scanning
36
+
37
+ ### Documentation
38
+
39
+ - Unify documentation site structure with Getting Started and Troubleshooting pages
40
+ - Symlink `docs/contributing.md` to root `CONTRIBUTING.md`
41
+
42
+ ---
43
+
3
44
  ## v0.4.6 (2026-03-12)
4
45
 
5
46
  ### 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
- - **29 detection rules** — sourced from gitleaks and TruffleHog detector definitions
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
@@ -46,7 +46,7 @@ Install in two commands from inside a Claude Code session:
46
46
  **1. Register the marketplace**
47
47
 
48
48
  ```
49
- /plugin marketplace add coo-quack/sensitive-canary
49
+ /plugin marketplace add coo-quack/claude-code-marketplace
50
50
  ```
51
51
 
52
52
  **2. Install the plugin**
@@ -188,9 +188,9 @@ To allow it through, add the suggested tag:
188
188
  ```
189
189
  > Read .env
190
190
 
191
- 📄 sensitive-canary: blocked — /path/to/.env
191
+ 🐤 sensitive-canary: blocked — /path/to/.env
192
192
 
193
- 🐤 Blocked: .env and .env.* files contain secrets and must not be read into the conversation.
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
- 📄 sensitive-canary: blocked — /path/to/config.yaml
210
+ 🐤 sensitive-canary: blocked — /path/to/config.yaml
211
211
 
212
- 🐤 Blocked: file contains sensitive data
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 (22 rules)
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 |
@@ -358,7 +360,6 @@ When both `[allow-*]` and `[mask-*]` tags appear in the same prompt, **the tag t
358
360
  ```
359
361
  .claude-plugin/
360
362
  plugin.json plugin manifest
361
- marketplace.json marketplace catalog
362
363
  hooks/
363
364
  hooks.json Claude Code hook configuration
364
365
  src/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coo-quack/sensitive-canary",
3
- "version": "0.4.6",
3
+ "version": "0.5.1",
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] || 0) + 1;
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
  {
@@ -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
- raw = fs.readFileSync(transcriptPath, "utf8");
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
- `${randomBird()} sensitive-canary: blocked — ${source}`,
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
- fs.writeSync(fd, terminalMessage);
187
- fs.closeSync(fd);
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
- `${randomBird()} Blocked: .env and .env.* files contain secrets and must not be read into the conversation.`,
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
- content = fs.readFileSync(filePath, "utf8");
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
- `${randomBird()} Blocked: file contains sensitive data`,
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
- `${randomBird()} Blocked: environment variable $${varName} contains sensitive data`,
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
- `${randomBird()} Blocked: bash command contains sensitive data`,
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 unique: Finding[] = afterAllow.filter(
80
- (f) =>
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(unique),
86
+ ...findingsToLines(afterAllow),
95
87
  "",
96
88
  "To allow, add a tag to your prompt:",
97
89
  ...(hasSecret ? [" [allow-secret] — allow secrets"] : []),
@@ -1,22 +0,0 @@
1
- {
2
- "name": "coo-quack",
3
- "owner": {
4
- "name": "coo-quack"
5
- },
6
- "metadata": {
7
- "description": "Security hooks that block secrets and PII before they reach the Anthropic API"
8
- },
9
- "plugins": [
10
- {
11
- "name": "sensitive-canary",
12
- "source": {
13
- "source": "github",
14
- "repo": "coo-quack/sensitive-canary"
15
- },
16
- "description": "Blocks secrets and PII before they reach the Anthropic API",
17
- "version": "0.4.6",
18
- "license": "MIT",
19
- "keywords": ["security", "secrets", "pii", "hooks"]
20
- }
21
- ]
22
- }