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