@coo-quack/sensitive-canary 0.7.0 → 0.8.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.
Files changed (37) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +791 -0
  3. package/README.md +142 -45
  4. package/dist/lib/bash-commands.js +405 -0
  5. package/dist/lib/command-tables.js +462 -0
  6. package/dist/lib/default-config.json +570 -0
  7. package/dist/lib/encoding.js +123 -0
  8. package/dist/lib/fail-closed.js +31 -0
  9. package/dist/lib/inspector.js +0 -0
  10. package/dist/lib/rules.js +399 -0
  11. package/dist/lib/shapes.js +161 -0
  12. package/dist/lib/shell.js +436 -0
  13. package/dist/lib/tool-inputs.js +217 -0
  14. package/dist/lib/transcript.js +115 -0
  15. package/dist/lib/validators.js +435 -0
  16. package/dist/pre-tool-use-hook.js +773 -0
  17. package/dist/user-prompt-submit-hook.js +105 -0
  18. package/hooks/hooks.json +1 -1
  19. package/package.json +25 -11
  20. package/src/lib/bash-commands.ts +455 -0
  21. package/src/lib/command-tables.ts +518 -0
  22. package/src/lib/default-config.json +155 -46
  23. package/src/lib/encoding.ts +135 -0
  24. package/src/lib/fail-closed.ts +36 -0
  25. package/src/lib/inspector.ts +0 -0
  26. package/src/lib/rules.ts +202 -365
  27. package/src/lib/shapes.ts +175 -0
  28. package/src/lib/shell.ts +512 -0
  29. package/src/lib/tool-inputs.ts +235 -0
  30. package/src/lib/transcript.ts +142 -0
  31. package/src/lib/validators.ts +435 -0
  32. package/src/pre-tool-use-hook.ts +774 -198
  33. package/src/user-prompt-submit-hook.ts +60 -18
  34. package/src/__tests__/pre-tool-use-hook.test.ts +0 -779
  35. package/src/__tests__/user-prompt-submit-hook.test.ts +0 -297
  36. package/src/lib/__tests__/inspector.test.ts +0 -289
  37. package/src/lib/__tests__/rules.test.ts +0 -1370
@@ -4,8 +4,9 @@
4
4
  {
5
5
  "id": "aws-access-key",
6
6
  "description": "AWS Access Key ID",
7
- "regex": "\\b(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}\\b",
8
- "category": "secret"
7
+ "regex": "\\b(A3T[A-Z0-9]|AKIA|ABIA|ACCA|APKA|ASCA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}\\b",
8
+ "category": "secret",
9
+ "validate": "aws-key"
9
10
  },
