@broberg/secret-scan 0.14.3 → 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/README.md +28 -1
- package/dist/cli.cjs +690 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +7 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +688 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +34 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +25 -1
- package/dist/index.d.ts +25 -1
- package/dist/index.js +33 -7
- package/dist/index.js.map +1 -1
- package/package.json +5 -1
package/dist/cli.cjs
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
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
|
+
regex: /sk-(?: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
|
+
exports.scanText = scanText;
|
|
689
|
+
//# sourceMappingURL=cli.cjs.map
|
|
690
|
+
//# sourceMappingURL=cli.cjs.map
|