@coo-quack/sensitive-canary 0.5.3 → 0.6.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/CHANGELOG.md +22 -8
- package/README.md +29 -3
- package/package.json +6 -6
- package/src/__tests__/pre-tool-use-hook.test.ts +87 -21
- package/src/__tests__/user-prompt-submit-hook.test.ts +43 -1
- package/src/lib/__tests__/rules.test.ts +98 -2
- package/src/lib/rules.ts +32 -3
- package/src/pre-tool-use-hook.ts +21 -6
- package/src/user-prompt-submit-hook.ts +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,27 @@
|
|
|
1
|
-
|
|
1
|
+
# Changelog
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## v0.6.0 (2026-08-02)
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
-
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- Add `SENSITIVE_CANARY_CATEGORIES` environment variable to limit which rule categories are active
|
|
8
|
+
- Accepts `secret`, `pii`, `secret,pii`, or `all` (comma-separated, case-insensitive); unset/empty/invalid means all categories
|
|
9
|
+
- Useful for reducing PII false positives (e.g. credit card or phone number rules firing on test fixtures) by scanning secrets only
|
|
10
|
+
- The name-based `.env`/`.env.*` block is a secret guard and is disabled when the `secret` category is not enabled
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## v0.5.3 (2026-06-27)
|
|
15
|
+
|
|
16
|
+
### CI
|
|
17
|
+
|
|
18
|
+
- Rework the main→develop sync to open a PR with auto-merge, using a minted GitHub App token so the created PR triggers CI
|
|
19
|
+
- Disable persist-credentials in the sync workflow so the App token push works
|
|
20
|
+
|
|
21
|
+
### Dependencies
|
|
22
|
+
|
|
23
|
+
- Pin pnpm via the `packageManager` field and update pnpm to v11 (security)
|
|
24
|
+
- Update node to v24, vite to v8, typescript to v6, and other dev dependencies and GitHub Actions
|
|
9
25
|
|
|
10
26
|
---
|
|
11
27
|
|
|
@@ -20,8 +36,6 @@
|
|
|
20
36
|
|
|
21
37
|
---
|
|
22
38
|
|
|
23
|
-
# Changelog
|
|
24
|
-
|
|
25
39
|
## v0.5.1 (2026-03-15)
|
|
26
40
|
|
|
27
41
|
### Fixes
|
package/README.md
CHANGED
|
@@ -183,7 +183,7 @@ To allow it through, add the suggested tag:
|
|
|
183
183
|
|
|
184
184
|
### .env file blocked
|
|
185
185
|
|
|
186
|
-
`.env` / `.env.*` files are blocked
|
|
186
|
+
`.env` / `.env.*` files are blocked by filename, regardless of their contents. This name-based block is a secret guard and only applies while the `secret` category is enabled (the default).
|
|
187
187
|
|
|
188
188
|
```
|
|
189
189
|
> Read .env
|
|
@@ -228,6 +228,32 @@ To intentionally bypass a block, include the appropriate tag in your **current p
|
|
|
228
228
|
|
|
229
229
|
---
|
|
230
230
|
|
|
231
|
+
## Configuration
|
|
232
|
+
|
|
233
|
+
### `SENSITIVE_CANARY_CATEGORIES`
|
|
234
|
+
|
|
235
|
+
Limit which rule categories are active. Set it in the `env` block of your Claude Code `settings.json`:
|
|
236
|
+
|
|
237
|
+
```json
|
|
238
|
+
{
|
|
239
|
+
"env": {
|
|
240
|
+
"SENSITIVE_CANARY_CATEGORIES": "secret"
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
| Value | Effect |
|
|
246
|
+
|---|---|
|
|
247
|
+
| `secret` | Scan for secrets only — PII rules are disabled |
|
|
248
|
+
| `pii` | Scan for PII only — secret rules and the name-based `.env`/`.env.*` block are disabled |
|
|
249
|
+
| `secret,pii` / `all` | Scan everything (default) |
|
|
250
|
+
|
|
251
|
+
Values are comma-separated and case-insensitive. Unset, empty, or containing no valid token means all categories are enabled.
|
|
252
|
+
|
|
253
|
+
This is a persistent filter, unlike allow tags which apply per prompt. The category filter is applied first, then allow tags. A typical use is setting `secret` when PII rules (credit card numbers, phone numbers, …) are too noisy against test fixtures.
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
231
257
|
## Detection rules
|
|
232
258
|
|
|
233
259
|
### Secrets (24 rules)
|
|
@@ -302,7 +328,7 @@ Claude calls Read / Bash tool
|
|
|
302
328
|
PreToolUse hook
|
|
303
329
|
↓
|
|
304
330
|
── Read tool ─────────────────────────────────────────────────────
|
|
305
|
-
│ 1. filename is .env / .env.* → blocked
|
|
331
|
+
│ 1. filename is .env / .env.* → blocked (secret category only)
|
|
306
332
|
│ 2. file contents contain secret / PII → blocked
|
|
307
333
|
└─ Bash tool ─────────────────────────────────────────────────────
|
|
308
334
|
1. env var values referenced in the command contain secret / PII → blocked
|
|
@@ -317,7 +343,7 @@ The terminal also receives a direct message (via `/dev/tty`).
|
|
|
317
343
|
|
|
318
344
|
## Allow Tags (detailed)
|
|
319
345
|
|
|
320
|
-
Allow tags filter the scan results — the scan
|
|
346
|
+
Allow tags filter the scan results — the scan still runs. The `.env`/`.env.*` name block is the only exception: when an allow tag is present, the file is passed through immediately without scanning.
|
|
321
347
|
|
|
322
348
|
### Mask tags
|
|
323
349
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coo-quack/sensitive-canary",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
"node": ">=22.6.0"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@biomejs/biome": "
|
|
24
|
-
"@types/node": "
|
|
25
|
-
"typescript": "
|
|
26
|
-
"vitepress": "
|
|
27
|
-
"vitest": "
|
|
23
|
+
"@biomejs/biome": "2.5.6",
|
|
24
|
+
"@types/node": "25.9.5",
|
|
25
|
+
"typescript": "7.0.2",
|
|
26
|
+
"vitepress": "2.0.0-alpha.18",
|
|
27
|
+
"vitest": "4.1.10"
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
30
|
"test": "vitest run",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
@@ -58,7 +58,7 @@ function writeTranscriptWithToolResults(
|
|
|
58
58
|
function runHook(
|
|
59
59
|
toolName: string,
|
|
60
60
|
filePath: string,
|
|
61
|
-
opts?: { transcriptPath?: string },
|
|
61
|
+
opts?: { env?: Record<string, string>; transcriptPath?: string },
|
|
62
62
|
) {
|
|
63
63
|
const input = JSON.stringify({
|
|
64
64
|
transcript_path: opts?.transcriptPath,
|
|
@@ -68,6 +68,7 @@ function runHook(
|
|
|
68
68
|
const result = spawnSync("node", [...NODE_FLAGS, HOOK], {
|
|
69
69
|
input,
|
|
70
70
|
encoding: "utf8",
|
|
71
|
+
env: { ...process.env, ...opts?.env },
|
|
71
72
|
});
|
|
72
73
|
const { decision, reason } = parseHookOutput(result.stdout);
|
|
73
74
|
return {
|
|
@@ -135,9 +136,9 @@ describe("pre-tool-use-hook — non-Read/non-Bash tools", () => {
|
|
|
135
136
|
});
|
|
136
137
|
});
|
|
137
138
|
|
|
138
|
-
// ── .env / .env.* —
|
|
139
|
+
// ── .env / .env.* — secret name block ─────────────────────────────────────────
|
|
139
140
|
|
|
140
|
-
describe("pre-tool-use-hook — .env/.env.*
|
|
141
|
+
describe("pre-tool-use-hook — .env/.env.* name block (secret category)", () => {
|
|
141
142
|
it("blocks .env regardless of content", () => {
|
|
142
143
|
const p = writeFixture(".env", "DEBUG=true\nNODE_ENV=development\n");
|
|
143
144
|
const { exitCode, decision } = runHook("Read", p);
|
|
@@ -386,23 +387,18 @@ describe("pre-tool-use-hook — Bash tool (command string)", () => {
|
|
|
386
387
|
// ── Bash tool — file-reading command blocking ─────────────────────────────────
|
|
387
388
|
|
|
388
389
|
describe("pre-tool-use-hook — Bash tool (file-reading commands)", () => {
|
|
389
|
-
it.each([
|
|
390
|
-
"
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
);
|
|
402
|
-
const { exitCode, decision } = runBashHook(`${cmd} ${p}`);
|
|
403
|
-
expect(exitCode).toBe(2);
|
|
404
|
-
expect(decision).toBe("block");
|
|
405
|
-
});
|
|
390
|
+
it.each(["cat", "head", "tail", "less", "more", "bat", "nl"])(
|
|
391
|
+
"blocks %s on a file with secrets",
|
|
392
|
+
(cmd) => {
|
|
393
|
+
const p = writeFixture(
|
|
394
|
+
`creds-${cmd}.txt`,
|
|
395
|
+
"AWS_KEY=AKIAIOSFODNN7EXAMPLE\n",
|
|
396
|
+
);
|
|
397
|
+
const { exitCode, decision } = runBashHook(`${cmd} ${p}`);
|
|
398
|
+
expect(exitCode).toBe(2);
|
|
399
|
+
expect(decision).toBe("block");
|
|
400
|
+
},
|
|
401
|
+
);
|
|
406
402
|
|
|
407
403
|
it("blocks cat on a file with PII", () => {
|
|
408
404
|
const p = writeFixture("contacts-bash.txt", "Email: user@example.com\n");
|
|
@@ -711,3 +707,73 @@ describe("pre-tool-use-hook — malformed input", () => {
|
|
|
711
707
|
expect(result.status).toBe(0);
|
|
712
708
|
});
|
|
713
709
|
});
|
|
710
|
+
|
|
711
|
+
// ── SENSITIVE_CANARY_CATEGORIES ───────────────────────────────────────────────
|
|
712
|
+
|
|
713
|
+
describe("pre-tool-use-hook — SENSITIVE_CANARY_CATEGORIES", () => {
|
|
714
|
+
it("pii-only: allows reading .env files (secret guard disabled)", () => {
|
|
715
|
+
const dir = join(tmpDir, "pii-only-env");
|
|
716
|
+
mkdirSync(dir, { recursive: true });
|
|
717
|
+
const p = join(dir, ".env");
|
|
718
|
+
writeFileSync(p, "DEBUG=true\n", "utf8");
|
|
719
|
+
const { exitCode } = runHook("Read", p, {
|
|
720
|
+
env: { SENSITIVE_CANARY_CATEGORIES: "pii" },
|
|
721
|
+
});
|
|
722
|
+
expect(exitCode).toBe(0);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it("pii-only: allows a file containing only secrets", () => {
|
|
726
|
+
const p = writeFixture("pii-only-secret.txt", "key=AKIAIOSFODNN7EXAMPLE");
|
|
727
|
+
const { exitCode } = runHook("Read", p, {
|
|
728
|
+
env: { SENSITIVE_CANARY_CATEGORIES: "pii" },
|
|
729
|
+
});
|
|
730
|
+
expect(exitCode).toBe(0);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it("pii-only: still blocks a file containing PII", () => {
|
|
734
|
+
const p = writeFixture("pii-only-pii.txt", "card: 4111111111111111");
|
|
735
|
+
const { exitCode, decision } = runHook("Read", p, {
|
|
736
|
+
env: { SENSITIVE_CANARY_CATEGORIES: "pii" },
|
|
737
|
+
});
|
|
738
|
+
expect(exitCode).toBe(2);
|
|
739
|
+
expect(decision).toBe("block");
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
it("secret-only: allows a file containing only PII", () => {
|
|
743
|
+
const p = writeFixture("secret-only-pii.txt", "card: 4111111111111111");
|
|
744
|
+
const { exitCode } = runHook("Read", p, {
|
|
745
|
+
env: { SENSITIVE_CANARY_CATEGORIES: "secret" },
|
|
746
|
+
});
|
|
747
|
+
expect(exitCode).toBe(0);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it("secret-only: still blocks a file containing secrets", () => {
|
|
751
|
+
const p = writeFixture(
|
|
752
|
+
"secret-only-secret.txt",
|
|
753
|
+
"key=AKIAIOSFODNN7EXAMPLE",
|
|
754
|
+
);
|
|
755
|
+
const { exitCode, decision } = runHook("Read", p, {
|
|
756
|
+
env: { SENSITIVE_CANARY_CATEGORIES: "secret" },
|
|
757
|
+
});
|
|
758
|
+
expect(exitCode).toBe(2);
|
|
759
|
+
expect(decision).toBe("block");
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
it("secret-only: allows a bash command containing only PII", () => {
|
|
763
|
+
const { exitCode } = runBashHook("echo 4111111111111111", {
|
|
764
|
+
env: { SENSITIVE_CANARY_CATEGORIES: "secret" },
|
|
765
|
+
});
|
|
766
|
+
expect(exitCode).toBe(0);
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it("unset: blocks both secrets and PII (default behavior)", () => {
|
|
770
|
+
const p = writeFixture(
|
|
771
|
+
"default-both.txt",
|
|
772
|
+
"key=AKIAIOSFODNN7EXAMPLE\ncard: 4111111111111111",
|
|
773
|
+
);
|
|
774
|
+
const { exitCode, reason } = runHook("Read", p);
|
|
775
|
+
expect(exitCode).toBe(2);
|
|
776
|
+
expect(reason).toContain("aws-access-key");
|
|
777
|
+
expect(reason).toContain("pii-credit-card");
|
|
778
|
+
});
|
|
779
|
+
});
|
|
@@ -4,10 +4,11 @@ import { describe, expect, it } from "vitest";
|
|
|
4
4
|
const HOOK = new URL("../user-prompt-submit-hook.ts", import.meta.url).pathname;
|
|
5
5
|
const NODE_FLAGS = ["--experimental-strip-types"];
|
|
6
6
|
|
|
7
|
-
function runHook(prompt: string) {
|
|
7
|
+
function runHook(prompt: string, opts?: { env?: Record<string, string> }) {
|
|
8
8
|
const result = spawnSync("node", [...NODE_FLAGS, HOOK], {
|
|
9
9
|
input: JSON.stringify({ prompt }),
|
|
10
10
|
encoding: "utf8",
|
|
11
|
+
env: { ...process.env, ...opts?.env },
|
|
11
12
|
});
|
|
12
13
|
return {
|
|
13
14
|
exitCode: result.status ?? -1,
|
|
@@ -253,3 +254,44 @@ describe("user-prompt-submit-hook — malformed input", () => {
|
|
|
253
254
|
expect(result.status).toBe(0);
|
|
254
255
|
});
|
|
255
256
|
});
|
|
257
|
+
|
|
258
|
+
describe("user-prompt-submit-hook — SENSITIVE_CANARY_CATEGORIES", () => {
|
|
259
|
+
it("pii-only: passes a prompt containing only secrets", () => {
|
|
260
|
+
const { exitCode } = runHook("my key is AKIAIOSFODNN7EXAMPLE", {
|
|
261
|
+
env: { SENSITIVE_CANARY_CATEGORIES: "pii" },
|
|
262
|
+
});
|
|
263
|
+
expect(exitCode).toBe(0);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("pii-only: still blocks a prompt containing PII", () => {
|
|
267
|
+
const { exitCode, stderr } = runHook("my card is 4111111111111111", {
|
|
268
|
+
env: { SENSITIVE_CANARY_CATEGORIES: "pii" },
|
|
269
|
+
});
|
|
270
|
+
expect(exitCode).toBe(2);
|
|
271
|
+
expect(stderr).toContain("sensitive data detected");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("secret-only: passes a prompt containing only PII", () => {
|
|
275
|
+
const { exitCode } = runHook("my card is 4111111111111111", {
|
|
276
|
+
env: { SENSITIVE_CANARY_CATEGORIES: "secret" },
|
|
277
|
+
});
|
|
278
|
+
expect(exitCode).toBe(0);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("secret-only: still blocks a prompt containing secrets", () => {
|
|
282
|
+
const { exitCode, stderr } = runHook("my key is AKIAIOSFODNN7EXAMPLE", {
|
|
283
|
+
env: { SENSITIVE_CANARY_CATEGORIES: "secret" },
|
|
284
|
+
});
|
|
285
|
+
expect(exitCode).toBe(2);
|
|
286
|
+
expect(stderr).toContain("sensitive data detected");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("unset: blocks both secrets and PII (default behavior)", () => {
|
|
290
|
+
const { exitCode, stderr } = runHook(
|
|
291
|
+
"key AKIAIOSFODNN7EXAMPLE card 4111111111111111",
|
|
292
|
+
);
|
|
293
|
+
expect(exitCode).toBe(2);
|
|
294
|
+
expect(stderr).toContain("aws-access-key");
|
|
295
|
+
expect(stderr).toContain("pii-credit-card");
|
|
296
|
+
});
|
|
297
|
+
});
|
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
enabledCategoriesFromEnv,
|
|
4
|
+
entropy,
|
|
5
|
+
luhn,
|
|
6
|
+
parseCategories,
|
|
7
|
+
redact,
|
|
8
|
+
scan,
|
|
9
|
+
} from "../rules.ts";
|
|
3
10
|
|
|
4
11
|
// ── luhn ──────────────────────────────────────────────────────────────────────
|
|
5
12
|
|
|
@@ -350,3 +357,92 @@ describe("scan — PII", () => {
|
|
|
350
357
|
expect(findings.some((f) => f.ruleId === "pii-ipv4")).toBe(false);
|
|
351
358
|
});
|
|
352
359
|
});
|
|
360
|
+
|
|
361
|
+
// ── parseCategories ───────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
describe("parseCategories", () => {
|
|
364
|
+
it("defaults to all categories when unset", () => {
|
|
365
|
+
expect(parseCategories(undefined)).toEqual(new Set(["secret", "pii"]));
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("defaults to all categories when empty", () => {
|
|
369
|
+
expect(parseCategories("")).toEqual(new Set(["secret", "pii"]));
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("parses a single category", () => {
|
|
373
|
+
expect(parseCategories("secret")).toEqual(new Set(["secret"]));
|
|
374
|
+
expect(parseCategories("pii")).toEqual(new Set(["pii"]));
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("parses a comma-separated list", () => {
|
|
378
|
+
expect(parseCategories("secret,pii")).toEqual(new Set(["secret", "pii"]));
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("treats 'all' as every category", () => {
|
|
382
|
+
expect(parseCategories("all")).toEqual(new Set(["secret", "pii"]));
|
|
383
|
+
expect(parseCategories("secret,all")).toEqual(new Set(["secret", "pii"]));
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("is case-insensitive and trims whitespace", () => {
|
|
387
|
+
expect(parseCategories(" Secret , PII ")).toEqual(
|
|
388
|
+
new Set(["secret", "pii"]),
|
|
389
|
+
);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("falls back to all when no valid token is present", () => {
|
|
393
|
+
expect(parseCategories("foo,bar")).toEqual(new Set(["secret", "pii"]));
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// ── scan: category filter ─────────────────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
describe("scan — category filter", () => {
|
|
400
|
+
const text = "key=AKIAIOSFODNN7EXAMPLE card: 4111111111111111";
|
|
401
|
+
|
|
402
|
+
it("scans all categories by default", () => {
|
|
403
|
+
const findings = scan(text);
|
|
404
|
+
expect(findings.some((f) => f.ruleId === "aws-access-key")).toBe(true);
|
|
405
|
+
expect(findings.some((f) => f.ruleId === "pii-credit-card")).toBe(true);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("scans only secrets when limited to the secret category", () => {
|
|
409
|
+
const findings = scan(text, new Set(["secret"]));
|
|
410
|
+
expect(findings.some((f) => f.ruleId === "aws-access-key")).toBe(true);
|
|
411
|
+
expect(findings.some((f) => f.ruleId === "pii-credit-card")).toBe(false);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("scans only PII when limited to the pii category", () => {
|
|
415
|
+
const findings = scan(text, new Set(["pii"]));
|
|
416
|
+
expect(findings.some((f) => f.ruleId === "aws-access-key")).toBe(false);
|
|
417
|
+
expect(findings.some((f) => f.ruleId === "pii-credit-card")).toBe(true);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("returns nothing when no categories are enabled", () => {
|
|
421
|
+
expect(scan(text, new Set())).toEqual([]);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// ── enabledCategoriesFromEnv ──────────────────────────────────────────────────
|
|
426
|
+
|
|
427
|
+
describe("enabledCategoriesFromEnv", () => {
|
|
428
|
+
const ENV_KEY = "SENSITIVE_CANARY_CATEGORIES";
|
|
429
|
+
const original = process.env[ENV_KEY];
|
|
430
|
+
|
|
431
|
+
afterEach(() => {
|
|
432
|
+
if (original === undefined) {
|
|
433
|
+
delete process.env[ENV_KEY];
|
|
434
|
+
} else {
|
|
435
|
+
process.env[ENV_KEY] = original;
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("returns all categories when the env var is unset", () => {
|
|
440
|
+
delete process.env[ENV_KEY];
|
|
441
|
+
expect(enabledCategoriesFromEnv()).toEqual(new Set(["secret", "pii"]));
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("returns the parsed categories when the env var is set", () => {
|
|
445
|
+
process.env[ENV_KEY] = "pii";
|
|
446
|
+
expect(enabledCategoriesFromEnv()).toEqual(new Set(["pii"]));
|
|
447
|
+
});
|
|
448
|
+
});
|
package/src/lib/rules.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
export type Category = "secret" | "pii";
|
|
2
|
+
|
|
1
3
|
export interface Finding {
|
|
2
4
|
ruleId: string;
|
|
3
5
|
description: string;
|
|
4
|
-
category:
|
|
6
|
+
category: Category;
|
|
5
7
|
matchRedacted: string;
|
|
6
8
|
secretValue: string;
|
|
7
9
|
}
|
|
@@ -13,7 +15,30 @@ interface Rule {
|
|
|
13
15
|
secretGroup?: number;
|
|
14
16
|
entropyThreshold?: number;
|
|
15
17
|
validate?: (str: string) => boolean;
|
|
16
|
-
category:
|
|
18
|
+
category: Category;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ALL_CATEGORIES: ReadonlySet<Category> = new Set(["secret", "pii"]);
|
|
22
|
+
|
|
23
|
+
// Parse the SENSITIVE_CANARY_CATEGORIES env var: a comma-separated list of
|
|
24
|
+
// "secret", "pii", or "all" (e.g. "secret" or "secret,pii"). Unset, empty, or
|
|
25
|
+
// containing no valid token means all categories are enabled.
|
|
26
|
+
export function parseCategories(value: string | undefined): Set<Category> {
|
|
27
|
+
const categories = new Set<Category>();
|
|
28
|
+
for (const token of (value ?? "").split(",")) {
|
|
29
|
+
const normalized = token.trim().toLowerCase();
|
|
30
|
+
if (normalized === "all") return new Set(ALL_CATEGORIES);
|
|
31
|
+
if (normalized === "secret" || normalized === "pii")
|
|
32
|
+
categories.add(normalized);
|
|
33
|
+
}
|
|
34
|
+
return categories.size > 0 ? categories : new Set(ALL_CATEGORIES);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Rule categories enabled for this process, from SENSITIVE_CANARY_CATEGORIES
|
|
38
|
+
// ("secret", "pii", "secret,pii", or "all"; default: all).
|
|
39
|
+
export function enabledCategoriesFromEnv(): Set<Category> {
|
|
40
|
+
const { SENSITIVE_CANARY_CATEGORIES } = process.env;
|
|
41
|
+
return parseCategories(SENSITIVE_CANARY_CATEGORIES);
|
|
17
42
|
}
|
|
18
43
|
|
|
19
44
|
// Luhn algorithm checksum validation. Returns true if the number (digits only) passes.
|
|
@@ -292,10 +317,14 @@ export function redact(str: string): string {
|
|
|
292
317
|
return `${str.slice(0, 4)}****${str.slice(-4)}`;
|
|
293
318
|
}
|
|
294
319
|
|
|
295
|
-
export function scan(
|
|
320
|
+
export function scan(
|
|
321
|
+
text: string,
|
|
322
|
+
categories: ReadonlySet<Category> = ALL_CATEGORIES,
|
|
323
|
+
): Finding[] {
|
|
296
324
|
const findings: Finding[] = [];
|
|
297
325
|
|
|
298
326
|
for (const rule of RULES) {
|
|
327
|
+
if (!categories.has(rule.category)) continue;
|
|
299
328
|
for (const match of text.matchAll(rule.regex)) {
|
|
300
329
|
const secretValue =
|
|
301
330
|
rule.secretGroup != null ? match[rule.secretGroup] : match[0];
|
package/src/pre-tool-use-hook.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
parseAllowTags,
|
|
11
11
|
randomBird,
|
|
12
12
|
} from "./lib/inspector.ts";
|
|
13
|
-
import { type Finding, scan } from "./lib/rules.ts";
|
|
13
|
+
import { enabledCategoriesFromEnv, type Finding, scan } from "./lib/rules.ts";
|
|
14
14
|
|
|
15
15
|
interface HookInput {
|
|
16
16
|
transcript_path?: string;
|
|
@@ -30,6 +30,8 @@ interface TranscriptLine {
|
|
|
30
30
|
// Maximum bytes to read from the tail of a transcript file.
|
|
31
31
|
const MAX_TRANSCRIPT_TAIL_BYTES = 65_536; // 64 KB
|
|
32
32
|
|
|
33
|
+
const ENABLED_CATEGORIES = enabledCategoriesFromEnv();
|
|
34
|
+
|
|
33
35
|
// ── Transcript ────────────────────────────────────────────────────────────────
|
|
34
36
|
|
|
35
37
|
// Returns true when the message contains at least one text content block
|
|
@@ -150,7 +152,8 @@ function extractFilePathsFromCommand(command: string): string[] {
|
|
|
150
152
|
|
|
151
153
|
// ── .env pattern ──────────────────────────────────────────────────────────────
|
|
152
154
|
|
|
153
|
-
// .env and .env.* (e.g. .env.local, .env.production)
|
|
155
|
+
// .env and .env.* (e.g. .env.local, .env.production) match the env filename pattern.
|
|
156
|
+
// The block only applies while the "secret" category is enabled (see shouldBlockEnvFile).
|
|
154
157
|
// Files that merely end in .env (e.g. production.env) are handled by content scanning.
|
|
155
158
|
function isBlockedEnvFile(filePath: string): boolean {
|
|
156
159
|
if (!filePath) return false;
|
|
@@ -158,6 +161,12 @@ function isBlockedEnvFile(filePath: string): boolean {
|
|
|
158
161
|
return base === ".env" || base.startsWith(".env.");
|
|
159
162
|
}
|
|
160
163
|
|
|
164
|
+
// The .env name-based block is a secret guard: it only applies while the
|
|
165
|
+
// "secret" category is enabled.
|
|
166
|
+
function shouldBlockEnvFile(filePath: string): boolean {
|
|
167
|
+
return ENABLED_CATEGORIES.has("secret") && isBlockedEnvFile(filePath);
|
|
168
|
+
}
|
|
169
|
+
|
|
161
170
|
// ── Output helpers ────────────────────────────────────────────────────────────
|
|
162
171
|
|
|
163
172
|
// Build the allow-tag hint lines shown to Claude.
|
|
@@ -239,7 +248,7 @@ function block(
|
|
|
239
248
|
// ── Core scan logic ───────────────────────────────────────────────────────────
|
|
240
249
|
|
|
241
250
|
function scanFile(filePath: string, allowTags: Set<string>): void {
|
|
242
|
-
if (
|
|
251
|
+
if (shouldBlockEnvFile(filePath)) {
|
|
243
252
|
if (allowTags.size > 0) return;
|
|
244
253
|
block(
|
|
245
254
|
filePath,
|
|
@@ -263,7 +272,10 @@ function scanFile(filePath: string, allowTags: Set<string>): void {
|
|
|
263
272
|
return;
|
|
264
273
|
}
|
|
265
274
|
|
|
266
|
-
const findings = applyAllowTags(
|
|
275
|
+
const findings = applyAllowTags(
|
|
276
|
+
dedupeFindings(scan(content, ENABLED_CATEGORIES)),
|
|
277
|
+
allowTags,
|
|
278
|
+
);
|
|
267
279
|
if (findings.length === 0) return;
|
|
268
280
|
|
|
269
281
|
block(
|
|
@@ -308,7 +320,10 @@ process.stdin.on("end", () => {
|
|
|
308
320
|
for (const varName of extractEnvVarNames(command)) {
|
|
309
321
|
const value = process.env[varName];
|
|
310
322
|
if (!value) continue;
|
|
311
|
-
const findings = applyAllowTags(
|
|
323
|
+
const findings = applyAllowTags(
|
|
324
|
+
dedupeFindings(scan(value, ENABLED_CATEGORIES)),
|
|
325
|
+
allowTags,
|
|
326
|
+
);
|
|
312
327
|
if (findings.length === 0) continue;
|
|
313
328
|
block(
|
|
314
329
|
`bash command: ${command.slice(0, 80)}`,
|
|
@@ -322,7 +337,7 @@ process.stdin.on("end", () => {
|
|
|
322
337
|
}
|
|
323
338
|
|
|
324
339
|
const cmdFindings = applyAllowTags(
|
|
325
|
-
dedupeFindings(scan(command)),
|
|
340
|
+
dedupeFindings(scan(command, ENABLED_CATEGORIES)),
|
|
326
341
|
allowTags,
|
|
327
342
|
);
|
|
328
343
|
if (cmdFindings.length > 0) {
|
|
@@ -8,12 +8,14 @@ import {
|
|
|
8
8
|
randomBird,
|
|
9
9
|
resolveTagPriority,
|
|
10
10
|
} from "./lib/inspector.ts";
|
|
11
|
-
import { scan } from "./lib/rules.ts";
|
|
11
|
+
import { enabledCategoriesFromEnv, scan } from "./lib/rules.ts";
|
|
12
12
|
|
|
13
13
|
interface HookInput {
|
|
14
14
|
prompt?: string;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
const ENABLED_CATEGORIES = enabledCategoriesFromEnv();
|
|
18
|
+
|
|
17
19
|
let raw = "";
|
|
18
20
|
process.stdin.setEncoding("utf8");
|
|
19
21
|
process.stdin.on("data", (chunk: string) => (raw += chunk));
|
|
@@ -27,7 +29,7 @@ process.stdin.on("end", () => {
|
|
|
27
29
|
|
|
28
30
|
const prompt = data.prompt ?? "";
|
|
29
31
|
|
|
30
|
-
const allFindings = scan(prompt);
|
|
32
|
+
const allFindings = scan(prompt, ENABLED_CATEGORIES);
|
|
31
33
|
|
|
32
34
|
if (allFindings.length === 0) process.exit(0);
|
|
33
35
|
|