10
11
  {
11
12
  "id": "gcp-api-key",
@@ -16,7 +17,7 @@
16
17
  {
17
18
  "id": "private-key",
18
19
  "description": "PEM Private Key",
19
- "regex": "-----BEGIN (RSA |EC |DSA |PGP |OPENSSH )?PRIVATE KEY",
20
+ "regex": "-----BEGIN ((RSA|EC|DSA|PGP|OPENSSH|ENCRYPTED|SSH2 ENCRYPTED) )?PRIVATE KEY",
20
21
  "category": "secret"
21
22
  },
22
23
  {
@@ -34,7 +35,7 @@
34
35
  {
35
36
  "id": "gitlab-pat",
36
37
  "description": "GitLab Personal Access Token",
37
- "regex": "glpat-[A-Za-z0-9_=-]{20,22}",
38
+ "regex": "(?:gl(?:pat|dt|rt|cbt|imt|agent|oas|ptt|soat|ffct|ft)-[A-Za-z0-9_=-]{20,}|GR1348941[A-Za-z0-9_-]{20})",
38
39
  "category": "secret"
39
40
  },
40
41
  {
@@ -46,7 +47,7 @@
46
47
  {
47
48
  "id": "slack-token",
48
49
  "description": "Slack Token",
49
- "regex": "xox[baprs]-[0-9a-zA-Z-]{10,72}",
50
+ "regex": "(?:xox[abeprs]|xapp|xwfp)-[0-9a-zA-Z-]{10,72}",
50
51
  "category": "secret"
51
52
  },
52
53
  {
@@ -64,13 +65,13 @@
64
65
  {
65
66
  "id": "telegram-bot-token",
66
67
  "description": "Telegram Bot Token",
67
- "regex": "[0-9]{8,10}:AA[0-9A-Za-z_-]{33}",
68
+ "regex": "\\b[0-9]{6,12}:AA[0-9A-Za-z_-]{30,128}\\b",
68
69
  "category": "secret"
69
70
  },
70
71
  {
71
72
  "id": "twilio-sid",
72
73
  "description": "Twilio Account SID",
73
- "regex": "AC[0-9a-f]{32}",
74
+ "regex": "(?<![A-Za-z0-9])(?:AC|SK)[0-9a-fA-F]{32}(?![A-Za-z0-9])",
74
75
  "category": "secret"
75
76
  },
76
77
  {
@@ -94,7 +95,7 @@
94
95
  {
95
96
  "id": "stripe-secret-key",
96
97
  "description": "Stripe Secret Key",
97
- "regex": "sk_(live|test)_[0-9a-zA-Z]{24}",
98
+ "regex": "(?:sk_(?:live|test|org))_[0-9a-zA-Z]{24}|whsec_[0-9a-zA-Z]{32,}",
98
99
  "category": "secret"
99
100
  },
100
101
  {
@@ -119,19 +120,19 @@
119
120
  {
120
121
  "id": "anthropic-key",
121
122
  "description": "Anthropic API Key",
122
- "regex": "sk-ant-[A-Za-z0-9_-]{95}",
123
+ "regex": "sk-ant-[A-Za-z0-9_-]{95,}",
123
124
  "category": "secret"
124
125
  },
125
126
  {
126
127
  "id": "replicate-token",
127
128
  "description": "Replicate API Token",
128
- "regex": "r8_[0-9A-Za-z_]{37}",
129
+ "regex": "r8_[0-9A-Za-z_-]{37}",
129
130
  "category": "secret"
130
131
  },
131
132
  {
132
133
  "id": "huggingface-token",
133
134
  "description": "Hugging Face Access Token",
134
- "regex": "hf_[a-zA-Z0-9]{34}",
135
+ "regex": "(?:hf_[a-zA-Z0-9]{34}|api_org_[a-zA-Z0-9]{34})",
135
136
  "category": "secret"
136
137
  },
137
138
  {
@@ -161,19 +162,19 @@
161
162
  {
162
163
  "id": "digitalocean-pat",
163
164
  "description": "DigitalOcean Personal Access Token",
164
- "regex": "dop_v1_[a-f0-9]{64}",
165
+ "regex": "do[opr]_v1_[a-f0-9]{64}",
165
166
  "category": "secret"
166
167
  },
167
168
  {
168
169
  "id": "square-access-token",
169
170
  "description": "Square Access Token",
170
- "regex": "EAAA[a-zA-Z0-9\\-_+=]{60}",
171
+ "regex": "(?<![A-Za-z0-9+/])(?:EAAA[a-zA-Z0-9\\-_+=]{60,1024}|sq0(?:atp|csp)-[A-Za-z0-9_-]{22,128})(?![A-Za-z0-9+/=_-])",
171
172
  "category": "secret"
172
173
  },
173
174
  {
174
175
  "id": "mapbox-token",
175
176
  "description": "Mapbox Token",
176
- "regex": "(?:pk|sk)\\.eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}",
177
+ "regex": "(?<![A-Za-z0-9_-])(?:pk|sk|tk)\\.eyJ[A-Za-z0-9_-]{10,1024}\\.[A-Za-z0-9_-]{10,1024}",
177
178
  "category": "secret"
178
179
  },
179
180
  {
@@ -185,7 +186,7 @@
185
186
  {
186
187
  "id": "sentry-org-token",
187
188
  "description": "Sentry Organization Auth Token",
188
- "regex": "sntrys_eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}",
189
+ "regex": "(?<![A-Za-z0-9_-])sntrys_[A-Za-z0-9+/=]{10,1024}_[A-Za-z0-9]{43}",
189
190
  "category": "secret"
190
191
  },
191
192
  {
@@ -197,13 +198,13 @@
197
198
  {
198
199
  "id": "linear-key",
199
200
  "description": "Linear API Key",
200
- "regex": "lin_api_[a-z0-9]{40}",
201
+ "regex": "lin_api_[a-zA-Z0-9]{40}",
201
202
  "category": "secret"
202
203
  },
203
204
  {
204
205
  "id": "postman-key",
205
206
  "description": "Postman API Key",
206
- "regex": "PMAK-[a-f0-9]{24}-[a-f0-9]{34}",
207
+ "regex": "PMAK-[a-fA-F0-9]{24}-[a-fA-F0-9]{34}",
207
208
  "category": "secret"
208
209
  },
209
210
  {
@@ -215,7 +216,7 @@
215
216
  {
216
217
  "id": "jwt",
217
218
  "description": "JSON Web Token (JWT)",
218
- "regex": "(?<!sntrys_|pk\\.|sk\\.)eyJ[A-Za-z0-9_-]{10,}\\.eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}",
219
+ "regex": "(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{10,4096}\\.eyJ[A-Za-z0-9_-]{10,4096}\\.[A-Za-z0-9_-]{10,4096}",
219
220
  "category": "secret"
220
221
  },
221
222
  {
@@ -230,27 +231,28 @@
230
231
  {
231
232
  "id": "env-assignment",
232
233
  "description": ".env style secret assignment",
233
- "regex": "\\b[A-Z_]*(SECRET|PASSWORD|PASSWD|TOKEN|API_KEY|PRIVATE_KEY)[A-Z_0-9]*\\s*=\\s*(\\S{8,})",
234
+ "regex": "(?:(?:^|\\n)[ \\t-]{0,64}[\"']?[A-Za-z0-9_]{0,64}(?:SECRET|PASSWORD|PASSWD|(?<![A-Za-z])PASS(?![A-Za-z])|TOKEN|API_KEY|ACCESS_KEY|PRIVATE_KEY)[A-Za-z0-9_]{0,64}[\"']?[ \\t]*:|\\b[A-Za-z0-9_]{0,64}(?:SECRET|PASSWORD|PASSWD|(?<![A-Za-z])PASS(?![A-Za-z])|TOKEN|API_KEY|ACCESS_KEY|PRIVATE_KEY)[A-Za-z0-9_]{0,64}[ \\t]*=)[ \\t]*[\"']?(?!\\$)(?=([^\\s\"'\\[\\]{}();,<>]{8,4096}))\\1(?:(?=$|[\\s\"';,)\\]}])|(?=[^\\s\"'\\[\\]{}();,<>]))",
235
+ "flags": "gi",
234
236
  "category": "secret",
235
- "secretGroup": 2,
237
+ "secretGroup": 1,
236
238
  "entropyThreshold": 3
237
239
  },
238
240
  {
239
241
  "id": "connection-string",
240
242
  "description": "Database Connection String with credentials",
241
- "regex": "(mongodb|mysql|postgres|postgresql|redis):\\/\\/[^:\\s]+:[^@\\s]+@",
243
+ "regex": "(mongodb(?:\\+srv)?|mysql|postgres|postgresql|redis):\\/\\/[^:\\s]{1,1024}:[^@\\s]{1,1024}@",
242
244
  "category": "secret"
243
245
  },
244
246
  {
245
247
  "id": "pii-email",
246
248
  "description": "Email Address",
247
- "regex": "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b",
249
+ "regex": "(?<!\\b(?:ssh|scp|rsync|sftp)\\b(?:[ \\t]+[^\\s@,;()]+){0,2}[ \\t]+)\\b(?!(?:git|hg|svn)@)[A-Za-z0-9._%+-]{1,64}@(?![0-9]+\\.)(?!example\\.(?:com|net|org|edu)\\b)(?![A-Za-z0-9.-]{0,255}\\.(?:test|invalid|localhost|local|example)\\b)(?:[A-Za-z0-9-]+\\.)+(?!(?:png|jpg|jpeg|gif|webp|svg|ico|bmp|tiff|tif|css|json|html|htm|xml|yaml|yml|lock|woff|ttf|otf|eot|webm|wav|flac|pdf|tgz|bz|xz)(?![A-Za-z]))[A-Za-z]{2,}(?![A-Za-z0-9.-])(?!:\\S)",
248
250
  "category": "pii"
249
251
  },
250
252
  {
251
253
  "id": "pii-credit-card",
252
254
  "description": "Credit Card Number",
253
- "regex": "\\b(?:4[0-9]{3}(?:[\\s-]?[0-9]{4}){3}|5[1-5][0-9]{2}(?:[\\s-]?[0-9]{4}){3}|3[47][0-9]{2}[\\s-]?[0-9]{6}[\\s-]?[0-9]{5}|6(?:011|5[0-9]{2})[0-9](?:[\\s-]?[0-9]{4}){3})\\b",
255
+ "regex": "\\b(?:4[0-9]{3}(?:[\\s-]?[0-9]{4}){3}[\\s-]?[0-9]{3}|4[0-9]{3}(?:[\\s-]?[0-9]{4}){3}|4[0-9]{3}(?:[\\s-]?[0-9]{4}){2}[\\s-]?[0-9]{1}|5[1-5][0-9]{2}(?:[\\s-]?[0-9]{4}){3}|2(?:22[1-9]|2[3-9][0-9]|[3-6][0-9]{2}|7[01][0-9]|720)(?:[\\s-]?[0-9]{4}){3}|3[47][0-9]{2}(?:[\\s-]?[0-9]{4}){2}[\\s-]?[0-9]{3}|3[47][0-9]{2}[\\s-]?[0-9]{6}[\\s-]?[0-9]{5}|6(?:011|4[4-9][0-9]|5[0-9]{2})(?:[\\s-]?[0-9]{4}){3}[\\s-]?[0-9]{3}|6(?:011|4[4-9][0-9]|5[0-9]{2})(?:[\\s-]?[0-9]{4}){3}[\\s-]?[0-9]{2}|6(?:011|4[4-9][0-9]|5[0-9]{2})(?:[\\s-]?[0-9]{4}){3}[\\s-]?[0-9]{1}|6(?:011|4[4-9][0-9]|5[0-9]{2})(?:[\\s-]?[0-9]{4}){3}|35(?:2[89]|[3-8][0-9])(?:[\\s-]?[0-9]{4}){3}[\\s-]?[0-9]{3}|35(?:2[89]|[3-8][0-9])(?:[\\s-]?[0-9]{4}){3}[\\s-]?[0-9]{2}|35(?:2[89]|[3-8][0-9])(?:[\\s-]?[0-9]{4}){3}[\\s-]?[0-9]{1}|35(?:2[89]|[3-8][0-9])(?:[\\s-]?[0-9]{4}){3}|3(?:0[0-5]|[689][0-9])[0-9](?:[\\s-]?[0-9]{4}){3}[\\s-]?[0-9]{3}|3(?:0[0-5]|[689][0-9])[0-9](?:[\\s-]?[0-9]{4}){3}[\\s-]?[0-9]{2}|3(?:0[0-5]|[689][0-9])[0-9](?:[\\s-]?[0-9]{4}){3}[\\s-]?[0-9]{1}|3(?:0[0-5]|[689][0-9])[0-9](?:[\\s-]?[0-9]{4}){3}|3(?:0[0-5]|[689][0-9])[0-9](?:[\\s-]?[0-9]{4}){2}[\\s-]?[0-9]{2}|3(?:0[0-5]|[689][0-9])[0-9][\\s-]?[0-9]{6}[\\s-]?[0-9]{4}|(?:62|81)[0-9]{2}(?:[\\s-]?[0-9]{4}){3}[\\s-]?[0-9]{3}|(?:62|81)[0-9]{2}(?:[\\s-]?[0-9]{4}){3}[\\s-]?[0-9]{2}|(?:62|81)[0-9]{2}(?:[\\s-]?[0-9]{4}){3}[\\s-]?[0-9]{1}|(?:62|81)[0-9]{2}(?:[\\s-]?[0-9]{4}){3}|(?:5018|5020|5038|6304|6759|676[1-3])(?:[\\s-]?[0-9]{4}){3}[\\s-]?[0-9]{3}|(?:5018|5020|5038|6304|6759|676[1-3])(?:[\\s-]?[0-9]{4}){3}[\\s-]?[0-9]{2}|(?:5018|5020|5038|6304|6759|676[1-3])(?:[\\s-]?[0-9]{4}){3}[\\s-]?[0-9]{1}|(?:5018|5020|5038|6304|6759|676[1-3])(?:[\\s-]?[0-9]{4}){3}|(?:5018|5020|5038|6304|6759|676[1-3])(?:[\\s-]?[0-9]{4}){2}[\\s-]?[0-9]{3}|(?:5018|5020|5038|6304|6759|676[1-3])(?:[\\s-]?[0-9]{4}){2}[\\s-]?[0-9]{2}|(?:5018|5020|5038|6304|6759|676[1-3])(?:[\\s-]?[0-9]{4}){2}[\\s-]?[0-9]{1}|(?:5018|5020|5038|6304|6759|676[1-3])(?:[\\s-]?[0-9]{4}){2})\\b",
254
256
  "category": "pii",
255
257
  "validate": "luhn"
256
258
  },
@@ -270,18 +272,13 @@
270
272
  "id": "pii-phone-jp",
271
273
  "description": "Japanese Phone Number",
272
274
  "regex": "\\b0\\d{1,4}[\\s-]\\d{1,4}[\\s-]\\d{4}\\b",
273
- "category": "pii"
275
+ "category": "pii",
276
+ "validate": "phone-jp"
274
277
  },
275
278
  {
276
279
  "id": "pii-postal-jp",
277
280
  "description": "Japanese Postal Code",
278
- "regex": "〒\\d{3}[\\s-]\\d{4}",
279
- "category": "pii"
280
- },
281
- {
282
- "id": "pii-ipv4",
283
- "description": "IPv4 Address (private range)",
284
- "regex": "\\b(10\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|172\\.(1[6-9]|2\\d|3[01])\\.\\d{1,3}\\.\\d{1,3}|192\\.168\\.\\d{1,3}\\.\\d{1,3})\\b",
281
+ "regex": "\u3012\\d{3}[\\s-]\\d{4}",
285
282
  "category": "pii"
286
283
  },
287
284
  {
@@ -302,7 +299,7 @@
302
299
  {
303
300
  "id": "pii-codice-fiscale-it",
304
301
  "description": "Italian Codice Fiscale",
305
- "regex": "\\b[A-Z]{6}\\d{2}[A-Z]\\d{2}[A-Z]\\d{3}[A-Z]\\b",
302
+ "regex": "\\b[A-Z]{6}[0-9LMNPQRSTUV]{2}[A-Z][0-9LMNPQRSTUV]{2}[A-Z][0-9LMNPQRSTUV]{3}[A-Z]\\b",
306
303
  "category": "pii",
307
304
  "validate": "codice-fiscale-it"
308
305
  },
@@ -327,9 +324,9 @@
327
324
  "category": "pii",
328
325
  "requireContext": true,
329
326
  "contextWords": [
330
- "tél",
327
+ "t\u00e9l",
331
328
  "tel",
332
- "téléphone",
329
+ "t\u00e9l\u00e9phone",
333
330
  "telephone",
334
331
  "phone",
335
332
  "mobile",
@@ -360,10 +357,10 @@
360
357
  "category": "pii",
361
358
  "requireContext": true,
362
359
  "contextWords": [
363
- "teléfono",
360
+ "tel\u00e9fono",
364
361
  "telefono",
365
362
  "tel",
366
- "móvil",
363
+ "m\u00f3vil",
367
364
  "movil",
368
365
  "phone",
369
366
  "fax"
@@ -382,23 +379,44 @@
382
379
  "postcode",
383
380
  "plz",
384
381
  "postleitzahl",
385
- "cap",
386
- "우편번호"
387
- ]
382
+ "\uc6b0\ud3b8\ubc88\ud638",
383
+ "cap"
384
+ ],
385
+ "excludeContext": ["buffer", "bytes", "byte", "memory", "cache"]
388
386
  },
389
387
  {
390
388
  "id": "pii-rrn-kr",
391
389
  "description": "Korean Resident Registration Number",
392
390
  "regex": "\\b\\d{6}[-\\s]?\\d{7}\\b",
393
391
  "category": "pii",
394
- "validate": "rrn-kr"
392
+ "validate": "rrn-kr",
393
+ "requireContext": true,
394
+ "contextWords": [
395
+ "\uc8fc\ubbfc\ub4f1\ub85d\ubc88\ud638",
396
+ "\uc8fc\ubbfc\ubc88\ud638",
397
+ "rrn",
398
+ "resident",
399
+ "registration",
400
+ "\uc2e0\ubd84\uc99d",
401
+ "\uac1c\uc778\uc815\ubcf4"
402
+ ]
395
403
  },
396
404
  {
397
405
  "id": "pii-brn-kr",
398
406
  "description": "Korean Business Registration Number",
399
407
  "regex": "\\b\\d{3}[-\\s]?\\d{2}[-\\s]?\\d{5}\\b",
400
408
  "category": "pii",
401
- "validate": "brn-kr"
409
+ "validate": "brn-kr",
410
+ "requireContext": true,
411
+ "contextWords": [
412
+ "\uc0ac\uc5c5\uc790\ub4f1\ub85d\ubc88\ud638",
413
+ "\uc0ac\uc5c5\uc790\ubc88\ud638",
414
+ "brn",
415
+ "business",
416
+ "registration",
417
+ "\ubc95\uc778",
418
+ "\uc138\uae08\uacc4\uc0b0\uc11c"
419
+ ]
402
420
  },
403
421
  {
404
422
  "id": "pii-resident-id-cn",
@@ -414,9 +432,9 @@
414
432
  "category": "pii",
415
433
  "requireContext": true,
416
434
  "contextWords": [
417
- "전화",
418
- "핸드폰",
419
- "휴대",
435
+ "\uc804\ud654",
436
+ "\ud578\ub4dc\ud3f0",
437
+ "\ud734\ub300",
420
438
  "tel",
421
439
  "phone",
422
440
  "mobile",
@@ -429,7 +447,14 @@
429
447
  "regex": "(?:\\+86[-\\s]?)?(?:1[3-9]\\d{9}|0\\d{2,3}[-\\s]?\\d{7,8})",
430
448
  "category": "pii",
431
449
  "requireContext": true,
432
- "contextWords": ["电话", "手机", "tel", "phone", "mobile", "fax"]
450
+ "contextWords": [
451
+ "\u7535\u8bdd",
452
+ "\u624b\u673a",
453
+ "tel",
454
+ "phone",
455
+ "mobile",
456
+ "fax"
457
+ ]
433
458
  },
434
459
  {
435
460
  "id": "pii-postal-cn",
@@ -437,7 +462,13 @@
437
462
  "regex": "\\b[1-9]\\d{5}\\b",
438
463
  "category": "pii",
439
464
  "requireContext": true,
440
- "contextWords": ["邮编", "邮政编码", "postal", "postcode", "zip"]
465
+ "contextWords": [
466
+ "\u90ae\u7f16",
467
+ "\u90ae\u653f\u7f16\u7801",
468
+ "postal",
469
+ "postcode",
470
+ "zip"
471
+ ]
441
472
  },
442
473
  {
443
474
  "id": "pii-ipv4-public",
@@ -456,6 +487,84 @@
456
487
  "requireContext": true,
457
488
  "contextWords": ["ipv6", "ip"],
458
489
  "validate": "public-ipv6"
490
+ },
491
+ {
492
+ "id": "openai-service-key",
493
+ "description": "OpenAI Service Account / Admin Key",
494
+ "regex": "sk-(?:svcacct|admin)-[A-Za-z0-9_-]{20,}",
495
+ "category": "secret"
496
+ },
497
+ {
498
+ "id": "azure-storage-key",
499
+ "description": "Azure Storage Account Key",
500
+ "regex": "AccountKey=[A-Za-z0-9+/]{86}==",
501
+ "category": "secret"
502
+ },
503
+ {
504
+ "id": "flyio-token",
505
+ "description": "Fly.io API Token",
506
+ "regex": "(?:fo1_[A-Za-z0-9_-]{43}|fm1[ar]_[A-Za-z0-9+/_-]{40,}={0,3}|fm2_[A-Za-z0-9+/_-]{40,}={0,3})",
507
+ "category": "secret"
508
+ },
509
+ {
510
+ "id": "databricks-token",
511
+ "description": "Databricks Personal Access Token",
512
+ "regex": "\\bdapi[0-9a-f]{32}\\b",
513
+ "category": "secret"
514
+ },
515
+ {
516
+ "id": "vault-token",
517
+ "description": "HashiCorp Vault Token",
518
+ "regex": "\\bhv[sb]\\.[A-Za-z0-9_-]{24,}",
519
+ "category": "secret"
520
+ },
521
+ {
522
+ "id": "shopify-token",
523
+ "description": "Shopify Access Token",
524
+ "regex": "\\bshp(?:at|ss|ca|pa)_[0-9a-fA-F]{32}\\b",
525
+ "category": "secret"
526
+ },
527
+ {
528
+ "id": "doppler-token",
529
+ "description": "Doppler Token",
530
+ "regex": "\\bdp\\.(?:pt|st|ct|sa|scim)\\.[A-Za-z0-9]{40,}",
531
+ "category": "secret"
532
+ },
533
+ {
534
+ "id": "grafana-token",
535
+ "description": "Grafana Cloud / Service Account Token",
536
+ "regex": "\\bgl(?:c|sa)_[A-Za-z0-9+/=_-]{32,}",
537
+ "category": "secret"
538
+ },
539
+ {
540
+ "id": "notion-token",
541
+ "description": "Notion Integration Token",
542
+ "regex": "\\b(?:ntn_[A-Za-z0-9]{40,}|secret_[A-Za-z0-9]{43})",
543
+ "category": "secret"
544
+ },
545
+ {
546
+ "id": "azure-sas-key",
547
+ "description": "Azure Shared Access Key (Service Bus, Event Hubs, IoT Hub)",
548
+ "regex": "SharedAccessKey=[A-Za-z0-9+/]{22,86}={0,2}",
549
+ "category": "secret"
550
+ },
551
+ {
552
+ "id": "google-oauth-secret",
553
+ "description": "Google OAuth Client Secret",
554
+ "regex": "\\bGOCSPX-[A-Za-z0-9_-]{20,}",
555
+ "category": "secret"
556
+ },
557
+ {
558
+ "id": "private-key-base64",
559
+ "description": "PEM Private Key, base64-encoded",
560
+ "regex": "(?:LS0tLS1CRUdJTi|0tLS0tQkVHSU4g|tLS0tLUJFR0lOI)[A-Za-z0-9+/=]{40,}",
561
+ "category": "secret"
562
+ },
563
+ {
564
+ "id": "url-basic-auth",
565
+ "description": "Credentials embedded in an http(s) URL",
566
+ "regex": "\\bhttps?://[A-Za-z0-9._~%+-]{1,64}:[^@/\\s\\\"'`<>]{3,256}@[A-Za-z0-9.-]+",
567
+ "category": "secret"
459
568
  }
460
569
  ]
461
570
  }
@@ -0,0 +1,135 @@
1
+ // How a file's bytes are read as text.
2
+ //
3
+ // A file is not labelled with its encoding, so these decide. The decisions are
4
+ // deliberately loose and the caller reads the bytes both ways when the verdict
5
+ // is a guess: the alternative is a wrong guess that decodes a credential into
6
+ // characters no rule matches, which is the whole scan switched off for that
7
+ // file.
8
+
9
+ import fs from "node:fs";
10
+
11
+ // The bytes as little-endian UTF-16, with whether a byte-order mark said so.
12
+ //
13
+ // The mark decides it outright. Without one, the question is whether every NUL
14
+ // falls on the same side of each pair — which it does in UTF-16 text, because
15
+ // the high byte of a Latin character is zero. Requiring most pairs to carry one
16
+ // is too strong: a file whose first few hundred characters are Japanese or
17
+ // Chinese has neither byte zero. One NUL on a consistent side is enough to ask
18
+ // the question; whether the answer is text is what settles it.
19
+ //
20
+ // A guess this loose is wrong sometimes, so `fromBom` marks the ones that are
21
+ // guesses and the caller scans the bytes both ways rather than betting on it.
22
+ export type Utf16Reading = { bytes: Buffer; fromBom: boolean };
23
+
24
+ export function detectUtf16(raw: Buffer): Utf16Reading | null {
25
+ if (raw.length < 4) return null;
26
+ // Swapping is done on a copy: `raw` is a view into the shared read buffer.
27
+ const swapped = (): Buffer =>
28
+ Buffer.from(raw)
29
+ .subarray(0, raw.length & ~1)
30
+ .swap16();
31
+ if (raw[0] === 0xff && raw[1] === 0xfe) return { bytes: raw, fromBom: true };
32
+ if (raw[0] === 0xfe && raw[1] === 0xff)
33
+ return { bytes: swapped(), fromBom: true };
34
+
35
+ // Far enough in to reach a newline or a space. Five hundred pairs of Japanese
36
+ // carry no zero byte at all, and that prefix decided the whole file.
37
+ const pairs = Math.min(raw.length >> 1, 8192);
38
+ let evenNuls = 0;
39
+ let oddNuls = 0;
40
+ for (let i = 0; i < pairs; i++) {
41
+ if (raw[i * 2] === 0) evenNuls++;
42
+ if (raw[i * 2 + 1] === 0) oddNuls++;
43
+ }
44
+ // Several, not one: a single stray NUL is not an encoding.
45
+ const MINIMUM_NULS = 8;
46
+ if (Math.max(evenNuls, oddNuls) < MINIMUM_NULS) return null;
47
+
48
+ // How much the minority side holds is not asked. Characters in the U+xx00
49
+ // rows put a NUL on that side, and Japanese is full of them — `一` is U+4E00,
50
+ // and a full-width space is U+3000 — so any threshold tight enough to exclude
51
+ // a binary also excludes an ordinary Japanese document. The counts cannot
52
+ // separate those two cases, so the question is left to `readsAsText`, and
53
+ // being wrong costs only a pass: the caller reads the bytes both ways
54
+ // whenever the verdict did not come from a byte-order mark.
55
+ //
56
+ // Which side leads picks the order the two byte orders are tried in, not
57
+ // whether they are tried.
58
+ const orderings: Buffer[] =
59
+ oddNuls >= evenNuls ? [raw, swapped()] : [swapped(), raw];
60
+ for (const candidate of orderings) {
61
+ if (readsAsText(candidate)) return { bytes: candidate, fromBom: false };
62
+ }
63
+ return null;
64
+ }
65
+
66
+ // Whether the runs of text between the NUL bytes read as something someone
67
+ // wrote. A `.env` written by a tool that terminates its values, and a log with
68
+ // a stray zero in it, both pass; a JPEG's compressed bytes do not.
69
+ export function readsAsUtf8Text(raw: Buffer): boolean {
70
+ const sample = utf8Runs(raw.subarray(0, 4096));
71
+ if (sample.length === 0) return false;
72
+ let bad = 0;
73
+ for (const ch of sample) {
74
+ const code = ch.codePointAt(0) ?? 0;
75
+ const isControl =
76
+ (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) ||
77
+ code === 0x7f;
78
+ if (isControl || code === 0xfffd) bad++;
79
+ }
80
+ return bad / sample.length < 0.05;
81
+ }
82
+
83
+ // Every run of text between the NUL bytes, joined by newlines so that no rule
84
+ // matches across two unrelated runs.
85
+ export function utf8Runs(raw: Buffer): string {
86
+ const text = raw.toString("utf8");
87
+ return text.indexOf("\0") === -1 ? text : text.split("\0").join("\n");
88
+ }
89
+
90
+ // Whether these bytes decoded as UTF-16 look like something someone wrote. A
91
+ // binary file can have its NULs on one side by chance; text decoded from the
92
+ // wrong encoding is mostly control characters and replacement characters, and
93
+ // this is what separates the two.
94
+ export function readsAsText(le: Buffer): boolean {
95
+ const sample = le.subarray(0, 4096).toString("utf16le");
96
+ if (sample.length === 0) return false;
97
+ let bad = 0;
98
+ for (const ch of sample) {
99
+ const code = ch.codePointAt(0) ?? 0;
100
+ const isControl =
101
+ (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) ||
102
+ code === 0x7f;
103
+ if (isControl || code === 0xfffd || (code >= 0xe000 && code <= 0xf8ff))
104
+ bad++;
105
+ }
106
+ return bad / sample.length < 0.05;
107
+ }
108
+
109
+ // Whether the first few kilobytes hold a NUL byte that UTF-16 does not explain.
110
+ // UTF-16 text is half NUL by construction, and a `.env` written by PowerShell is
111
+ // the file a sweep least wants to skip.
112
+ const BINARY_SNIFF_BYTES = 4096;
113
+
114
+ export function looksBinary(filePath: string): boolean {
115
+ let fd: number | undefined;
116
+ try {
117
+ fd = fs.openSync(filePath, "r");
118
+ const head = Buffer.alloc(BINARY_SNIFF_BYTES);
119
+ const read = fs.readSync(fd, head, 0, head.length, 0);
120
+ const bytes = head.subarray(0, read);
121
+ if (!bytes.includes(0)) return false;
122
+ // Neither reading, rather than the UTF-16 verdict alone. That verdict is
123
+ // deliberately loose — the file scan covers a wrong guess by reading the
124
+ // bytes both ways — and a sweep has no such second chance, so it asks the
125
+ // question the answer is wanted for: does anything here read as text?
126
+ const utf16 = detectUtf16(bytes);
127
+ if (utf16 !== null && readsAsText(utf16.bytes)) return false;
128
+ return !readsAsUtf8Text(bytes);
129
+ } catch {
130
+ // Unreadable. Nothing to sweep, and the named-file path still guards it.
131
+ return true;
132
+ } finally {
133
+ if (fd !== undefined) fs.closeSync(fd);
134
+ }
135
+ }
@@ -0,0 +1,36 @@
1
+ // What both hooks do when the check itself goes wrong.
2
+ //
3
+ // A hook that crashes exits 1, and only exit 2 blocks, so an unforeseen error
4
+ // is a silent pass — the failure this whole tool exists to prevent. What went
5
+ // wrong is unknown at this point, and "unknown" is not "safe": the check did
6
+ // not finish, so the call is stopped rather than let through. `[allow-all]`
7
+ // gets past it, and the message says the check failed rather than claiming a
8
+ // finding.
9
+ //
10
+ // One copy, because two would be two rules: the wording, the exit code and
11
+ // which events are handled all have to be the same in both hooks, and a fix
12
+ // applied to one of two copies is a hook that fails open on the other.
13
+
14
+ export function failClosed(error: unknown): never {
15
+ try {
16
+ process.stderr.write(
17
+ `\n🐤 sensitive-canary: the check could not complete — ${
18
+ error instanceof Error ? error.message : String(error)
19
+ }\n\n` +
20
+ " Nothing was scanned, so nothing can be vouched for. Stopping rather\n" +
21
+ " than passing it through. Add [allow-all] to your prompt to proceed\n" +
22
+ " anyway, and please report this.\n",
23
+ );
24
+ } catch {
25
+ // A closed stderr must not turn the block back into a pass.
26
+ }
27
+ process.exit(2);
28
+ }
29
+
30
+ // Registered by both hooks as their first statement. A rejection that nothing
31
+ // awaited reaches the process the same way an exception does, and either one
32
+ // arriving unhandled is the pass this exists to stop.
33
+ export function blockOnUnhandledError(): void {
34
+ process.on("uncaughtException", failClosed);
35
+ process.on("unhandledRejection", failClosed);
36
+ }
Binary file