@coo-quack/sensitive-canary 0.5.3 → 0.7.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 +61 -8
- package/README.md +163 -8
- 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__/inspector.test.ts +8 -0
- package/src/lib/__tests__/rules.test.ts +1020 -2
- package/src/lib/default-config.json +461 -0
- package/src/lib/rules.ts +650 -243
- 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,66 @@
|
|
|
1
|
-
|
|
1
|
+
# Changelog
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## v0.7.0 (2026-08-04)
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
-
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- Add multi-region PII detection rules (25 PII rules, up from 7)
|
|
8
|
+
- National IDs with checksum validation: Japanese My Number, French NIR, Italian Codice Fiscale, German Steuer-IdNr., Spanish DNI/NIE, Korean RRN and BRN, Chinese Resident Identity Card
|
|
9
|
+
- Phone numbers for JP, US, FR, IT, DE, ES, KR, CN
|
|
10
|
+
- Postal codes for JP, US/EU/KR (5/9-digit), and CN (6-digit)
|
|
11
|
+
- Public IPv4 and IPv6 addresses (reserved ranges excluded)
|
|
12
|
+
- Add context gating for noisy rules
|
|
13
|
+
- Rules with `requireContext` only fire when a nearby context word (phone, ZIP, IP, etc.) is found within a small window around the match (default: 3 tokens ≈ 24 characters)
|
|
14
|
+
- Reduces false positives on bare digit sequences without sacrificing detection when labels are present
|
|
15
|
+
- Move all rule definitions to JSON (`src/lib/default-config.json`)
|
|
16
|
+
- Rules are now data, not code — the full set can be inspected and modified without editing TypeScript
|
|
17
|
+
- Checksum validators remain in code and are referenced by name from the config
|
|
18
|
+
- Add user-defined custom rules via config file
|
|
19
|
+
- Create `~/.config/sensitive-canary/config.json` or set `SENSITIVE_CANARY_CONFIG` to a custom path
|
|
20
|
+
- Add new rules, override built-in rules by id, and set a custom `contextWindow`
|
|
21
|
+
- Invalid rules are skipped with a warning; the rest of the config loads normally
|
|
22
|
+
- Expand secret detection coverage (39 secret rules, up from 24)
|
|
23
|
+
- AI services: Replicate, Hugging Face, Groq, OpenRouter, xAI (Grok), Perplexity
|
|
24
|
+
- Cloud / IaaS: DigitalOcean PAT, Supabase PAT
|
|
25
|
+
- Payment: Square access token
|
|
26
|
+
- SaaS / Dev tools: Mapbox, Sentry (user + org tokens), Atlassian, Linear, Postman
|
|
27
|
+
|
|
28
|
+
### Fixes
|
|
29
|
+
|
|
30
|
+
- Fix My Number checksum: when the weighted-sum remainder is 0 or 1, the check digit is 0 (not invalid). Valid My Numbers ending in 0 were previously rejected.
|
|
31
|
+
- Correct spec source abbreviation: JIPTEC → J-LIS (地方公共団体情報システム機構)
|
|
32
|
+
- Harden `compileRule`: force `g` flag on regex, validate `regex` field, warn on unknown validator name
|
|
33
|
+
- Add strict schema validation for user-defined rules (required fields, optional field types, cross-field constraints)
|
|
34
|
+
- Pass `secretValue` (not full match) to validator so `secretGroup` + `validate` works in user rules
|
|
35
|
+
|
|
36
|
+
### Dependencies
|
|
37
|
+
|
|
38
|
+
- Update pnpm to v11.19.0 and refresh the lockfile
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## v0.6.0 (2026-08-02)
|
|
43
|
+
|
|
44
|
+
### Features
|
|
45
|
+
|
|
46
|
+
- Add `SENSITIVE_CANARY_CATEGORIES` environment variable to limit which rule categories are active
|
|
47
|
+
- Accepts `secret`, `pii`, `secret,pii`, or `all` (comma-separated, case-insensitive); unset/empty/invalid means all categories
|
|
48
|
+
- Useful for reducing PII false positives (e.g. credit card or phone number rules firing on test fixtures) by scanning secrets only
|
|
49
|
+
- The name-based `.env`/`.env.*` block is a secret guard and is disabled when the `secret` category is not enabled
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## v0.5.3 (2026-06-27)
|
|
54
|
+
|
|
55
|
+
### CI
|
|
56
|
+
|
|
57
|
+
- Rework the main→develop sync to open a PR with auto-merge, using a minted GitHub App token so the created PR triggers CI
|
|
58
|
+
- Disable persist-credentials in the sync workflow so the App token push works
|
|
59
|
+
|
|
60
|
+
### Dependencies
|
|
61
|
+
|
|
62
|
+
- Pin pnpm via the `packageManager` field and update pnpm to v11 (security)
|
|
63
|
+
- Update node to v24, vite to v8, typescript to v6, and other dev dependencies and GitHub Actions
|
|
9
64
|
|
|
10
65
|
---
|
|
11
66
|
|
|
@@ -20,8 +75,6 @@
|
|
|
20
75
|
|
|
21
76
|
---
|
|
22
77
|
|
|
23
|
-
# Changelog
|
|
24
|
-
|
|
25
78
|
## v0.5.1 (2026-03-15)
|
|
26
79
|
|
|
27
80
|
### Fixes
|
package/README.md
CHANGED
|
@@ -25,9 +25,10 @@ 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
|
+
- **64 detection rules** — sourced from gitleaks and TruffleHog detector definitions
|
|
29
|
+
- **Checksum validation** — credit cards (Luhn) and national ID numbers (JP My Number, FR NIR, IT Codice Fiscale, DE Steuer-IdNr., ES DNI/NIE, KR RRN/BRN, CN Resident ID)
|
|
30
|
+
- **Context gating** — phone numbers, postal codes, and public IP addresses require a nearby label, reducing false positives on bare digit sequences
|
|
29
31
|
- **Entropy filtering** — reduces false positives on low-entropy values
|
|
30
|
-
- **Luhn validation** — credit card numbers are validated, not just pattern-matched
|
|
31
32
|
- **Local only** — all scanning runs in your terminal; nothing is sent anywhere
|
|
32
33
|
|
|
33
34
|
---
|
|
@@ -183,7 +184,7 @@ To allow it through, add the suggested tag:
|
|
|
183
184
|
|
|
184
185
|
### .env file blocked
|
|
185
186
|
|
|
186
|
-
`.env` / `.env.*` files are blocked
|
|
187
|
+
`.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
188
|
|
|
188
189
|
```
|
|
189
190
|
> Read .env
|
|
@@ -228,9 +229,124 @@ To intentionally bypass a block, include the appropriate tag in your **current p
|
|
|
228
229
|
|
|
229
230
|
---
|
|
230
231
|
|
|
232
|
+
## Configuration
|
|
233
|
+
|
|
234
|
+
### `SENSITIVE_CANARY_CATEGORIES`
|
|
235
|
+
|
|
236
|
+
Limit which rule categories are active. Set it in the `env` block of your Claude Code `settings.json`:
|
|
237
|
+
|
|
238
|
+
```json
|
|
239
|
+
{
|
|
240
|
+
"env": {
|
|
241
|
+
"SENSITIVE_CANARY_CATEGORIES": "secret"
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
| Value | Effect |
|
|
247
|
+
|---|---|
|
|
248
|
+
| `secret` | Scan for secrets only — PII rules are disabled |
|
|
249
|
+
| `pii` | Scan for PII only — secret rules and the name-based `.env`/`.env.*` block are disabled |
|
|
250
|
+
| `secret,pii` / `all` | Scan everything (default) |
|
|
251
|
+
|
|
252
|
+
Values are comma-separated and case-insensitive. Unset, empty, or containing no valid token means all categories are enabled.
|
|
253
|
+
|
|
254
|
+
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.
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## Custom Rules
|
|
259
|
+
|
|
260
|
+
All detection rules are defined in `src/lib/default-config.json` as data, not code. You can add your own rules or override built-in ones by creating a config file.
|
|
261
|
+
|
|
262
|
+
### Config file location
|
|
263
|
+
|
|
264
|
+
Create `~/.config/sensitive-canary/config.json`, or point to a custom path with the `SENSITIVE_CANARY_CONFIG` environment variable. Set it in the `env` block of your Claude Code `settings.json`:
|
|
265
|
+
|
|
266
|
+
```json
|
|
267
|
+
{
|
|
268
|
+
"env": {
|
|
269
|
+
"SENSITIVE_CANARY_CONFIG": "/path/to/my-rules.json"
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
or export it in your shell:
|
|
275
|
+
|
|
276
|
+
```sh
|
|
277
|
+
export SENSITIVE_CANARY_CONFIG=/path/to/my-rules.json
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### Adding a rule
|
|
281
|
+
|
|
282
|
+
Each rule is a JSON object with an `id`, `description`, `regex` (source string), and `category` (`"secret"` or `"pii"`):
|
|
283
|
+
|
|
284
|
+
```json
|
|
285
|
+
{
|
|
286
|
+
"rules": [
|
|
287
|
+
{
|
|
288
|
+
"id": "custom-api-key",
|
|
289
|
+
"description": "My Service API Key",
|
|
290
|
+
"regex": "MYSVC-[A-Za-z0-9]{32}",
|
|
291
|
+
"category": "secret"
|
|
292
|
+
}
|
|
293
|
+
]
|
|
294
|
+
}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### Overriding a built-in rule
|
|
298
|
+
|
|
299
|
+
A user rule with the same `id` as a built-in rule replaces it. For example, to tighten the email regex:
|
|
300
|
+
|
|
301
|
+
```json
|
|
302
|
+
{
|
|
303
|
+
"rules": [
|
|
304
|
+
{
|
|
305
|
+
"id": "pii-email",
|
|
306
|
+
"description": "Internal Email",
|
|
307
|
+
"regex": "[A-Za-z0-9]+@internal\\.corp\\.(com|org)",
|
|
308
|
+
"category": "pii"
|
|
309
|
+
}
|
|
310
|
+
]
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### Context gating and validators
|
|
315
|
+
|
|
316
|
+
User rules support the same fields as built-in rules:
|
|
317
|
+
|
|
318
|
+
| Field | Type | Description |
|
|
319
|
+
|---|---|---|
|
|
320
|
+
| `requireContext` | boolean | Only fire when a nearby context word is found |
|
|
321
|
+
| `contextWords` | string[] | Words that satisfy the context requirement |
|
|
322
|
+
| `contextWindow` | number | Override the global context window (default: 3 tokens) |
|
|
323
|
+
| `entropyThreshold` | number | Skip matches below this Shannon entropy |
|
|
324
|
+
| `secretGroup` | number | Capture group index containing the secret (default: 0 = full match) |
|
|
325
|
+
| `validate` | string | Name of a built-in checksum validator (see below) |
|
|
326
|
+
| `flags` | string | Regex flags (default: `"g"`) |
|
|
327
|
+
|
|
328
|
+
Available validators (referenced by name in the `validate` field):
|
|
329
|
+
|
|
330
|
+
`luhn`, `mynumber-jp`, `nir-fr`, `codice-fiscale-it`, `steuer-id-de`, `dni-nie-es`, `rrn-kr`, `brn-kr`, `resident-id-cn`, `public-ipv4`, `public-ipv6`
|
|
331
|
+
|
|
332
|
+
### Overriding the context window globally
|
|
333
|
+
|
|
334
|
+
Set `contextWindow` at the top level to change how many tokens of surrounding text are scanned for context words (default: 3):
|
|
335
|
+
|
|
336
|
+
```json
|
|
337
|
+
{
|
|
338
|
+
"contextWindow": 5,
|
|
339
|
+
"rules": []
|
|
340
|
+
}
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Invalid rules (bad regex, wrong types, missing required fields) are skipped with a warning on stderr. The rest of the config still loads. Each rule is validated against a strict schema before compilation — `requireContext: true` without `contextWords` is also rejected, since empty `contextWords` would silently disable context gating and make the rule fire on every match.
|
|
344
|
+
|
|
345
|
+
---
|
|
346
|
+
|
|
231
347
|
## Detection rules
|
|
232
348
|
|
|
233
|
-
### Secrets (
|
|
349
|
+
### Secrets (39 rules)
|
|
234
350
|
|
|
235
351
|
| Rule ID | Description |
|
|
236
352
|
|---|---|
|
|
@@ -254,25 +370,64 @@ To intentionally bypass a block, include the appropriate tag in your **current p
|
|
|
254
370
|
| `openai-key` | OpenAI API Key (legacy format) |
|
|
255
371
|
| `openai-project-key` | OpenAI Project API Key (`sk-proj-` prefix) *(entropy ≥ 3.5)* |
|
|
256
372
|
| `anthropic-key` | Anthropic API Key |
|
|
373
|
+
| `replicate-token` | Replicate API Token |
|
|
374
|
+
| `huggingface-token` | Hugging Face Access Token |
|
|
375
|
+
| `groq-key` | Groq API Key |
|
|
376
|
+
| `openrouter-key` | OpenRouter API Key |
|
|
377
|
+
| `xai-key` | xAI (Grok) API Key |
|
|
378
|
+
| `perplexity-key` | Perplexity API Key |
|
|
379
|
+
| `digitalocean-pat` | DigitalOcean Personal Access Token |
|
|
380
|
+
| `square-access-token` | Square Access Token |
|
|
381
|
+
| `mapbox-token` | Mapbox Token |
|
|
382
|
+
| `sentry-user-token` | Sentry User Auth Token |
|
|
383
|
+
| `sentry-org-token` | Sentry Organization Auth Token |
|
|
384
|
+
| `atlassian-token` | Atlassian API Token |
|
|
385
|
+
| `linear-key` | Linear API Key |
|
|
386
|
+
| `postman-key` | Postman API Key |
|
|
387
|
+
| `supabase-key` | Supabase Personal Access Token |
|
|
257
388
|
| `jwt` | JSON Web Token (JWT) |
|
|
258
389
|
| `generic-secret` | Generic API key / secret assignment *(entropy ≥ 3.5)* |
|
|
259
390
|
| `env-assignment` | `.env`-style secret assignment *(entropy ≥ 3.0)* |
|
|
260
391
|
| `connection-string` | Database connection string with embedded credentials |
|
|
261
392
|
|
|
262
|
-
### PII (
|
|
393
|
+
### PII (25 rules)
|
|
263
394
|
|
|
264
395
|
| Rule ID | Description | Validation |
|
|
265
396
|
|---|---|---|
|
|
266
397
|
| `pii-email` | Email address | — |
|
|
267
398
|
| `pii-credit-card` | Credit card number | Luhn check |
|
|
399
|
+
| `pii-ipv4` | IPv4 address (RFC 1918 private ranges only) | — |
|
|
268
400
|
| `pii-ssn` | US Social Security Number | Invalid prefix exclusion |
|
|
401
|
+
| `pii-mynumber-jp` | Japanese Individual Number (My Number) | Checksum (weighted mod 11) |
|
|
402
|
+
| `pii-nir-fr` | French NIR / Social Security Number | Check key (mod 97) |
|
|
403
|
+
| `pii-codice-fiscale-it` | Italian Codice Fiscale | Control character (mod 26) |
|
|
404
|
+
| `pii-steuer-id-de` | German Steuer-Identifikationsnummer | MOD 11,10 |
|
|
405
|
+
| `pii-dni-nie-es` | Spanish DNI / NIE | Control letter (mod 23) |
|
|
269
406
|
| `pii-phone-us` | US phone number | — |
|
|
270
407
|
| `pii-phone-jp` | Japanese phone number | — |
|
|
408
|
+
| `pii-phone-fr` | French phone number | Context-gated |
|
|
409
|
+
| `pii-phone-it` | Italian phone number | Context-gated |
|
|
410
|
+
| `pii-phone-de` | German phone number | Context-gated |
|
|
411
|
+
| `pii-phone-es` | Spanish phone number | Context-gated |
|
|
271
412
|
| `pii-postal-jp` | Japanese postal code (`〒` prefix required) | — |
|
|
272
|
-
| `pii-
|
|
413
|
+
| `pii-postal-code` | Postal code (US ZIP / EU / KR) | Context-gated |
|
|
414
|
+
| `pii-rrn-kr` | Korean Resident Registration Number | Checksum (weighted mod 11) |
|
|
415
|
+
| `pii-brn-kr` | Korean Business Registration Number | Checksum (NTS standard algorithm) |
|
|
416
|
+
| `pii-resident-id-cn` | Chinese Resident Identity Card | Check digit (GB 11643 MOD 11-2) |
|
|
417
|
+
| `pii-phone-kr` | Korean phone number | Context-gated |
|
|
418
|
+
| `pii-phone-cn` | Chinese phone number | Context-gated |
|
|
419
|
+
| `pii-postal-cn` | Chinese postal code (6-digit) | Context-gated |
|
|
420
|
+
| `pii-ipv4-public` | Public IPv4 address | Context-gated, reserved ranges excluded |
|
|
421
|
+
| `pii-ipv6` | IPv6 address | Context-gated, reserved ranges excluded |
|
|
273
422
|
|
|
274
423
|
Detection patterns are based on rule definitions from [gitleaks](https://github.com/gitleaks/gitleaks) and [TruffleHog](https://github.com/trufflesecurity/trufflehog).
|
|
275
424
|
|
|
425
|
+
National ID checksum algorithms follow the official specs from each issuing authority: 地方公共団体情報システム機構 (J-LIS) for My Number, INSEE for NIR, Agenzia delle Entrate for Codice Fiscale, Bundeszentralamt für Steuern for Steuer-IdNr., the Ministerio del Interior for DNI/NIE, the Ministry of the Interior and Safety for the Korean RRN, GB 11643-1999 for the Chinese Resident Identity Card, and the NTS (Hometax) standard algorithm for the Korean BRN.
|
|
426
|
+
|
|
427
|
+
### Context gating
|
|
428
|
+
|
|
429
|
+
Phone numbers (IT, DE, FR, ES, KR, CN), bare 5/9-digit and Chinese 6-digit postal codes, and public IP addresses produce too many false positives on digit-only patterns. These rules carry a list of context words (phone, ZIP, PLZ, CAP, IP, etc. in the relevant languages) and only fire when one of those words appears near the match. National ID numbers rely on their checksums instead and do not need context. Japanese postal codes keep their `〒` prefix requirement, which is a stricter form of the same idea.
|
|
430
|
+
|
|
276
431
|
---
|
|
277
432
|
|
|
278
433
|
## How It Works
|
|
@@ -302,7 +457,7 @@ Claude calls Read / Bash tool
|
|
|
302
457
|
PreToolUse hook
|
|
303
458
|
↓
|
|
304
459
|
── Read tool ─────────────────────────────────────────────────────
|
|
305
|
-
│ 1. filename is .env / .env.* → blocked
|
|
460
|
+
│ 1. filename is .env / .env.* → blocked (secret category only)
|
|
306
461
|
│ 2. file contents contain secret / PII → blocked
|
|
307
462
|
└─ Bash tool ─────────────────────────────────────────────────────
|
|
308
463
|
1. env var values referenced in the command contain secret / PII → blocked
|
|
@@ -317,7 +472,7 @@ The terminal also receives a direct message (via `/dev/tty`).
|
|
|
317
472
|
|
|
318
473
|
## Allow Tags (detailed)
|
|
319
474
|
|
|
320
|
-
Allow tags filter the scan results — the scan
|
|
475
|
+
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
476
|
|
|
322
477
|
### Mask tags
|
|
323
478
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coo-quack/sensitive-canary",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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
|
+
});
|
|
@@ -162,6 +162,7 @@ describe("applyAllowTags", () => {
|
|
|
162
162
|
category: "secret",
|
|
163
163
|
matchRedacted: "AKIA****",
|
|
164
164
|
secretValue: "AKIATEST",
|
|
165
|
+
score: 1,
|
|
165
166
|
},
|
|
166
167
|
{
|
|
167
168
|
ruleId: "pii-email",
|
|
@@ -169,6 +170,7 @@ describe("applyAllowTags", () => {
|
|
|
169
170
|
category: "pii",
|
|
170
171
|
matchRedacted: "user****",
|
|
171
172
|
secretValue: "user@example.com",
|
|
173
|
+
score: 1,
|
|
172
174
|
},
|
|
173
175
|
];
|
|
174
176
|
|
|
@@ -213,6 +215,7 @@ describe("dedupeFindings", () => {
|
|
|
213
215
|
category: "secret",
|
|
214
216
|
matchRedacted: "AKIA****",
|
|
215
217
|
secretValue: "AKIATEST",
|
|
218
|
+
score: 1,
|
|
216
219
|
},
|
|
217
220
|
{
|
|
218
221
|
ruleId: "aws-access-key",
|
|
@@ -220,6 +223,7 @@ describe("dedupeFindings", () => {
|
|
|
220
223
|
category: "secret",
|
|
221
224
|
matchRedacted: "AKIA****",
|
|
222
225
|
secretValue: "AKIATEST",
|
|
226
|
+
score: 1,
|
|
223
227
|
},
|
|
224
228
|
];
|
|
225
229
|
expect(dedupeFindings(findings)).toHaveLength(1);
|
|
@@ -233,6 +237,7 @@ describe("dedupeFindings", () => {
|
|
|
233
237
|
category: "secret",
|
|
234
238
|
matchRedacted: "AKIA****",
|
|
235
239
|
secretValue: "AKIATEST1",
|
|
240
|
+
score: 1,
|
|
236
241
|
},
|
|
237
242
|
{
|
|
238
243
|
ruleId: "aws-access-key",
|
|
@@ -240,6 +245,7 @@ describe("dedupeFindings", () => {
|
|
|
240
245
|
category: "secret",
|
|
241
246
|
matchRedacted: "AKIA****",
|
|
242
247
|
secretValue: "AKIATEST2",
|
|
248
|
+
score: 1,
|
|
243
249
|
},
|
|
244
250
|
];
|
|
245
251
|
expect(dedupeFindings(findings)).toHaveLength(2);
|
|
@@ -257,6 +263,7 @@ describe("findingsToLines", () => {
|
|
|
257
263
|
category: "secret",
|
|
258
264
|
matchRedacted: "AKIA****MPLE",
|
|
259
265
|
secretValue: "AKIAIOSFODNN7EXAMPLE",
|
|
266
|
+
score: 1,
|
|
260
267
|
},
|
|
261
268
|
];
|
|
262
269
|
const lines = findingsToLines(findings);
|
|
@@ -273,6 +280,7 @@ describe("findingsToLines", () => {
|
|
|
273
280
|
category: "pii",
|
|
274
281
|
matchRedacted: "user****",
|
|
275
282
|
secretValue: "user@example.com",
|
|
283
|
+
score: 1,
|
|
276
284
|
},
|
|
277
285
|
];
|
|
278
286
|
const lines = findingsToLines(findings);
|