@broberg/secret-scan 0.15.0 → 0.16.2

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/dist/cli.js ADDED
@@ -0,0 +1,690 @@
1
+ #!/usr/bin/env node
2
+ // src/index.ts
3
+ var PATTERNS = [
4
+ {
5
+ label: "private-key",
6
+ description: "PEM private key block (RSA/EC/OPENSSH/DSA/PGP)",
7
+ regex: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g
8
+ },
9
+ {
10
+ label: "anthropic-api-key",
11
+ description: "Anthropic API key (sk-ant-\u2026)",
12
+ regex: /sk-ant-(?:api03-)?[A-Za-z0-9_-]{20,}/g
13
+ },
14
+ {
15
+ // OpenRouter — distinct from OpenAI; runs BEFORE the generic sk- (which would
16
+ // otherwise also match + mislabel it).
17
+ label: "openrouter-api-key",
18
+ description: "OpenRouter API key (sk-or-v1- + 64 hex)",
19
+ regex: /\bsk-or-v1-[0-9a-f]{64}/g
20
+ },
21
+ {
22
+ // DeepSeek — shares the sk- prefix with OpenAI, so it MUST run before the
23
+ // generic openai pattern (specific-before-generic = correct attribution).
24
+ // DeepSeek's documented shape is sk- + 32 lowercase hex (GitGuardian confirms
25
+ // an sk- prefix but hides the exact regex); the hex-only body + {32,} length
26
+ // distinguishes it from OpenAI's mixed-case base62 keys, so a real OpenAI key
27
+ // is never mislabelled. The field-anchored fallback below catches any
28
+ // DEEPSEEK_API_KEY value that doesn't fit this canonical shape.
29
+ label: "deepseek-api-key",
30
+ description: "DeepSeek API key (sk- + 32 lowercase hex)",
31
+ regex: /\bsk-[0-9a-f]{32,}(?![0-9a-z])/g
32
+ },
33
+ {
34
+ label: "openai-api-key",
35
+ description: "OpenAI API key (sk-\u2026 / sk-proj-\u2026)",
36
+ // F035.23 — \b: "sk-" must start a word. Without it "klassisk-ansigtsbehandling-…"
37
+ // in an ordinary URL was an OpenAI key (house-of-wellness, a blocked commit).
38
+ regex: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}/g
39
+ },
40
+ {
41
+ // ElevenLabs — sk_ with UNDERSCORE (vs OpenAI sk-), 48 hex.
42
+ label: "elevenlabs-api-key",
43
+ description: "ElevenLabs API key (sk_ + 48 hex)",
44
+ regex: /\bsk_[0-9a-f]{48}\b/g
45
+ },
46
+ {
47
+ // fal.ai — uuid:hex32 (key_id:key_secret); the colon is the signal.
48
+ label: "fal-api-key",
49
+ description: "fal.ai key (uuid:hex32)",
50
+ regex: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:[0-9a-f]{32}\b/g
51
+ },
52
+ {
53
+ // Black Forest Labs (FLUX) API key — bfl_ prefix + a long token (sample
54
+ // bfl_Qo1…). The distinctive prefix + {20,} length keeps false positives near
55
+ // zero; image-provider sibling of the fal key above.
56
+ label: "bfl-api-key",
57
+ description: "Black Forest Labs / FLUX API key (bfl_ + token)",
58
+ regex: /\bbfl_[A-Za-z0-9_-]{20,}/g
59
+ },
60
+ {
61
+ label: "google-api-key",
62
+ description: "Google / Gemini API key (AIza\u2026)",
63
+ regex: /AIza[0-9A-Za-z_-]{35}/g
64
+ },
65
+ {
66
+ label: "google-oauth-secret",
67
+ description: "Google OAuth client secret (GOCSPX-\u2026)",
68
+ regex: /GOCSPX-[A-Za-z0-9_-]{28}/g
69
+ },
70
+ // ── AWS ─────────────────────────────────────────────────────────────────
71
+ //
72
+ // ORDER IS LOAD-BEARING HERE, and it is not the order you would write first.
73
+ // redactSecrets() applies patterns in sequence to the text it has ALREADY
74
+ // redacted, so the id pattern must run LAST: it replaces `AKIA…` with a
75
+ // marker, and the paired rule below anchors on that id. Put the id first and
76
+ // the pair rule silently stops firing — a guard that is present, tested in
77
+ // isolation, and dead in place.
78
+ {
79
+ // THE HALF THAT MATTERS, and it shipped unmatched for months. An access key
80
+ // id alone is useless to an attacker; the SECRET key is the credential. So
81
+ // redacting only the id — and stamping [REDACTED:…] right beside the live
82
+ // secret — is worse than redacting nothing, because the marker tells the
83
+ // reader the text was cleaned. Reported by cardmem the day they took AWS on.
84
+ //
85
+ // A bare 40-char base64 value CANNOT be matched: it is the shape of every
86
+ // git object hash and base64 body in every repo we own. So this is
87
+ // CONTEXT-ONLY, like every other prefix-less secret in this file.
88
+ //
89
+ // `access` is REQUIRED in the field name on purpose. A bare `secret_key`
90
+ // (Terraform's spelling) would drag in far too much; that case is caught by
91
+ // the paired rule below instead, which is the argument for having both.
92
+ //
93
+ // AND THE VALUE CLASS IS "ANYTHING THAT IS NOT A DELIMITER", not an
94
+ // alphabet. cardmem measured their Tigris secret's charset after 0.8.1 and
95
+ // it contains `+` — which our class happened to include, but only because
96
+ // we guessed base64 rather than base64url. Their warning is the one worth
97
+ // acting on: that is ONE key. It tells us what CAN occur, never what always
98
+ // occurs, and the next provider's alphabet is another guess we would make
99
+ // the same way. Under a field literally named `aws_secret_access_key`, the
100
+ // NAME is the evidence; the value's alphabet adds nothing and can only be
101
+ // wrong. So the value runs to the first delimiter and no further.
102
+ //
103
+ // ONE EXCLUSION, and it is a false positive our own docs produced the
104
+ // moment the class widened: a value that is a REFERENCE to a secret is not
105
+ // a secret. `secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!` is code
106
+ // showing how to READ the credential, and redacting it would put a
107
+ // [REDACTED:…] marker in a README about wiring up storage. So a value that
108
+ // is ENTIRELY a dotted identifier path, or starts with a shell/template
109
+ // expansion, is skipped. It must be the WHOLE value — a real secret may
110
+ // contain dots, and the exclusion must not fire on one that does.
111
+ //
112
+ // THE LENGTH IS A FLOOR, NOT AWS'S 40 — measured by cardmem in production
113
+ // and it is the finding that matters most here. Their four AWS_*-named
114
+ // variables on Fly are NOT AWS: they are Tigris (Fly's S3-compatible
115
+ // store), with a 54-character `tid_` id and a 75-character secret. Every
116
+ // S3-compatible service — Tigris, R2, MinIO, Backblaze — reuses AWS's
117
+ // variable NAMES with its own key format.
118
+ //
119
+ // Pinning 40 put a shape assumption on top of a name anchor, so the field
120
+ // said AWS_SECRET_ACCESS_KEY, the value did not look like AWS, and the
121
+ // credential stayed in the clear with no marker anywhere near it. The field
122
+ // name is the signal in every context-only pattern in this file; that is
123
+ // the whole design, and requiring a second signal quietly undid it.
124
+ label: "aws-secret-access-key",
125
+ description: "AWS/S3-compatible secret access key ((aws-)secret-access-key field + 20+ non-delimiter chars)",
126
+ regex: /\b(?:aws[_-]?)?secret[_-]?access[_-]?key\b["'`]?\s*[:=]\s*["'`]?(?!(?:\$|\{)|(?:[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)+!?)(?=[\s"'`,;]|$))[^\s"'`,;]{20,}/gi
127
+ },
128
+ {
129
+ // An STS session token is a live credential for as long as it lasts, and it
130
+ // travels in the same dump as the pair above.
131
+ label: "aws-session-token",
132
+ description: "AWS session token ((aws-)session-token field + 100+ base64)",
133
+ regex: /\b(?:aws[_-]?)?session[_-]?token\b["'`]?\s*[:=]\s*["'`]?(?!(?:\$|\{)|(?:[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)+!?)(?=[\s"'`,;]|$))[^\s"'`,;]{100,}/gi
134
+ },
135
+ {
136
+ // THE WINDOW IS MEASURED, not chosen. Gap between the end of the id and the
137
+ // start of the secret, in the six formats these actually arrive in:
138
+ //
139
+ // console CSV row 1 terraform provider block 18
140
+ // sts assume-role JSON 21 aws CLI credentials file 25
141
+ // env export pair 30 docker-compose env 30
142
+ //
143
+ // 80 is the largest real case plus room for one intervening line, and both
144
+ // sides of it are pinned by a fixture — a proximity threshold nothing can
145
+ // move is a magic number wearing a measurement's clothes (F035.12).
146
+ //
147
+ // The false-positive cost is near zero BECAUSE the id must be present: a
148
+ // 40-char base64 string is only redacted when an AWS access key id sits
149
+ // within 80 characters of it. That also catches the pair when the field is
150
+ // named something we never anticipated, which the rule above cannot.
151
+ label: "aws-secret-access-key-paired",
152
+ description: "A 40-char base64 value within 80 characters of an AWS access key id",
153
+ // `=` is deliberately NOT in the leading lookbehind, though it IS in the
154
+ // value class: base64 padding never STARTS a value, and excluding it there
155
+ // blocked every `KEY=value` form — measured, `blob=<secret>` went
156
+ // unredacted while the same pair in CSV and Terraform was caught.
157
+ //
158
+ // THE CHEAP LOOKBEHIND GOES FIRST (0.11.1). Both are zero-width at the same
159
+ // position, so the order does not change what matches — only what it costs.
160
+ // With the 100-char id search first, EVERY character of a long run paid it:
161
+ // 50,000 × 'A' took 3.7 s in this one pattern and a 400 KB blob 30+ s in
162
+ // redactSecrets. `(?<![A-Za-z0-9/+])` rejects every position inside a run
163
+ // in one step, and the lookahead then demands a whole 40-char value AHEAD
164
+ // before anything looks behind — so after a space or a colon (where the
165
+ // first guard passes) it fails in a character or two. The expensive id
166
+ // search only runs where a complete candidate already stands.
167
+ regex: /(?<![A-Za-z0-9/+])(?=[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=]))(?<=(?:AKIA|ASIA)[0-9A-Z]{16}[\s\S]{0,80})[A-Za-z0-9/+=]{40}/g
168
+ },
169
+ {
170
+ // A SEPARATE LABEL FROM AKIA, and the reason is operational rather than
171
+ // tidy: the two demand different responses. A leaked long-term key must be
172
+ // rotated; a leaked STS key may already have expired on its own. A reader
173
+ // seeing [REDACTED:…] in a log can only make that call if the marker says
174
+ // which one it was. ASIA was unmatched entirely before 0.8.0, so an
175
+ // assumed-role dump read as clean.
176
+ label: "aws-temporary-access-key-id",
177
+ description: "AWS temporary (STS) access key id (ASIA\u2026)",
178
+ regex: /\bASIA[0-9A-Z]{16}\b/g
179
+ },
180
+ {
181
+ label: "aws-access-key-id",
182
+ description: "AWS long-term access key id (AKIA\u2026)",
183
+ regex: /\bAKIA[0-9A-Z]{16}\b/g
184
+ },
185
+ {
186
+ label: "github-token",
187
+ description: "GitHub token (ghp_/gho_/ghs_/ghu_/ghr_\u2026)",
188
+ regex: /\bgh[posru]_[A-Za-z0-9]{36,}\b/g
189
+ },
190
+ {
191
+ // GitHub fine-grained PAT — distinct prefix `github_pat_` (not caught by the
192
+ // classic gh[posru]_ above), then base62 + a `_` separator (~82 chars total).
193
+ // The prefix is so distinctive that {50,} keeps false positives at zero.
194
+ label: "github-fine-grained-pat",
195
+ description: "GitHub fine-grained personal access token (github_pat_\u2026)",
196
+ regex: /\bgithub_pat_[A-Za-z0-9_]{50,}/g
197
+ },
198
+ {
199
+ label: "gitlab-token",
200
+ description: "GitLab personal access token (glpat-\u2026)",
201
+ regex: /\bglpat-[A-Za-z0-9_-]{20,}/g
202
+ },
203
+ {
204
+ label: "slack-token",
205
+ description: "Slack token (xox[baprs]-\u2026)",
206
+ regex: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g
207
+ },
208
+ {
209
+ label: "stripe-secret-key",
210
+ description: "Stripe live secret/restricted key (sk_live_/rk_live_\u2026)",
211
+ regex: /\b[rs]k_live_[A-Za-z0-9]{20,}/g
212
+ },
213
+ {
214
+ // Filed by buddy, who found REAL ones sitting in plaintext in their own
215
+ // transcription DB. Their scrub deliberately runs the format axis ONLY, so a
216
+ // prefixed secret we do not match is a secret nobody catches — a precise
217
+ // prefix is the only route that helps them. Zero false-positive risk: the
218
+ // literal `whsec_` does not occur by accident.
219
+ label: "stripe-webhook-secret",
220
+ description: "Stripe webhook signing secret (whsec_\u2026)",
221
+ regex: /\bwhsec_[A-Za-z0-9+/=_-]{20,}/g
222
+ },
223
+ {
224
+ // Resend (re_…). Lookahead requires a digit in the body so we don't redact
225
+ // long snake_case identifiers like re_compute_the_thing.
226
+ label: "resend-api-key",
227
+ description: "Resend API key (re_ + token)",
228
+ regex: /\bre_(?=[A-Za-z0-9_]*\d)[A-Za-z0-9_]{24,}\b/g
229
+ },
230
+ {
231
+ label: "supabase-access-token",
232
+ description: "Supabase personal/management access token (sbp_ + 40 hex)",
233
+ regex: /\bsbp_[0-9a-f]{40}/g
234
+ },
235
+ {
236
+ label: "supabase-secret-key",
237
+ description: "Supabase secret API key (sb_secret_\u2026)",
238
+ regex: /\bsb_secret_[A-Za-z0-9_-]{20,}/g
239
+ },
240
+ {
241
+ // Used by every @broberg/* publish — the highest-value leak from a .env / commit history.
242
+ label: "npm-token",
243
+ description: "npm publish/automation token (npm_ + 36 base62)",
244
+ regex: /\bnpm_[A-Za-z0-9]{36}\b/g
245
+ },
246
+ {
247
+ label: "fly-api-token",
248
+ description: "Fly.io API token (FlyV1 fm2_\u2026 / fo1_\u2026)",
249
+ regex: /(?:FlyV1 fm2_[A-Za-z0-9+/=_-]{20,}|\bfo1_[A-Za-z0-9_-]{20,})/g
250
+ },
251
+ {
252
+ // Also covers Turso DB/platform auth tokens AND Supabase anon/service_role
253
+ // keys — both are JWTs (eyJ…), so the single JWT pattern catches them.
254
+ label: "jwt",
255
+ description: "JSON Web Token (eyJ\u2026) \u2014 incl. Turso + Supabase service_role tokens",
256
+ regex: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g
257
+ },
258
+ {
259
+ // genApiKey = randomBytes(24).hex → uk_ + 48 lowercase hex (components' real key,
260
+ // measured 4/11 2026). F090.6: widened to 32–64 alphanumerics — cardmem's fleet
261
+ // git gate (F036.23) planted uk_ + 40 mixed and it slipped through; a uk_ prefix
262
+ // with a tail that long has next to no false positives, and a key from another
263
+ // generator must not pass the gate because its alphabet differs.
264
+ label: "upmetrics-key",
265
+ description: "Upmetrics project key (uk_ + 32\u201364 alphanumerics; real keys are 48 hex)",
266
+ regex: /\buk_[A-Za-z0-9]{32,64}(?![A-Za-z0-9])/g
267
+ },
268
+ {
269
+ label: "cardmem-key",
270
+ description: "Cardmem personal/incident/project key (pa_/pi_/pk_ + 64 hex)",
271
+ regex: /\bp[aik]_[A-Za-z0-9]{20,}/g
272
+ },
273
+ {
274
+ // cardmem inbox-webhook key — piw_ isn't matched by p[aik]_ above (3rd char 'w' ≠ '_').
275
+ label: "cardmem-webhook-key",
276
+ description: "Cardmem inbox-webhook key (piw_ + 64 hex)",
277
+ regex: /\bpiw_[0-9a-f]{64}/g
278
+ },
279
+ {
280
+ // VERIFIED WITH THE OWNER, 2026-08-28: `trail_` + exactly 64 LOWERCASE HEX.
281
+ // trail generated 2000 keys through @broberg/apikey's generateKey('trail')
282
+ // and counted the alphabet: 0-9a-f only, length 64-64, no `-`, no `_`. Source
283
+ // is `${prefix}_${randomBytes(bytes).toString("hex")}`, bytes=32, with a hard
284
+ // floor of 16 — so even a future caller asking for the minimum yields 32 hex
285
+ // chars, still above {20,}.
286
+ //
287
+ // Recorded because the QUESTION is easy to re-ask and the ANSWER is not: this
288
+ // is one of the few patterns here assuming alphanumerics only, and had trail
289
+ // used base64url (the more common one-liner) the `-` and `_` would break the
290
+ // run, {20,} would never be satisfied, and the WHOLE key would pass through
291
+ // unredacted — not partially, entirely. Measured rather than assumed, because
292
+ // two sampled keys cannot tell hex from base64url that has not hit a `-` yet.
293
+ label: "trail-key",
294
+ description: "Trail personal API key (trail_ + 64 hex; verified 2026-08-28)",
295
+ regex: /\btrail_[A-Za-z0-9]{20,}/g
296
+ },
297
+ {
298
+ // Cronjobs API key (cronjobs.webhouse.net) — cj_ + randomBytes(32).base64url =
299
+ // exactly 43 base64url chars (46 total). Prefix + fixed length = very low FP.
300
+ // The UI's truncated cj_<8 chars>… preview is shorter than {43} → not matched.
301
+ // Negative lookahead (not \b) because base64url's `-` breaks a trailing \b.
302
+ label: "cronjobs-api-key",
303
+ description: "Cronjobs API key (cj_ + 43 base64url)",
304
+ regex: /\bcj_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g
305
+ },
306
+ {
307
+ // HelpDesk API key (helpdesk.broberg.ai) — minted through @broberg/apikey,
308
+ // which is OURS: generateKey(prefix, 32) → `${prefix}_${randomBytes(32).hex}`,
309
+ // so exactly 64 LOWERCASE hex. Verified in packages/apikey/src/core.ts rather
310
+ // than taken from the report. There is no checksum and no internal structure
311
+ // to anchor on; prefix + fixed length + hex is everything there is, and it is
312
+ // enough — `hd_live_` followed by exactly 64 hex does not occur by accident.
313
+ //
314
+ // THE LENGTH IS EXACT ON PURPOSE, and this is the half that needs defending
315
+ // in six months. HelpDesk shows a PREVIEW — `hd_live_f4b4cf`, prefix + 6 hex
316
+ // — deliberately, in their UI and their logs, so a human can see WHICH key
317
+ // was revoked. It is not a secret. Redacting it breaks a value designed to be
318
+ // read, and then the preview stops doing its job.
319
+ //
320
+ // So the tempting loosening — "let us catch the shortened ones too" — is the
321
+ // one thing this pattern must never accept. `{64}` excludes the preview, and
322
+ // a NAMED test says so, because by then nobody will remember why.
323
+ //
324
+ // The trailing lookahead covers BOTH cases: 65 hex is not a key, and neither
325
+ // is 64 lowercase followed by an uppercase hex digit.
326
+ //
327
+ // NO PUBLISHABLE VARIANT, measured not assumed: HelpDesk is headless and its
328
+ // console is a client of the same API using a session token. `grep -c "hd_"`
329
+ // in the deployed bundle returns 0, so no key ever reaches a browser and the
330
+ // Stripe pk_live_ trap has no counterpart here. Re-check if that changes.
331
+ label: "helpdesk-api-key",
332
+ description: "HelpDesk API key (hd_live_ + 64 hex, minted by @broberg/apikey)",
333
+ regex: /\bhd_live_[0-9a-f]{64}(?![0-9a-fA-F])/g
334
+ },
335
+ {
336
+ // UpCloud API token — `ucat_` + a ULID: exactly 26 Crockford base32 chars
337
+ // (no I, L, O, U). Measured in three independent places, not inferred from
338
+ // the masked example that filed it: UpCloud's API docs (create response),
339
+ // UpCloud's own Go client fixture, and Kingfisher's rule — all three are in
340
+ // test/f035-16.test.ts. The token's `id` is a separate UUID and is not a
341
+ // secret.
342
+ //
343
+ // NO EXAMPLE VALUE IN THIS COMMENT, on purpose: the bundle keeps comments,
344
+ // so a literal token here ships in dist/ and the scanner flags ITSELF — the
345
+ // pre-commit gate's test caught exactly that on the first 0.10.0 tag.
346
+ //
347
+ // Case-insensitive like Kingfisher: Crockford decodes either case, and a
348
+ // lowercased token is still a live credential. The trailing lookahead keeps
349
+ // 27 chars from matching its first 26 — and leaves UpCloud's own
350
+ // `ucat_[REDACTED]` marker alone.
351
+ label: "upcloud-api-token",
352
+ description: "UpCloud API token (ucat_ + 26 Crockford base32)",
353
+ regex: /\bucat_[0-9A-HJKMNP-TV-Z]{26}(?![0-9A-Za-z])/gi
354
+ },
355
+ // ── F035.17 — the vault survey of 30 Sep 2026. Every shape below was MEASURED
356
+ // on values already stored in cardmem's vault (the script read them server-side
357
+ // and printed only prefix, length and charset), then checked against a source:
358
+ // Kingfisher's public rule set for vendor tokens, our own minters for fleet keys.
359
+ // No example value appears in these comments: the bundle keeps comments, and a
360
+ // literal here would make the scanner flag its own dist/ (see upcloud above).
361
+ {
362
+ // Cloudflare's prefixed user API token. 3 in the vault, all 48 after the
363
+ // prefix; Kingfisher allows 41-64. Runs before the context-only
364
+ // cloudflare-api-token below so a prefixed one is named by its prefix.
365
+ label: "cloudflare-user-api-token",
366
+ description: "Cloudflare user API token (cfut_ + 41-64 base64url)",
367
+ regex: /\bcfut_[A-Za-z0-9_-]{41,64}(?![A-Za-z0-9_-])/g
368
+ },
369
+ {
370
+ // Runpod: rpa_ + 40 uppercase/digit + a 6-char mixed-case checksum tail
371
+ // (Kingfisher runpod.1). 2 in the vault, both 46.
372
+ label: "runpod-api-key",
373
+ description: "Runpod API key (rpa_ + 46)",
374
+ regex: /\brpa_[A-Z0-9]{40}[A-Za-z0-9]{6}(?![A-Za-z0-9])/g
375
+ },
376
+ {
377
+ // Hugging Face user (hf_) and org (api_org_) tokens: 34 letters/digits.
378
+ label: "huggingface-token",
379
+ description: "Hugging Face token (hf_ / api_org_ + 34)",
380
+ regex: /\b(?:hf|api_org)_[A-Za-z0-9]{34}(?![A-Za-z0-9])/g
381
+ },
382
+ {
383
+ // Tailscale: tskey-<kind>-<id>-<secret>. The body carries its own dash, so
384
+ // the class includes it. Measured 50-51 after the kind; Kingfisher's {20,36}
385
+ // would stop short of those, so the ceiling is ours.
386
+ label: "tailscale-key",
387
+ description: "Tailscale key (tskey-<kind>-\u2026)",
388
+ regex: /\btskey-[a-z]{3,10}-[A-Za-z0-9_-]{20,64}(?![A-Za-z0-9_-])/g
389
+ },
390
+ {
391
+ // Tigris secret access key: tsec_ + exactly 70 (Kingfisher tigris.2).
392
+ label: "tigris-secret-key",
393
+ description: "Tigris secret access key (tsec_ + 70)",
394
+ regex: /\btsec_[A-Za-z0-9_+-]{70}(?![A-Za-z0-9_+-])/g
395
+ },
396
+ {
397
+ // Slack APP-level token. slack-token above only knows xox*, so an xapp-
398
+ // token went through untouched. Same label on purpose: the vault already
399
+ // stores it as slack-token, and a second name for one provider helps nobody.
400
+ label: "slack-token",
401
+ description: "Slack app-level token (xapp-\u2026)",
402
+ regex: /\bxapp-\d{1,3}-[A-Za-z0-9]{8,15}-\d{8,15}-[A-Za-z0-9]{20,70}(?![A-Za-z0-9])/g
403
+ },
404
+ {
405
+ // Aiven service password — the credential UpCloud's managed PostgreSQL hands
406
+ // out. AVNS_ + 19. One in the vault; [Likely] fixed length, and a password
407
+ // that silently stops matching is caught by the survey re-run, not by luck.
408
+ label: "aiven-service-password",
409
+ description: "Aiven service password (AVNS_ + 19) \u2014 UpCloud managed databases",
410
+ regex: /\bAVNS_[A-Za-z0-9_-]{19}(?![A-Za-z0-9_-])/g
411
+ },
412
+ {
413
+ // BID app key. OURS: broberg-id mints `bidk_${randomBytes(32).base64url}`,
414
+ // so exactly 43 base64url — a fact about our minter, not a guess.
415
+ label: "bid-app-key",
416
+ description: "BID app key (bidk_ + 43 base64url)",
417
+ regex: /\bbidk_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g
418
+ },
419
+ {
420
+ // Fleet hex keys measured in the vault. Hex length is fixed by construction
421
+ // (randomBytes(n).hex), so the survey's lengths are the minter's lengths.
422
+ label: "beacon-token",
423
+ description: "Beacon token (bcn_ + 64 hex)",
424
+ regex: /\bbcn_[0-9a-f]{64}(?![0-9a-fA-F])/g
425
+ },
426
+ {
427
+ label: "mailworker-admin-key",
428
+ description: "mailworker admin key (mw_ + 64 hex)",
429
+ regex: /\bmw_[0-9a-f]{64}(?![0-9a-fA-F])/g
430
+ },
431
+ {
432
+ label: "upmetrics-remediation-token",
433
+ description: "Upmetrics remediation token (umrt_ + 48 hex)",
434
+ regex: /\bumrt_[0-9a-f]{48}(?![0-9a-fA-F])/g
435
+ },
436
+ {
437
+ // A database URL with a password in it. Matches ONLY the password (the
438
+ // lookbehind pins scheme://user: before it, the lookahead pins @ after), so
439
+ // a redacted URL still says which database it points at.
440
+ //
441
+ // The password must contain a digit or be 12+ characters — so the
442
+ // `user:password@` and `user:pass@` placeholders every README carries stay
443
+ // readable. A real generated DB password clears that bar trivially.
444
+ label: "connection-string",
445
+ description: "Password inside a database connection URL",
446
+ regex: /(?<=\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|rediss?|amqps?):\/\/[^\s:@/]+:)(?=[^\s@/]*\d|[^\s@/]{12})[^\s@/]+(?=@)/gi
447
+ },
448
+ {
449
+ // randomBytes(32).hex → wh_ + 64 lowercase hex (67 chars total).
450
+ // NOTE: unlike cj_ and hd_live_ above, this one has no trailing lookahead, so
451
+ // wh_ + 65 hex matches its first 64. Not a leak (the value is still redacted)
452
+ // and not changed here — flagged rather than silently altered in a card about
453
+ // something else.
454
+ label: "cms-access-token",
455
+ description: "webhouse.app CMS access token (wh_ + 64 hex)",
456
+ regex: /\bwh_[0-9a-f]{64}/g
457
+ },
458
+ {
459
+ // Cloudflare API token (R2 / DNS management) — 40 base64url chars, NO prefix.
460
+ // A bare {40} would false-positive broadly, so this is CONTEXT-ONLY: it only
461
+ // fires next to a cf/cloudflare-api-token-named field. Runs before
462
+ // labeled-hex-secret so a hex-valued CF token is attributed correctly.
463
+ label: "cloudflare-api-token",
464
+ description: "Cloudflare API token (cf/cloudflare-api-token field + 40 base64url)",
465
+ regex: /\b(?:cf|cloudflare)_?api_?token\b\s*[:=]\s*["'`]?[A-Za-z0-9_-]{40}(?![A-Za-z0-9_-])/gi
466
+ },
467
+ {
468
+ // F035.20 — Mistral's newer keys carry a prefix: `mstrl_` + 32 base62 + `_` +
469
+ // 6 base62 (45 chars). Measured by cardmem on ONE stored key, value never
470
+ // sent. The prefix makes it distinctive, so it runs by default (D-453d89).
471
+ // Exact shape only: a key with another split is left unlabelled, not guessed.
472
+ label: "mistral-api-key",
473
+ description: "Mistral API key (mstrl_ + 32 base62 + _ + 6 base62)",
474
+ regex: /\bmstrl_[A-Za-z0-9]{32}_[A-Za-z0-9]{6}(?![A-Za-z0-9_-])/g
475
+ },
476
+ {
477
+ // Mistral API key — prefix-less ~32 base62 (Christian-confirmed sample). A bare
478
+ // [A-Za-z0-9]{32} would FP on every ID/hash, so CONTEXT-ONLY: anchored on a
479
+ // mistral-(api-)key/token-named field. Runs before labeled-hex for attribution.
480
+ label: "mistral-api-key",
481
+ description: "Mistral API key (mistral-(api-)key/token field + 24+ base62)",
482
+ regex: /\bmistral(?:[_-]?api)?[_-]?(?:key|token)\b\s*[:=]\s*["'`]?[A-Za-z0-9]{24,}(?![A-Za-z0-9])/gi
483
+ },
484
+ {
485
+ // DeepSeek — field-anchored fallback for any DEEPSEEK_API_KEY/TOKEN value that
486
+ // doesn't fit the canonical sk-+hex shape (mirrors the Mistral context-only
487
+ // approach). The field name is the signal → near-zero false positives. The
488
+ // sk-+hex format pattern above already attributes the canonical shape; this
489
+ // backstops a format change or an opaque token.
490
+ label: "deepseek-api-key",
491
+ description: "DeepSeek API key (deepseek-(api-)key/token field + 20+ token)",
492
+ regex: /\bdeepseek(?:[_-]?api)?[_-]?(?:key|token)\b\s*[:=]\s*["'`]?[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])/gi
493
+ },
494
+ {
495
+ // Vimeo personal access token — ~32 lowercase hex, no prefix (sanne). A bare
496
+ // hex32 would FP massively (MD5/UUID), so CONTEXT-ONLY: anchored on a
497
+ // vimeo-(access-)token-named field.
498
+ label: "vimeo-access-token",
499
+ description: "Vimeo access token (vimeo-(access-)token field + 20+ base62)",
500
+ regex: /\bvimeo(?:[_-]?access)?[_-]?token\b\s*[:=]\s*["'`]?[A-Za-z0-9]{20,}(?![A-Za-z0-9])/gi
501
+ },
502
+ {
503
+ // F035.22 — every key @broberg/apikey mints: generateKey(prefix) returns
504
+ // `${prefix}_${randomBytes(32).toString("hex")}`. The named fleet prefixes
505
+ // above (uk_, pa_, piw_, trail_ …) keep their own labels — they match first.
506
+ // Buzz's bz_ was not on any list and went to its log in clear text
507
+ // (buzzalert #4388); every new apikey consumer brings a new prefix, so a
508
+ // list by name always lags. Exactly 64 LOWER-case hex after a short
509
+ // lower-case prefix and an underscore, bounded both sides — a bare digest or
510
+ // `sha256:<hex>` has no `<prefix>_` and is left alone.
511
+ label: "apikey-prefixed-key",
512
+ description: "A key minted by @broberg/apikey generateKey (<prefix>_<64 hex>)",
513
+ regex: /(?<![A-Za-z0-9_])[a-z][a-z0-9]{1,15}_[0-9a-f]{64}(?![A-Za-z0-9_])/g
514
+ },
515
+ {
516
+ // Context-based catch for prefix-less high-entropy service secrets
517
+ // (CMS_JWT_SECRET, revalidateSecret, fleet openssl-rand-hex secrets): a 40+
518
+ // hex value assigned to a field whose name contains
519
+ // secret/token/password/api-key. The name requirement keeps the
520
+ // false-positive rate near zero (a bare 40/64-hex would hit shas/hashes).
521
+ label: "labeled-hex-secret",
522
+ description: "A 40+ hex value assigned to a secret/token/password/api-key-named field",
523
+ regex: /\b[A-Za-z0-9_-]*(?:secret|token|password|api[_-]?key)\b\s*[:=]\s*["'`]?[0-9a-f]{40,}/gi
524
+ },
525
+ {
526
+ // F090.6 — a bearer token with no known prefix (cardmem's git gate, F036.23).
527
+ // CONTEXT, not randomness: the word «Bearer» is the signal, like the field name
528
+ // is for labeled-hex-secret. The value must look like a secret, not a word or a
529
+ // placeholder: 24+ token chars, a digit AND a letter, and either an uppercase
530
+ // letter or four digits. «Bearer <token>», «Bearer ${TOKEN}» and «Bearer abc» are
531
+ // left alone. A JWT after Bearer is caught earlier by its own pattern.
532
+ label: "bearer-token",
533
+ description: "A high-entropy value after \xABBearer \xBB (no known prefix)",
534
+ regex: /\bBearer\s+(?=[A-Za-z0-9._~+\/-]*\d)(?=[A-Za-z0-9._~+\/-]*[A-Za-z])(?=[A-Za-z0-9._~+\/-]*[A-Z]|(?:[A-Za-z0-9._~+\/-]*\d){4})[A-Za-z0-9._~+\/-]{24,}={0,2}(?![A-Za-z0-9._~+\/-])/g
535
+ },
536
+ {
537
+ // F090.6 — the same shape assigned to token=/key=/secret= (a URL query or an
538
+ // env line) with no known prefix. Case-sensitive on purpose: /i would make the
539
+ // [A-Z] requirement match lowercase and let identifiers through.
540
+ label: "labeled-token-param",
541
+ description: "A high-entropy value in token=/key=/secret=/api_key=/access_token=",
542
+ regex: /(?<![A-Za-z0-9_])(?:access_token|api_key|apikey|token|key|secret|ACCESS_TOKEN|API_KEY|APIKEY|TOKEN|KEY|SECRET)=(?=[A-Za-z0-9._~+\/-]*\d)(?=[A-Za-z0-9._~+\/-]*[A-Za-z])(?=[A-Za-z0-9._~+\/-]*[A-Z]|(?:[A-Za-z0-9._~+\/-]*\d){4})[A-Za-z0-9._~+\/-]{24,}={0,2}(?![A-Za-z0-9._~+\/-])/g
543
+ },
544
+ {
545
+ // Discord bot token — three base64url segments. Anchored both sides so it
546
+ // can't partial-match a longer dotted string.
547
+ label: "discord-bot-token",
548
+ description: "Discord bot token (3 base64url segments)",
549
+ regex: /(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{24,26}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,40}(?![A-Za-z0-9_-])/g
550
+ },
551
+ {
552
+ label: "discord-mfa-token",
553
+ description: "Discord MFA token (mfa. + 84 chars)",
554
+ regex: /\bmfa\.[A-Za-z0-9_-]{84}\b/g
555
+ },
556
+ {
557
+ // Cloudflare Turnstile PROD secret (sanne, verified 2/2) — 0x4 + 6×A prefix,
558
+ // then 26 base64url (35 total). The 24-char SITE key + 1x/2x/3x TEST keys are
559
+ // intentionally NOT matched (the {26} length gate misses them) so a public
560
+ // key is never redacted.
561
+ label: "cloudflare-turnstile-secret",
562
+ description: "Cloudflare Turnstile secret key (0x4AAAAAA + 26 base64url, 35 total)",
563
+ regex: /0x4AAAAAA[A-Za-z0-9_-]{26}(?![A-Za-z0-9_-])/g
564
+ },
565
+ {
566
+ label: "cloudflare-global-key",
567
+ description: "Cloudflare global API key (37-hex)",
568
+ regex: /\b[0-9a-f]{37}\b/g
569
+ },
570
+ {
571
+ // LAST on purpose: this is the only unprefixed shape in the list, so every
572
+ // anchored pattern above must get first refusal.
573
+ //
574
+ // Philips Hue v2 application key — 40 chars of [A-Za-z0-9-] with NO prefix,
575
+ // so there is nothing to anchor on. The negative lookahead is load-bearing,
576
+ // not decoration: a bare [A-Za-z0-9-]{40} also matches a GIT COMMIT SHA, and
577
+ // telemetry/error output is full of those. A redactor that eats commit
578
+ // hashes gets switched off within a week, after which it protects nothing.
579
+ //
580
+ // F035.10 — THAT SENTENCE CAME TRUE ABOUT THIS VERY PATTERN. It was the only
581
+ // prefix-less pattern here matching on ENTROPY ALONE, and base64 is
582
+ // mixed-case alphanumeric, so it fired inside npm integrity digests:
583
+ //
584
+ // resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/…}
585
+ // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ matched
586
+ //
587
+ // 33 hits in components' own pnpm-lock.yaml, 32 of them digests. (`-` is in
588
+ // the class but is not a word character, so \b anchors happily mid-digest.)
589
+ // Reported by trail, whose gate then could not commit a lockfile change —
590
+ // i.e. no dependency update at all. A gate nobody can satisfy is a gate
591
+ // someone switches off, which costs more than the hole it closed.
592
+ //
593
+ // It is now CONTEXT-ONLY like every other prefix-less secret in this file
594
+ // (cloudflare-api-token, mistral-api-key, vimeo-access-token,
595
+ // labeled-hex-secret): the field name is the signal, not the randomness.
596
+ // Deliberately given up: a bare 40-char Hue key in prose with no field name.
597
+ // See the README's "Deliberately NOT detected" — do not remove the anchor to
598
+ // "fix" that; a pattern that cannot tell a key from a checksum is worse than
599
+ // no pattern. (Original pattern contributed by beacon, F035.7.)
600
+ label: "hue-application-key",
601
+ description: "Philips Hue application key (hue/bridge-named field + 40 chars)",
602
+ // The `["'`]?` BEFORE the separator is not decoration: a Hue key most often
603
+ // arrives as JSON — {"hue_application_key": "…"} — and the four older
604
+ // context-only patterns in this file all omit it, so they miss the quoted
605
+ // form. Noted rather than silently changed there; that is its own card.
606
+ regex: /\b(?:hue|bridge)[_-]?(?:application[_-]?key|username|user|key)\b["'`]?\s*[:=]\s*["'`]?[A-Za-z0-9-]{40}(?![A-Za-z0-9-])/gi
607
+ }
608
+ ];
609
+ var HUE_KEY_BODY = String.raw`(?=[A-Za-z0-9-]*[a-z])(?=[A-Za-z0-9-]*[A-Z])[A-Za-z0-9-]{40}`;
610
+ var VALUE_ONLY = [
611
+ {
612
+ label: "hue-application-key",
613
+ description: "Philips Hue application key (40 chars, no prefix)",
614
+ // Anchored: `classify` is handed ONE value and asks what it is.
615
+ regex: new RegExp(String.raw`^(?=[A-Za-z0-9-]{40}$)${HUE_KEY_BODY}$`)
616
+ }
617
+ ];
618
+ var withoutGlobal = (list) => Object.freeze(
619
+ list.map(
620
+ (p) => Object.freeze({ ...p, regex: new RegExp(p.regex.source, p.regex.flags.replace("g", "")) })
621
+ )
622
+ );
623
+ withoutGlobal(PATTERNS);
624
+ withoutGlobal(VALUE_ONLY);
625
+ var SECRETS_NOT_EXAMINED = ["personal-data"];
626
+ var redactionMarker = (label) => `[REDACTED:${label}]`;
627
+ function patternsFor(opts) {
628
+ return opts?.extraPatterns && opts.extraPatterns.length > 0 ? [...PATTERNS, ...opts.extraPatterns] : PATTERNS;
629
+ }
630
+ function redactSecrets(text, opts) {
631
+ const scanned = opts?.announced ? ["format", "announced"] : ["format"];
632
+ const notExamined = SECRETS_NOT_EXAMINED;
633
+ if (!text) return { redacted: text, findings: [], scanned, notExamined };
634
+ let redacted = text;
635
+ const findings = [];
636
+ for (const p of patternsFor(opts)) {
637
+ let count = 0;
638
+ redacted = redacted.replace(p.regex, () => {
639
+ count++;
640
+ return redactionMarker(p.label);
641
+ });
642
+ if (count > 0) findings.push({ label: p.label, count, confidence: "format" });
643
+ }
644
+ return { redacted, findings, scanned, notExamined };
645
+ }
646
+
647
+ // src/cli.ts
648
+ function scanText(text) {
649
+ const hits = [];
650
+ const perLine = /* @__PURE__ */ new Map();
651
+ text.split("\n").forEach((line, i) => {
652
+ for (const f of redactSecrets(line).findings) {
653
+ hits.push({ label: f.label, line: i + 1 });
654
+ perLine.set(f.label, (perLine.get(f.label) ?? 0) + f.count);
655
+ }
656
+ });
657
+ for (const f of redactSecrets(text).findings) {
658
+ if ((perLine.get(f.label) ?? 0) < f.count) hits.push({ label: f.label, line: null });
659
+ }
660
+ return hits;
661
+ }
662
+ async function main(argv) {
663
+ if (!argv.includes("--stdin")) {
664
+ process.stderr.write("usage: secret-scan --stdin (reads text on stdin; exit 1 = secret found, 0 = none, 2 = error)\n");
665
+ return 2;
666
+ }
667
+ const chunks = [];
668
+ for await (const c of process.stdin) chunks.push(c);
669
+ const hits = scanText(Buffer.concat(chunks).toString("utf8"));
670
+ for (const h of hits) process.stderr.write(`secret-scan: ${h.label} ${h.line === null ? "(spans several lines)" : `line ${h.line}`}
671
+ `);
672
+ if (hits.length) process.stderr.write(`secret-scan: ${hits.length} finding(s) \u2014 values not shown. Remove the secret; if it was ever committed or pushed, ROTATE it.
673
+ `);
674
+ return hits.length ? 1 : 0;
675
+ }
676
+ var isMain = process.argv[1] && /secret-scan|cli\.(c?js|ts)$/.test(process.argv[1]);
677
+ if (isMain) {
678
+ main(process.argv.slice(2)).then(
679
+ (code) => process.exit(code),
680
+ (e) => {
681
+ process.stderr.write(`secret-scan: could not scan: ${e instanceof Error ? e.message : String(e)}
682
+ `);
683
+ process.exit(2);
684
+ }
685
+ );
686
+ }
687
+
688
+ export { scanText };
689
+ //# sourceMappingURL=cli.js.map
690
+ //# sourceMappingURL=cli.js.map