@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
@@ -0,0 +1,570 @@
1
+ {
2
+ "contextWindow": 3,
3
+ "rules": [
4
+ {
5
+ "id": "aws-access-key",
6
+ "description": "AWS Access Key ID",
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"
10
+ },
11
+ {
12
+ "id": "gcp-api-key",
13
+ "description": "Google Cloud API Key",
14
+ "regex": "AIza[0-9A-Za-z_-]{35}",
15
+ "category": "secret"
16
+ },
17
+ {
18
+ "id": "private-key",
19
+ "description": "PEM Private Key",
20
+ "regex": "-----BEGIN ((RSA|EC|DSA|PGP|OPENSSH|ENCRYPTED|SSH2 ENCRYPTED) )?PRIVATE KEY",
21
+ "category": "secret"
22
+ },
23
+ {
24
+ "id": "github-pat",
25
+ "description": "GitHub Personal Access Token",
26
+ "regex": "gh[pousr]_[A-Za-z0-9]{36,255}",
27
+ "category": "secret"
28
+ },
29
+ {
30
+ "id": "github-fine-grained",
31
+ "description": "GitHub Fine-Grained Token",
32
+ "regex": "github_pat_[A-Za-z0-9_]{82}",
33
+ "category": "secret"
34
+ },
35
+ {
36
+ "id": "gitlab-pat",
37
+ "description": "GitLab Personal Access Token",
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})",
39
+ "category": "secret"
40
+ },
41
+ {
42
+ "id": "npm-token",
43
+ "description": "npm Access Token",
44
+ "regex": "npm_[A-Za-z0-9]{36}",
45
+ "category": "secret"
46
+ },
47
+ {
48
+ "id": "slack-token",
49
+ "description": "Slack Token",
50
+ "regex": "(?:xox[abeprs]|xapp|xwfp)-[0-9a-zA-Z-]{10,72}",
51
+ "category": "secret"
52
+ },
53
+ {
54
+ "id": "slack-webhook",
55
+ "description": "Slack Webhook URL",
56
+ "regex": "https:\\/\\/hooks\\.slack\\.com\\/services\\/T[A-Za-z0-9_]{8,10}\\/B[A-Za-z0-9_]{8,12}\\/[A-Za-z0-9_]{23,24}",
57
+ "category": "secret"
58
+ },
59
+ {
60
+ "id": "discord-webhook",
61
+ "description": "Discord Webhook URL",
62
+ "regex": "https:\\/\\/discord(?:app)?\\.com\\/api\\/webhooks\\/[0-9]{17,20}\\/[A-Za-z0-9_-]{68}",
63
+ "category": "secret"
64
+ },
65
+ {
66
+ "id": "telegram-bot-token",
67
+ "description": "Telegram Bot Token",
68
+ "regex": "\\b[0-9]{6,12}:AA[0-9A-Za-z_-]{30,128}\\b",
69
+ "category": "secret"
70
+ },
71
+ {
72
+ "id": "twilio-sid",
73
+ "description": "Twilio Account SID",
74
+ "regex": "(?<![A-Za-z0-9])(?:AC|SK)[0-9a-fA-F]{32}(?![A-Za-z0-9])",
75
+ "category": "secret"
76
+ },
77
+ {
78
+ "id": "sendgrid-key",
79
+ "description": "SendGrid API Key",
80
+ "regex": "SG\\.[A-Za-z0-9_-]{20,24}\\.[A-Za-z0-9_-]{39,50}",
81
+ "category": "secret"
82
+ },
83
+ {
84
+ "id": "mailgun-key",
85
+ "description": "Mailgun API Key",
86
+ "regex": "key-[0-9a-zA-Z]{32}",
87
+ "category": "secret"
88
+ },
89
+ {
90
+ "id": "mailchimp-key",
91
+ "description": "Mailchimp API Key",
92
+ "regex": "[0-9a-f]{32}-us[0-9]{1,2}",
93
+ "category": "secret"
94
+ },
95
+ {
96
+ "id": "stripe-secret-key",
97
+ "description": "Stripe Secret Key",
98
+ "regex": "(?:sk_(?:live|test|org))_[0-9a-zA-Z]{24}|whsec_[0-9a-zA-Z]{32,}",
99
+ "category": "secret"
100
+ },
101
+ {
102
+ "id": "stripe-restricted-key",
103
+ "description": "Stripe Restricted Key",
104
+ "regex": "rk_(live|test)_[0-9a-zA-Z]{24}",
105
+ "category": "secret"
106
+ },
107
+ {
108
+ "id": "openai-key",
109
+ "description": "OpenAI API Key (legacy)",
110
+ "regex": "sk-(?!proj-|ant-)[A-Za-z0-9]{48}",
111
+ "category": "secret"
112
+ },
113
+ {
114
+ "id": "openai-project-key",
115
+ "description": "OpenAI Project API Key",
116
+ "regex": "sk-proj-[A-Za-z0-9_-]{40,}",
117
+ "category": "secret",
118
+ "entropyThreshold": 3.5
119
+ },
120
+ {
121
+ "id": "anthropic-key",
122
+ "description": "Anthropic API Key",
123
+ "regex": "sk-ant-[A-Za-z0-9_-]{95,}",
124
+ "category": "secret"
125
+ },
126
+ {
127
+ "id": "replicate-token",
128
+ "description": "Replicate API Token",
129
+ "regex": "r8_[0-9A-Za-z_-]{37}",
130
+ "category": "secret"
131
+ },
132
+ {
133
+ "id": "huggingface-token",
134
+ "description": "Hugging Face Access Token",
135
+ "regex": "(?:hf_[a-zA-Z0-9]{34}|api_org_[a-zA-Z0-9]{34})",
136
+ "category": "secret"
137
+ },
138
+ {
139
+ "id": "groq-key",
140
+ "description": "Groq API Key",
141
+ "regex": "gsk_[A-Za-z0-9]{52}",
142
+ "category": "secret"
143
+ },
144
+ {
145
+ "id": "openrouter-key",
146
+ "description": "OpenRouter API Key",
147
+ "regex": "sk-or-v1-[0-9a-f]{64}",
148
+ "category": "secret"
149
+ },
150
+ {
151
+ "id": "xai-key",
152
+ "description": "xAI (Grok) API Key",
153
+ "regex": "xai-[0-9A-Za-z_]{80}",
154
+ "category": "secret"
155
+ },
156
+ {
157
+ "id": "perplexity-key",
158
+ "description": "Perplexity API Key",
159
+ "regex": "pplx-[a-zA-Z0-9]{48}",
160
+ "category": "secret"
161
+ },
162
+ {
163
+ "id": "digitalocean-pat",
164
+ "description": "DigitalOcean Personal Access Token",
165
+ "regex": "do[opr]_v1_[a-f0-9]{64}",
166
+ "category": "secret"
167
+ },
168
+ {
169
+ "id": "square-access-token",
170
+ "description": "Square Access Token",
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+/=_-])",
172
+ "category": "secret"
173
+ },
174
+ {
175
+ "id": "mapbox-token",
176
+ "description": "Mapbox Token",
177
+ "regex": "(?<![A-Za-z0-9_-])(?:pk|sk|tk)\\.eyJ[A-Za-z0-9_-]{10,1024}\\.[A-Za-z0-9_-]{10,1024}",
178
+ "category": "secret"
179
+ },
180
+ {
181
+ "id": "sentry-user-token",
182
+ "description": "Sentry User Auth Token",
183
+ "regex": "sntryu_[a-f0-9]{64}",
184
+ "category": "secret"
185
+ },
186
+ {
187
+ "id": "sentry-org-token",
188
+ "description": "Sentry Organization Auth Token",
189
+ "regex": "(?<![A-Za-z0-9_-])sntrys_[A-Za-z0-9+/=]{10,1024}_[A-Za-z0-9]{43}",
190
+ "category": "secret"
191
+ },
192
+ {
193
+ "id": "atlassian-token",
194
+ "description": "Atlassian API Token",
195
+ "regex": "ATATT3[A-Za-z0-9_\\-=]{180,}",
196
+ "category": "secret"
197
+ },
198
+ {
199
+ "id": "linear-key",
200
+ "description": "Linear API Key",
201
+ "regex": "lin_api_[a-zA-Z0-9]{40}",
202
+ "category": "secret"
203
+ },
204
+ {
205
+ "id": "postman-key",
206
+ "description": "Postman API Key",
207
+ "regex": "PMAK-[a-fA-F0-9]{24}-[a-fA-F0-9]{34}",
208
+ "category": "secret"
209
+ },
210
+ {
211
+ "id": "supabase-key",
212
+ "description": "Supabase Personal Access Token",
213
+ "regex": "sbp_[a-z0-9]{40}",
214
+ "category": "secret"
215
+ },
216
+ {
217
+ "id": "jwt",
218
+ "description": "JSON Web Token (JWT)",
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}",
220
+ "category": "secret"
221
+ },
222
+ {
223
+ "id": "generic-secret",
224
+ "description": "Generic API Key / Secret",
225
+ "regex": "(api[_-]?key|secret[_-]?key|access[_-]?token|api[_-]?secret)\\s*[:=]\\s*['\"]?([A-Za-z0-9\\-_.]{20,})",
226
+ "category": "secret",
227
+ "flags": "gi",
228
+ "secretGroup": 2,
229
+ "entropyThreshold": 3.5
230
+ },
231
+ {
232
+ "id": "env-assignment",
233
+ "description": ".env style secret assignment",
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",
236
+ "category": "secret",
237
+ "secretGroup": 1,
238
+ "entropyThreshold": 3
239
+ },
240
+ {
241
+ "id": "connection-string",
242
+ "description": "Database Connection String with credentials",
243
+ "regex": "(mongodb(?:\\+srv)?|mysql|postgres|postgresql|redis):\\/\\/[^:\\s]{1,1024}:[^@\\s]{1,1024}@",
244
+ "category": "secret"
245
+ },
246
+ {
247
+ "id": "pii-email",
248
+ "description": "Email Address",
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)",
250
+ "category": "pii"
251
+ },
252
+ {
253
+ "id": "pii-credit-card",
254
+ "description": "Credit Card Number",
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",
256
+ "category": "pii",
257
+ "validate": "luhn"
258
+ },
259
+ {
260
+ "id": "pii-ssn",
261
+ "description": "US Social Security Number",
262
+ "regex": "\\b(?!000|666|9\\d{2})\\d{3}[- ](?!00)\\d{2}[- ](?!0000)\\d{4}\\b",
263
+ "category": "pii"
264
+ },
265
+ {
266
+ "id": "pii-phone-us",
267
+ "description": "US Phone Number",
268
+ "regex": "\\b(\\+1[\\s.-]?)?\\(?\\d{3}\\)?[\\s.-]\\d{3}[\\s.-]\\d{4}\\b",
269
+ "category": "pii"
270
+ },
271
+ {
272
+ "id": "pii-phone-jp",
273
+ "description": "Japanese Phone Number",
274
+ "regex": "\\b0\\d{1,4}[\\s-]\\d{1,4}[\\s-]\\d{4}\\b",
275
+ "category": "pii",
276
+ "validate": "phone-jp"
277
+ },
278
+ {
279
+ "id": "pii-postal-jp",
280
+ "description": "Japanese Postal Code",
281
+ "regex": "\u3012\\d{3}[\\s-]\\d{4}",
282
+ "category": "pii"
283
+ },
284
+ {
285
+ "id": "pii-mynumber-jp",
286
+ "description": "Japanese Individual Number (My Number)",
287
+ "regex": "\\b\\d{12}\\b",
288
+ "category": "pii",
289
+ "validate": "mynumber-jp"
290
+ },
291
+ {
292
+ "id": "pii-nir-fr",
293
+ "description": "French NIR / Social Security Number",
294
+ "regex": "\\b[12]\\d{2}(?:0[1-9]|1[0-2]|20)(?:\\d{5}|2[AB]\\d{3})\\d{3}\\s?\\d{2}\\b",
295
+ "category": "pii",
296
+ "flags": "gi",
297
+ "validate": "nir-fr"
298
+ },
299
+ {
300
+ "id": "pii-codice-fiscale-it",
301
+ "description": "Italian Codice Fiscale",
302
+ "regex": "\\b[A-Z]{6}[0-9LMNPQRSTUV]{2}[A-Z][0-9LMNPQRSTUV]{2}[A-Z][0-9LMNPQRSTUV]{3}[A-Z]\\b",
303
+ "category": "pii",
304
+ "validate": "codice-fiscale-it"
305
+ },
306
+ {
307
+ "id": "pii-steuer-id-de",
308
+ "description": "German Steuer-Identifikationsnummer",
309
+ "regex": "\\b[1-9]\\d{10}\\b",
310
+ "category": "pii",
311
+ "validate": "steuer-id-de"
312
+ },
313
+ {
314
+ "id": "pii-dni-nie-es",
315
+ "description": "Spanish DNI / NIE",
316
+ "regex": "\\b(?:\\d{8}[A-Z]|[XYZ]\\d{7}[A-Z])\\b",
317
+ "category": "pii",
318
+ "validate": "dni-nie-es"
319
+ },
320
+ {
321
+ "id": "pii-phone-fr",
322
+ "description": "French Phone Number",
323
+ "regex": "\\b0[1-9](?:[\\s.-]?\\d{2}){4}\\b",
324
+ "category": "pii",
325
+ "requireContext": true,
326
+ "contextWords": [
327
+ "t\u00e9l",
328
+ "tel",
329
+ "t\u00e9l\u00e9phone",
330
+ "telephone",
331
+ "phone",
332
+ "mobile",
333
+ "portable",
334
+ "fax"
335
+ ]
336
+ },
337
+ {
338
+ "id": "pii-phone-it",
339
+ "description": "Italian Phone Number",
340
+ "regex": "\\b(?:0\\d{8,9}|3\\d{8,9})\\b",
341
+ "category": "pii",
342
+ "requireContext": true,
343
+ "contextWords": ["telefono", "tel", "cellulare", "mobile", "phone", "fax"]
344
+ },
345
+ {
346
+ "id": "pii-phone-de",
347
+ "description": "German Phone Number",
348
+ "regex": "\\b0[1-9]\\d{6,11}\\b",
349
+ "category": "pii",
350
+ "requireContext": true,
351
+ "contextWords": ["telefon", "tel", "handy", "mobil", "phone", "fax"]
352
+ },
353
+ {
354
+ "id": "pii-phone-es",
355
+ "description": "Spanish Phone Number",
356
+ "regex": "\\b[67]\\d{8}\\b",
357
+ "category": "pii",
358
+ "requireContext": true,
359
+ "contextWords": [
360
+ "tel\u00e9fono",
361
+ "telefono",
362
+ "tel",
363
+ "m\u00f3vil",
364
+ "movil",
365
+ "phone",
366
+ "fax"
367
+ ]
368
+ },
369
+ {
370
+ "id": "pii-postal-code",
371
+ "description": "Postal Code (US ZIP / EU / KR)",
372
+ "regex": "\\b\\d{5}(?:-\\d{4})?\\b",
373
+ "category": "pii",
374
+ "requireContext": true,
375
+ "contextWords": [
376
+ "zip",
377
+ "postal",
378
+ "postale",
379
+ "postcode",
380
+ "plz",
381
+ "postleitzahl",
382
+ "\uc6b0\ud3b8\ubc88\ud638",
383
+ "cap"
384
+ ],
385
+ "excludeContext": ["buffer", "bytes", "byte", "memory", "cache"]
386
+ },
387
+ {
388
+ "id": "pii-rrn-kr",
389
+ "description": "Korean Resident Registration Number",
390
+ "regex": "\\b\\d{6}[-\\s]?\\d{7}\\b",
391
+ "category": "pii",
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
+ ]
403
+ },
404
+ {
405
+ "id": "pii-brn-kr",
406
+ "description": "Korean Business Registration Number",
407
+ "regex": "\\b\\d{3}[-\\s]?\\d{2}[-\\s]?\\d{5}\\b",
408
+ "category": "pii",
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
+ ]
420
+ },
421
+ {
422
+ "id": "pii-resident-id-cn",
423
+ "description": "Chinese Resident Identity Card",
424
+ "regex": "\\b\\d{17}[\\dXx]\\b",
425
+ "category": "pii",
426
+ "validate": "resident-id-cn"
427
+ },
428
+ {
429
+ "id": "pii-phone-kr",
430
+ "description": "Korean Phone Number",
431
+ "regex": "(?:\\+82[-\\s]?)?(?:01[016789]|0\\d{1,2})[-\\s]?\\d{3,4}[-\\s]?\\d{4}",
432
+ "category": "pii",
433
+ "requireContext": true,
434
+ "contextWords": [
435
+ "\uc804\ud654",
436
+ "\ud578\ub4dc\ud3f0",
437
+ "\ud734\ub300",
438
+ "tel",
439
+ "phone",
440
+ "mobile",
441
+ "fax"
442
+ ]
443
+ },
444
+ {
445
+ "id": "pii-phone-cn",
446
+ "description": "Chinese Phone Number",
447
+ "regex": "(?:\\+86[-\\s]?)?(?:1[3-9]\\d{9}|0\\d{2,3}[-\\s]?\\d{7,8})",
448
+ "category": "pii",
449
+ "requireContext": true,
450
+ "contextWords": [
451
+ "\u7535\u8bdd",
452
+ "\u624b\u673a",
453
+ "tel",
454
+ "phone",
455
+ "mobile",
456
+ "fax"
457
+ ]
458
+ },
459
+ {
460
+ "id": "pii-postal-cn",
461
+ "description": "Chinese Postal Code",
462
+ "regex": "\\b[1-9]\\d{5}\\b",
463
+ "category": "pii",
464
+ "requireContext": true,
465
+ "contextWords": [
466
+ "\u90ae\u7f16",
467
+ "\u90ae\u653f\u7f16\u7801",
468
+ "postal",
469
+ "postcode",
470
+ "zip"
471
+ ]
472
+ },
473
+ {
474
+ "id": "pii-ipv4-public",
475
+ "description": "Public IPv4 Address",
476
+ "regex": "\\b\\d{1,3}(?:\\.\\d{1,3}){3}\\b",
477
+ "category": "pii",
478
+ "requireContext": true,
479
+ "contextWords": ["ip", "ipv4"],
480
+ "validate": "public-ipv4"
481
+ },
482
+ {
483
+ "id": "pii-ipv6",
484
+ "description": "IPv6 Address",
485
+ "regex": "\\b[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{0,4}){2,7}\\b",
486
+ "category": "pii",
487
+ "requireContext": true,
488
+ "contextWords": ["ipv6", "ip"],
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"
568
+ }
569
+ ]
570
+ }
@@ -0,0 +1,123 @@
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
+ import fs from "node:fs";
9
+ export function detectUtf16(raw) {
10
+ if (raw.length < 4)
11
+ return null;
12
+ // Swapping is done on a copy: `raw` is a view into the shared read buffer.
13
+ const swapped = () => Buffer.from(raw)
14
+ .subarray(0, raw.length & ~1)
15
+ .swap16();
16
+ if (raw[0] === 0xff && raw[1] === 0xfe)
17
+ return { bytes: raw, fromBom: true };
18
+ if (raw[0] === 0xfe && raw[1] === 0xff)
19
+ return { bytes: swapped(), fromBom: true };
20
+ // Far enough in to reach a newline or a space. Five hundred pairs of Japanese
21
+ // carry no zero byte at all, and that prefix decided the whole file.
22
+ const pairs = Math.min(raw.length >> 1, 8192);
23
+ let evenNuls = 0;
24
+ let oddNuls = 0;
25
+ for (let i = 0; i < pairs; i++) {
26
+ if (raw[i * 2] === 0)
27
+ evenNuls++;
28
+ if (raw[i * 2 + 1] === 0)
29
+ oddNuls++;
30
+ }
31
+ // Several, not one: a single stray NUL is not an encoding.
32
+ const MINIMUM_NULS = 8;
33
+ if (Math.max(evenNuls, oddNuls) < MINIMUM_NULS)
34
+ return null;
35
+ // How much the minority side holds is not asked. Characters in the U+xx00
36
+ // rows put a NUL on that side, and Japanese is full of them — `一` is U+4E00,
37
+ // and a full-width space is U+3000 — so any threshold tight enough to exclude
38
+ // a binary also excludes an ordinary Japanese document. The counts cannot
39
+ // separate those two cases, so the question is left to `readsAsText`, and
40
+ // being wrong costs only a pass: the caller reads the bytes both ways
41
+ // whenever the verdict did not come from a byte-order mark.
42
+ //
43
+ // Which side leads picks the order the two byte orders are tried in, not
44
+ // whether they are tried.
45
+ const orderings = oddNuls >= evenNuls ? [raw, swapped()] : [swapped(), raw];
46
+ for (const candidate of orderings) {
47
+ if (readsAsText(candidate))
48
+ return { bytes: candidate, fromBom: false };
49
+ }
50
+ return null;
51
+ }
52
+ // Whether the runs of text between the NUL bytes read as something someone
53
+ // wrote. A `.env` written by a tool that terminates its values, and a log with
54
+ // a stray zero in it, both pass; a JPEG's compressed bytes do not.
55
+ export function readsAsUtf8Text(raw) {
56
+ const sample = utf8Runs(raw.subarray(0, 4096));
57
+ if (sample.length === 0)
58
+ return false;
59
+ let bad = 0;
60
+ for (const ch of sample) {
61
+ const code = ch.codePointAt(0) ?? 0;
62
+ const isControl = (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) ||
63
+ code === 0x7f;
64
+ if (isControl || code === 0xfffd)
65
+ bad++;
66
+ }
67
+ return bad / sample.length < 0.05;
68
+ }
69
+ // Every run of text between the NUL bytes, joined by newlines so that no rule
70
+ // matches across two unrelated runs.
71
+ export function utf8Runs(raw) {
72
+ const text = raw.toString("utf8");
73
+ return text.indexOf("\0") === -1 ? text : text.split("\0").join("\n");
74
+ }
75
+ // Whether these bytes decoded as UTF-16 look like something someone wrote. A
76
+ // binary file can have its NULs on one side by chance; text decoded from the
77
+ // wrong encoding is mostly control characters and replacement characters, and
78
+ // this is what separates the two.
79
+ export function readsAsText(le) {
80
+ const sample = le.subarray(0, 4096).toString("utf16le");
81
+ if (sample.length === 0)
82
+ return false;
83
+ let bad = 0;
84
+ for (const ch of sample) {
85
+ const code = ch.codePointAt(0) ?? 0;
86
+ const isControl = (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) ||
87
+ code === 0x7f;
88
+ if (isControl || code === 0xfffd || (code >= 0xe000 && code <= 0xf8ff))
89
+ bad++;
90
+ }
91
+ return bad / sample.length < 0.05;
92
+ }
93
+ // Whether the first few kilobytes hold a NUL byte that UTF-16 does not explain.
94
+ // UTF-16 text is half NUL by construction, and a `.env` written by PowerShell is
95
+ // the file a sweep least wants to skip.
96
+ const BINARY_SNIFF_BYTES = 4096;
97
+ export function looksBinary(filePath) {
98
+ let fd;
99
+ try {
100
+ fd = fs.openSync(filePath, "r");
101
+ const head = Buffer.alloc(BINARY_SNIFF_BYTES);
102
+ const read = fs.readSync(fd, head, 0, head.length, 0);
103
+ const bytes = head.subarray(0, read);
104
+ if (!bytes.includes(0))
105
+ return false;
106
+ // Neither reading, rather than the UTF-16 verdict alone. That verdict is
107
+ // deliberately loose — the file scan covers a wrong guess by reading the
108
+ // bytes both ways — and a sweep has no such second chance, so it asks the
109
+ // question the answer is wanted for: does anything here read as text?
110
+ const utf16 = detectUtf16(bytes);
111
+ if (utf16 !== null && readsAsText(utf16.bytes))
112
+ return false;
113
+ return !readsAsUtf8Text(bytes);
114
+ }
115
+ catch {
116
+ // Unreadable. Nothing to sweep, and the named-file path still guards it.
117
+ return true;
118
+ }
119
+ finally {
120
+ if (fd !== undefined)
121
+ fs.closeSync(fd);
122
+ }
123
+ }