@broberg/secret-scan 0.15.0 → 0.16.1

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