@broberg/secret-scan 0.4.0 → 0.5.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 +62 -0
- package/dist/index.cjs +38 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +38 -4
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -189,6 +189,35 @@ value includes their `NAME=value` context — a bare provider token classifies v
|
|
|
189
189
|
its prefix, and a prefix-less bare token (e.g. a raw Mistral key) is genuinely
|
|
190
190
|
unidentifiable and returns `null`.
|
|
191
191
|
|
|
192
|
+
### A `null` has two meanings — check the length floor first
|
|
193
|
+
|
|
194
|
+
Every token pattern carries a **minimum length** (typically `{20,}`). So a
|
|
195
|
+
`null` from `classify()` means **either** of two things, and they are
|
|
196
|
+
indistinguishable from the return value alone:
|
|
197
|
+
|
|
198
|
+
1. no pattern matches this string; **or**
|
|
199
|
+
2. a pattern exists, but your sample was **shorter than its floor**.
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
classify("rk_live_51ABCdef"); // → null (8 chars after the prefix — under the floor)
|
|
203
|
+
classify("rk_live_51" + "A".repeat(90)); // → { label: "stripe-secret-key", … }
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
**Before you report a missing pattern, re-probe with a realistic-length value.**
|
|
207
|
+
Two gap reports in two days were both this, and both were withdrawn: a short
|
|
208
|
+
sample measured the *floor* and was read as missing *coverage*.
|
|
209
|
+
|
|
210
|
+
The floor is deliberate and load-bearing. Without it the literal string
|
|
211
|
+
`sk_live_` in prose, a doc or a code comment would be flagged — and a redactor
|
|
212
|
+
that fires on prose gets switched off within a week, after which it protects
|
|
213
|
+
nothing. Do not lower it.
|
|
214
|
+
|
|
215
|
+
**And measure the version you actually run.** Patterns are added over time
|
|
216
|
+
(`whsec_` landed in **0.4.0**), and a caret on a `0.x` version locks the minor —
|
|
217
|
+
`^0.1.7` can never resolve `0.4.0`, so a consumer never picks these up by
|
|
218
|
+
itself. Check the **installed** version, not the source tree and not the roster,
|
|
219
|
+
before concluding a pattern is absent.
|
|
220
|
+
|
|
192
221
|
## Two recommended integration shapes
|
|
193
222
|
|
|
194
223
|
1. **Write boundary (ingest gate)** — redact before you persist, so secrets never
|
|
@@ -230,6 +259,39 @@ regexes — most-specific first so attribution is correct:
|
|
|
230
259
|
- **Field-anchored (context-only, to avoid FP on bare tokens):** Cloudflare API
|
|
231
260
|
token, Mistral, Vimeo — matched only next to their env-var name.
|
|
232
261
|
|
|
262
|
+
### Deliberately NOT detected
|
|
263
|
+
|
|
264
|
+
- **Stripe publishable keys (`pk_live_` / `pk_test_`).** These are *publishable*
|
|
265
|
+
by design — they ship in browser bundles and in setup instructions. They are
|
|
266
|
+
not a leak risk, and redacting one would corrupt copy-pasted setup docs for no
|
|
267
|
+
gain. This is a **decided scope boundary, not a gap**: a secret-scanner that
|
|
268
|
+
masks a public value trains people to ignore it. Requested and declined,
|
|
269
|
+
2026-08-25.
|
|
270
|
+
|
|
271
|
+
- **A bare Hue application key sitting in free text** (40 mixed-case chars with
|
|
272
|
+
no field name near it). `hue-application-key` is **context-only**: it fires on
|
|
273
|
+
a `hue`/`bridge`-named field, not on the shape alone.
|
|
274
|
+
|
|
275
|
+
This is the same trade as above, and it was paid for. Until 0.5.1 the pattern
|
|
276
|
+
matched on shape, and the shape of a Hue key is also the shape of a
|
|
277
|
+
40-character window inside an npm integrity digest:
|
|
278
|
+
|
|
279
|
+
```
|
|
280
|
+
resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/…}
|
|
281
|
+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ matched
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
33 hits in one real `pnpm-lock.yaml`. Any repo running this package as a
|
|
285
|
+
pre-commit gate could no longer commit a lockfile change — so no dependency
|
|
286
|
+
update at all. **A gate nobody can satisfy is a gate someone switches off**,
|
|
287
|
+
and then the other 38 patterns protect nothing either. Do not remove the
|
|
288
|
+
anchor to "improve coverage": that is the change that breaks everything.
|
|
289
|
+
|
|
290
|
+
**`classify()` is unaffected** and still names a bare key. It answers a
|
|
291
|
+
different question — its caller has already said "this is a secret, what
|
|
292
|
+
kind?", so there is no surrounding text to corrupt and no checksum to confuse
|
|
293
|
+
it with. Same value, two questions, two evidence bars.
|
|
294
|
+
|
|
233
295
|
### Design notes
|
|
234
296
|
|
|
235
297
|
- **Pattern-based, not entropy** — a redacted *real* fact corrupts knowledge, so
|
package/dist/index.cjs
CHANGED
|
@@ -261,11 +261,41 @@ var SECRET_PATTERNS = [
|
|
|
261
261
|
// not decoration: a bare [A-Za-z0-9-]{40} also matches a GIT COMMIT SHA, and
|
|
262
262
|
// telemetry/error output is full of those. A redactor that eats commit
|
|
263
263
|
// hashes gets switched off within a week, after which it protects nothing.
|
|
264
|
-
//
|
|
265
|
-
//
|
|
264
|
+
//
|
|
265
|
+
// F035.10 — THAT SENTENCE CAME TRUE ABOUT THIS VERY PATTERN. It was the only
|
|
266
|
+
// prefix-less pattern here matching on ENTROPY ALONE, and base64 is
|
|
267
|
+
// mixed-case alphanumeric, so it fired inside npm integrity digests:
|
|
268
|
+
//
|
|
269
|
+
// resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/…}
|
|
270
|
+
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ matched
|
|
271
|
+
//
|
|
272
|
+
// 33 hits in components' own pnpm-lock.yaml, 32 of them digests. (`-` is in
|
|
273
|
+
// the class but is not a word character, so \b anchors happily mid-digest.)
|
|
274
|
+
// Reported by trail, whose gate then could not commit a lockfile change —
|
|
275
|
+
// i.e. no dependency update at all. A gate nobody can satisfy is a gate
|
|
276
|
+
// someone switches off, which costs more than the hole it closed.
|
|
277
|
+
//
|
|
278
|
+
// It is now CONTEXT-ONLY like every other prefix-less secret in this file
|
|
279
|
+
// (cloudflare-api-token, mistral-api-key, vimeo-access-token,
|
|
280
|
+
// labeled-hex-secret): the field name is the signal, not the randomness.
|
|
281
|
+
// Deliberately given up: a bare 40-char Hue key in prose with no field name.
|
|
282
|
+
// See the README's "Deliberately NOT detected" — do not remove the anchor to
|
|
283
|
+
// "fix" that; a pattern that cannot tell a key from a checksum is worse than
|
|
284
|
+
// no pattern. (Original pattern contributed by beacon, F035.7.)
|
|
266
285
|
label: "hue-application-key",
|
|
267
|
-
description: "Philips Hue
|
|
268
|
-
|
|
286
|
+
description: "Philips Hue application key (hue/bridge-named field + 40 chars)",
|
|
287
|
+
// The `["'`]?` BEFORE the separator is not decoration: a Hue key most often
|
|
288
|
+
// arrives as JSON — {"hue_application_key": "…"} — and the four older
|
|
289
|
+
// context-only patterns in this file all omit it, so they miss the quoted
|
|
290
|
+
// form. Noted rather than silently changed there; that is its own card.
|
|
291
|
+
regex: /\b(?:hue|bridge)[_-]?(?:application[_-]?key|username|user|key)\b["'`]?\s*[:=]\s*["'`]?[A-Za-z0-9-]{40}(?![A-Za-z0-9-])/gi
|
|
292
|
+
}
|
|
293
|
+
];
|
|
294
|
+
var VALUE_ONLY_PATTERNS = [
|
|
295
|
+
{
|
|
296
|
+
label: "hue-application-key",
|
|
297
|
+
description: "Philips Hue application key (40 chars, no prefix)",
|
|
298
|
+
regex: /^(?=[A-Za-z0-9-]{40}$)(?=[A-Za-z0-9-]*[a-z])(?=[A-Za-z0-9-]*[A-Z])[A-Za-z0-9-]{40}$/
|
|
269
299
|
}
|
|
270
300
|
];
|
|
271
301
|
var ANNOUNCED_LABEL = "announced-secret";
|
|
@@ -320,6 +350,10 @@ function classify(value, opts) {
|
|
|
320
350
|
p.regex.lastIndex = 0;
|
|
321
351
|
if (p.regex.test(v)) return { label: p.label, description: p.description };
|
|
322
352
|
}
|
|
353
|
+
for (const p of VALUE_ONLY_PATTERNS) {
|
|
354
|
+
p.regex.lastIndex = 0;
|
|
355
|
+
if (p.regex.test(v)) return { label: p.label, description: p.description };
|
|
356
|
+
}
|
|
323
357
|
return null;
|
|
324
358
|
}
|
|
325
359
|
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @broberg/secret-scan — fleet secret/credential redaction.\n *\n * `redactSecrets(text)` replaces every matched secret with `[REDACTED:<label>]`\n * and reports what it found. PURE + deterministic (regex/string only, no deps,\n * no I/O) so an engine write-gate, an egress scrub, a CLI, an admin preview UI,\n * and any repo all share the EXACT same detection — and it's trivially testable.\n *\n * Lifted verbatim from broberg/trail F197 (the second-brain safeguard); see\n * docs/features/F035-secret-scan.md. components owns + publishes this; @trail/shared\n * re-exports it.\n *\n * Design choices:\n * - Pattern-based, NOT entropy/generic-randomness — a redacted real fact would\n * corrupt knowledge, so we accept missing an exotic token over false positives.\n * - Order matters: most-specific patterns run first (e.g. `sk-ant-` before the\n * generic OpenAI `sk-`; `sk-or-v1-` before `sk-`), because each match is\n * consumed before the next pattern runs → order = attribution.\n * - Redact, never reject — the surrounding knowledge survives; only the\n * credential substring is neutralised.\n * - NEVER a bare high-entropy/hex pattern (it would hit git shas/hashes).\n * Prefix-less service secrets are caught only via `labeled-hex-secret` (a 40+\n * hex value assigned to a secret/token/password/api-key-named field).\n *\n * Two recommended integration shapes for consumers:\n * (a) write boundary — `redactSecrets(text)` before persist (ingest gate);\n * (b) egress — scrub before a value leaves to a user/LLM (highest-value guard).\n */\n\nexport interface SecretPattern {\n /** stable id shown in the redaction marker + findings */\n label: string;\n /** human description of what this matches */\n description: string;\n /** global regex (used for replace-all + counting) */\n regex: RegExp;\n}\n\n/** Ordered most-specific → least. Every regex carries the `g` flag. */\nexport const SECRET_PATTERNS: SecretPattern[] = [\n {\n label: 'private-key',\n description: 'PEM private key block (RSA/EC/OPENSSH/DSA/PGP)',\n regex:\n /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\\s\\S]*?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g,\n },\n {\n label: 'anthropic-api-key',\n description: 'Anthropic API key (sk-ant-…)',\n regex: /sk-ant-(?:api03-)?[A-Za-z0-9_-]{20,}/g,\n },\n {\n // OpenRouter — distinct from OpenAI; runs BEFORE the generic sk- (which would\n // otherwise also match + mislabel it).\n label: 'openrouter-api-key',\n description: 'OpenRouter API key (sk-or-v1- + 64 hex)',\n regex: /\\bsk-or-v1-[0-9a-f]{64}/g,\n },\n {\n // DeepSeek — shares the sk- prefix with OpenAI, so it MUST run before the\n // generic openai pattern (specific-before-generic = correct attribution).\n // DeepSeek's documented shape is sk- + 32 lowercase hex (GitGuardian confirms\n // an sk- prefix but hides the exact regex); the hex-only body + {32,} length\n // distinguishes it from OpenAI's mixed-case base62 keys, so a real OpenAI key\n // is never mislabelled. The field-anchored fallback below catches any\n // DEEPSEEK_API_KEY value that doesn't fit this canonical shape.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (sk- + 32 lowercase hex)',\n regex: /\\bsk-[0-9a-f]{32,}(?![0-9a-z])/g,\n },\n {\n label: 'openai-api-key',\n description: 'OpenAI API key (sk-… / sk-proj-…)',\n regex: /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/g,\n },\n {\n // ElevenLabs — sk_ with UNDERSCORE (vs OpenAI sk-), 48 hex.\n label: 'elevenlabs-api-key',\n description: 'ElevenLabs API key (sk_ + 48 hex)',\n regex: /\\bsk_[0-9a-f]{48}\\b/g,\n },\n {\n // fal.ai — uuid:hex32 (key_id:key_secret); the colon is the signal.\n label: 'fal-api-key',\n description: 'fal.ai key (uuid:hex32)',\n 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,\n },\n {\n // Black Forest Labs (FLUX) API key — bfl_ prefix + a long token (sample\n // bfl_Qo1…). The distinctive prefix + {20,} length keeps false positives near\n // zero; image-provider sibling of the fal key above.\n label: 'bfl-api-key',\n description: 'Black Forest Labs / FLUX API key (bfl_ + token)',\n regex: /\\bbfl_[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'google-api-key',\n description: 'Google / Gemini API key (AIza…)',\n regex: /AIza[0-9A-Za-z_-]{35}/g,\n },\n {\n label: 'google-oauth-secret',\n description: 'Google OAuth client secret (GOCSPX-…)',\n regex: /GOCSPX-[A-Za-z0-9_-]{28}/g,\n },\n {\n label: 'aws-access-key-id',\n description: 'AWS access key id (AKIA…)',\n regex: /\\bAKIA[0-9A-Z]{16}\\b/g,\n },\n {\n label: 'github-token',\n description: 'GitHub token (ghp_/gho_/ghs_/ghu_/ghr_…)',\n regex: /\\bgh[posru]_[A-Za-z0-9]{36,}\\b/g,\n },\n {\n // GitHub fine-grained PAT — distinct prefix `github_pat_` (not caught by the\n // classic gh[posru]_ above), then base62 + a `_` separator (~82 chars total).\n // The prefix is so distinctive that {50,} keeps false positives at zero.\n label: 'github-fine-grained-pat',\n description: 'GitHub fine-grained personal access token (github_pat_…)',\n regex: /\\bgithub_pat_[A-Za-z0-9_]{50,}/g,\n },\n {\n label: 'gitlab-token',\n description: 'GitLab personal access token (glpat-…)',\n regex: /\\bglpat-[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'slack-token',\n description: 'Slack token (xox[baprs]-…)',\n regex: /\\bxox[baprs]-[A-Za-z0-9-]{10,}/g,\n },\n {\n label: 'stripe-secret-key',\n description: 'Stripe live secret/restricted key (sk_live_/rk_live_…)',\n regex: /\\b[rs]k_live_[A-Za-z0-9]{20,}/g,\n },\n {\n // Filed by buddy, who found REAL ones sitting in plaintext in their own\n // transcription DB. Their scrub deliberately runs the format axis ONLY, so a\n // prefixed secret we do not match is a secret nobody catches — a precise\n // prefix is the only route that helps them. Zero false-positive risk: the\n // literal `whsec_` does not occur by accident.\n label: 'stripe-webhook-secret',\n description: 'Stripe webhook signing secret (whsec_…)',\n regex: /\\bwhsec_[A-Za-z0-9+/=_-]{20,}/g,\n },\n {\n // Resend (re_…). Lookahead requires a digit in the body so we don't redact\n // long snake_case identifiers like re_compute_the_thing.\n label: 'resend-api-key',\n description: 'Resend API key (re_ + token)',\n regex: /\\bre_(?=[A-Za-z0-9_]*\\d)[A-Za-z0-9_]{24,}\\b/g,\n },\n {\n label: 'supabase-access-token',\n description: 'Supabase personal/management access token (sbp_ + 40 hex)',\n regex: /\\bsbp_[0-9a-f]{40}/g,\n },\n {\n label: 'supabase-secret-key',\n description: 'Supabase secret API key (sb_secret_…)',\n regex: /\\bsb_secret_[A-Za-z0-9_-]{20,}/g,\n },\n {\n // Used by every @broberg/* publish — the highest-value leak from a .env / commit history.\n label: 'npm-token',\n description: 'npm publish/automation token (npm_ + 36 base62)',\n regex: /\\bnpm_[A-Za-z0-9]{36}\\b/g,\n },\n {\n label: 'fly-api-token',\n description: 'Fly.io API token (FlyV1 fm2_… / fo1_…)',\n regex: /(?:FlyV1 fm2_[A-Za-z0-9+/=_-]{20,}|\\bfo1_[A-Za-z0-9_-]{20,})/g,\n },\n {\n // Also covers Turso DB/platform auth tokens AND Supabase anon/service_role\n // keys — both are JWTs (eyJ…), so the single JWT pattern catches them.\n label: 'jwt',\n description: 'JSON Web Token (eyJ…) — incl. Turso + Supabase service_role tokens',\n regex: /\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}/g,\n },\n {\n // genApiKey = randomBytes(24).hex → uk_ + exactly 48 lowercase hex.\n label: 'upmetrics-key',\n description: 'Upmetrics project key (uk_ + 48 hex)',\n regex: /\\buk_[0-9a-f]{48}/g,\n },\n {\n label: 'cardmem-key',\n description: 'Cardmem personal/incident/project key (pa_/pi_/pk_ + 64 hex)',\n regex: /\\bp[aik]_[A-Za-z0-9]{20,}/g,\n },\n {\n // cardmem inbox-webhook key — piw_ isn't matched by p[aik]_ above (3rd char 'w' ≠ '_').\n label: 'cardmem-webhook-key',\n description: 'Cardmem inbox-webhook key (piw_ + 64 hex)',\n regex: /\\bpiw_[0-9a-f]{64}/g,\n },\n {\n label: 'trail-key',\n description: 'Trail personal API key (trail_…)',\n regex: /\\btrail_[A-Za-z0-9]{20,}/g,\n },\n {\n // Cronjobs API key (cronjobs.webhouse.net) — cj_ + randomBytes(32).base64url =\n // exactly 43 base64url chars (46 total). Prefix + fixed length = very low FP.\n // The UI's truncated cj_<8 chars>… preview is shorter than {43} → not matched.\n // Negative lookahead (not \\b) because base64url's `-` breaks a trailing \\b.\n label: 'cronjobs-api-key',\n description: 'Cronjobs API key (cj_ + 43 base64url)',\n regex: /\\bcj_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g,\n },\n {\n // randomBytes(32).hex → wh_ + 64 lowercase hex (67 chars total).\n label: 'cms-access-token',\n description: 'webhouse.app CMS access token (wh_ + 64 hex)',\n regex: /\\bwh_[0-9a-f]{64}/g,\n },\n {\n // Cloudflare API token (R2 / DNS management) — 40 base64url chars, NO prefix.\n // A bare {40} would false-positive broadly, so this is CONTEXT-ONLY: it only\n // fires next to a cf/cloudflare-api-token-named field. Runs before\n // labeled-hex-secret so a hex-valued CF token is attributed correctly.\n label: 'cloudflare-api-token',\n description: 'Cloudflare API token (cf/cloudflare-api-token field + 40 base64url)',\n regex: /\\b(?:cf|cloudflare)_?api_?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{40}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Mistral API key — prefix-less ~32 base62 (Christian-confirmed sample). A bare\n // [A-Za-z0-9]{32} would FP on every ID/hash, so CONTEXT-ONLY: anchored on a\n // mistral-(api-)key/token-named field. Runs before labeled-hex for attribution.\n label: 'mistral-api-key',\n description: 'Mistral API key (mistral-(api-)key/token field + 24+ base62)',\n regex: /\\bmistral(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{24,}(?![A-Za-z0-9])/gi,\n },\n {\n // DeepSeek — field-anchored fallback for any DEEPSEEK_API_KEY/TOKEN value that\n // doesn't fit the canonical sk-+hex shape (mirrors the Mistral context-only\n // approach). The field name is the signal → near-zero false positives. The\n // sk-+hex format pattern above already attributes the canonical shape; this\n // backstops a format change or an opaque token.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (deepseek-(api-)key/token field + 20+ token)',\n regex: /\\bdeepseek(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Vimeo personal access token — ~32 lowercase hex, no prefix (sanne). A bare\n // hex32 would FP massively (MD5/UUID), so CONTEXT-ONLY: anchored on a\n // vimeo-(access-)token-named field.\n label: 'vimeo-access-token',\n description: 'Vimeo access token (vimeo-(access-)token field + 20+ base62)',\n regex: /\\bvimeo(?:[_-]?access)?[_-]?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{20,}(?![A-Za-z0-9])/gi,\n },\n {\n // Context-based catch for prefix-less high-entropy service secrets\n // (CMS_JWT_SECRET, revalidateSecret, fleet openssl-rand-hex secrets): a 40+\n // hex value assigned to a field whose name contains\n // secret/token/password/api-key. The name requirement keeps the\n // false-positive rate near zero (a bare 40/64-hex would hit shas/hashes).\n label: 'labeled-hex-secret',\n description: 'A 40+ hex value assigned to a secret/token/password/api-key-named field',\n regex: /\\b[A-Za-z0-9_-]*(?:secret|token|password|api[_-]?key)\\b\\s*[:=]\\s*[\"'`]?[0-9a-f]{40,}/gi,\n },\n {\n // Discord bot token — three base64url segments. Anchored both sides so it\n // can't partial-match a longer dotted string.\n label: 'discord-bot-token',\n description: 'Discord bot token (3 base64url segments)',\n 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,\n },\n {\n label: 'discord-mfa-token',\n description: 'Discord MFA token (mfa. + 84 chars)',\n regex: /\\bmfa\\.[A-Za-z0-9_-]{84}\\b/g,\n },\n {\n // Cloudflare Turnstile PROD secret (sanne, verified 2/2) — 0x4 + 6×A prefix,\n // then 26 base64url (35 total). The 24-char SITE key + 1x/2x/3x TEST keys are\n // intentionally NOT matched (the {26} length gate misses them) so a public\n // key is never redacted.\n label: 'cloudflare-turnstile-secret',\n description: 'Cloudflare Turnstile secret key (0x4AAAAAA + 26 base64url, 35 total)',\n regex: /0x4AAAAAA[A-Za-z0-9_-]{26}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'cloudflare-global-key',\n description: 'Cloudflare global API key (37-hex)',\n regex: /\\b[0-9a-f]{37}\\b/g,\n },\n {\n // LAST on purpose: this is the only unprefixed shape in the list, so every\n // anchored pattern above must get first refusal.\n //\n // Philips Hue v2 application key — 40 chars of [A-Za-z0-9-] with NO prefix,\n // so there is nothing to anchor on. The negative lookahead is load-bearing,\n // not decoration: a bare [A-Za-z0-9-]{40} also matches a GIT COMMIT SHA, and\n // telemetry/error output is full of those. A redactor that eats commit\n // hashes gets switched off within a week, after which it protects nothing.\n // Hue keys are mixed-case; SHAs are lowercase hex — that asymmetry is the\n // whole guard. (Pattern contributed + field-tested by beacon, F035.7.)\n label: 'hue-application-key',\n description: 'Philips Hue v2 application key (40 chars, no prefix)',\n regex: /\\b(?![0-9a-f]{40}\\b)[A-Za-z0-9-]{40}\\b/g,\n },\n];\n\n/**\n * WHY a finding was flagged — the two detection axes this package has.\n *\n * `format` the VALUE carries the signal: `sk-ant-…`, `ghp_…`, `AKIA…`. Shape\n * alone identifies it, so it is safe to run on anything.\n * `announced` the LABEL carries the signal: `Adgangskode: hunter2`. The value is\n * arbitrary human text with no shape to match, so the only evidence\n * is that someone wrote the word \"password\" next to it.\n */\nexport type SecretConfidence = 'format' | 'announced';\n\nexport interface RedactionFinding {\n label: string;\n count: number;\n /** which axis matched — see SecretConfidence. */\n confidence: SecretConfidence;\n}\n\nexport interface RedactionResult {\n /** input with every secret replaced by `[REDACTED:<label>]` */\n redacted: string;\n /** per-pattern counts of what was redacted (empty = nothing found ON THE AXES IN `scanned`) */\n findings: RedactionFinding[];\n /**\n * Which axes this call actually EXAMINED — always `['format']`, plus\n * `'announced'` when `opts.announced` was set.\n *\n * It exists because `findings: []` alone cannot tell you which question was\n * asked. `redactSecrets(\"Adgangskode: hunter2\")` and `redactSecrets(\"hello\")`\n * both return an empty `findings`, and until 0.3.0 nothing in the return value\n * distinguished \"we found nothing\" from \"we never looked there\".\n *\n * A caller that must be sure can now ASSERT rather than trust the docs:\n *\n * ```ts\n * const r = redactSecrets(body, { announced: true });\n * if (!r.scanned.includes('announced')) throw new Error('announced axis not scanned');\n * ```\n *\n * Note the honest limit: this does not PREVENT the mistake — someone who\n * forgets the flag can equally forget to check this. It makes the mistake\n * *detectable* instead of merely documented, which is the difference between a\n * check and an agreement. Filed by buddy, who had just declined the same\n * \"we'll agree to label things\" fix from another session on the grounds that\n * an agreement holds only until the first person forgets it, and said it would\n * be cheap to use that argument in one direction and not the other.\n */\n scanned: readonly SecretConfidence[];\n}\n\nexport interface RedactOptions {\n /**\n * Extra consumer/per-tenant patterns, run AFTER the canonical set (so canonical\n * attribution wins). Backs a future self-service \"paste a key → detector\" UI.\n */\n extraPatterns?: SecretPattern[];\n /**\n * Also detect ANNOUNCED secrets — `Adgangskode: hunter2` — where the label is\n * the only evidence. **Off by default, and it must stay that way.** See\n * ANNOUNCED_LABEL for the measurement that decided it.\n */\n announced?: boolean;\n}\n\n/** Marker label for a secret detected by its announcing label rather than shape. */\nexport const ANNOUNCED_LABEL = 'announced-secret';\n\n/**\n * Label + separator + value. The label list is deliberately short and concrete;\n * this is not a general \"looks like config\" detector.\n *\n * WHY THIS IS OPT-IN, MEASURED RATHER THAN GUESSED. Over this repo on\n * 2026-08-14 — 548 tracked files, 544 readable as text, containing essentially\n * no real secrets — this exact regex matched **97 times**, and every one was\n * noise. Per label: `secret` 61, `api key` 33, `password` 4, and every Danish\n * label 0. So 94 of the 97 are the two words that are also ordinary IDENTIFIERS\n * in source code (`secret: config.secret`, `apiKey: Record<…>`).\n *\n * That is the real finding, and it is sharper than \"the pattern is noisy\": its\n * precision depends entirely on WHAT IS BEING SCANNED. In an inbound mail body\n * — buddy's actual case — `Adgangskode:` is a strong signal. In a TypeScript\n * file it is a variable name. **The package cannot know which corpus it is\n * looking at; only the caller can.** So the caller makes the decision, and the\n * default cannot be on. (This is the opposite of this repo's usual defaults-ON\n * stance — webpush F067.1, lens-engine F065 — and the numbers above are why.)\n *\n * A broader label+separator+value pattern measured 305 on the same corpus, and\n * refining it only reached 202 — no amount of tuning makes a generic version\n * safe. A template/env-reference guard (`${FOO}`, `<your-key>`) was written and\n * then dropped: it changed the count by exactly 0, because the noise here is\n * identifiers, not templates.\n *\n * The value must not already be a redaction marker, so this can run AFTER the\n * format pass without flattening its more specific attribution.\n *\n * NOT IN THE LIST, AND DELIBERATELY (v0.4.0): bare `kode`. **In Danish, `kode`\n * mostly means SOURCE CODE** — the credential words are `kodeord` and\n * `adgangskode`, both still matched. Until 0.4.0 the bare form was included and\n * fired on ordinary technical prose; measured by buddy over 82,662 lines of real\n * Danish transcription: \"Det er min kode: se linje 40\" · \"Kode: const x = 1\" ·\n * \"Merge-kode: konflikten er løst\" · \"QR-kode: scan den\".\n *\n * And the behaviour was ARBITRARY, which is the part that settled it: `\\b` meant\n * `Landekode:` / `Postkode:` / `Fejlkode:` never matched (no word boundary inside\n * the word) while `QR-kode:` did (a hyphen IS one). Whether a compound was\n * flagged came down to whether someone happened to type a hyphen.\n *\n * THE COST, stated rather than hidden: `Her er min kode: hunter2` is no longer\n * detected, and that is a real Danish way to announce a password. Deliberate — a\n * token that means \"source code\" half the time is noise in every corpus, not\n * just buddy's. If you need it back, file it; do not re-add it locally.\n */\nconst ANNOUNCED_SECRET =\n /(\\b(?:adgangskode|kodeord|hemmelighed|password|passwd|api[ -]?key|apinøgle|secret|pwd)\\s*[:=]\\s*)(?!\\[REDACTED:)\\S+/gi;\n\n\n/** Replacement marker for a redacted secret. */\nexport const redactionMarker = (label: string): string => `[REDACTED:${label}]`;\n\nfunction patternsFor(opts?: RedactOptions): SecretPattern[] {\n return opts?.extraPatterns && opts.extraPatterns.length > 0\n ? [...SECRET_PATTERNS, ...opts.extraPatterns]\n : SECRET_PATTERNS;\n}\n\n/**\n * Scan `text` and replace every detected secret with its redaction marker.\n * Pure: clean input returns byte-identical (`findings: []`).\n *\n * ⚠️ **This does NOT catch an announced secret unless you pass\n * `{ announced: true }`.** `redactSecrets(\"Adgangskode: hunter2\")` returns the\n * password untouched with `findings: []` — which is indistinguishable from\n * \"this text is clean\", because the announced axis was never examined.\n *\n * The two axes are separate and only one is on by default (see\n * SecretConfidence). If you are gating untrusted inbound text, reach for\n * `hasAnnouncedSecret()` — or pass the flag. Do not assume an empty `findings`\n * means safe.\n *\n * Filed by buddy, who nearly reported this package as behaving wrongly: their\n * probe used the defaults and so could not see the axis they were testing. The\n * behaviour is right; the NAMES are the trap — two functions that sound\n * interchangeable, one of which is only complete with a flag.\n */\nexport function redactSecrets(text: string, opts?: RedactOptions): RedactionResult {\n // Computed from the OPTIONS, not from what was found — so it answers \"which\n // question did this call ask?\" identically on empty, clean and dirty input.\n // The empty-text path returns it too, deliberately: a caller asserting on\n // `scanned` must not get a different shape just because the body was blank.\n const scanned: readonly SecretConfidence[] = opts?.announced\n ? ['format', 'announced']\n : ['format'];\n if (!text) return { redacted: text, findings: [], scanned };\n let redacted = text;\n const findings: RedactionFinding[] = [];\n for (const p of patternsFor(opts)) {\n let count = 0;\n redacted = redacted.replace(p.regex, () => {\n count++;\n return redactionMarker(p.label);\n });\n if (count > 0) findings.push({ label: p.label, count, confidence: 'format' });\n }\n // Announced runs LAST, and only on request. Order is not cosmetic: the format\n // pass has already replaced everything it recognises, and this regex refuses a\n // value that is already a marker — so `API key: sk-ant-…` keeps its specific\n // `anthropic-api-key` attribution instead of being flattened to a generic one.\n // The announcing label itself is KEPT in the output; only the value goes, so\n // the redacted text still reads `Adgangskode: [REDACTED:announced-secret]` and\n // a human or model reading it can still tell what was removed.\n if (opts?.announced) {\n let count = 0;\n const redactedAnnounced = redacted.replace(ANNOUNCED_SECRET, (_match, prefix: string) => {\n count++;\n return prefix + redactionMarker(ANNOUNCED_LABEL);\n });\n if (count > 0) {\n redacted = redactedAnnounced;\n findings.push({ label: ANNOUNCED_LABEL, count, confidence: 'announced' });\n }\n }\n return { redacted, findings, scanned };\n}\n\n/**\n * True if `text` announces a credential by label — `Adgangskode: hunter2` —\n * without building a redaction. Cheap enough to run on every inbound message.\n *\n * This exists because for untrusted inbound text heading to a model, the right\n * response is often to REFUSE rather than redact: a false positive costs a\n * slightly worse classification, a false negative costs a leak. That use needs a\n * boolean, not a redactor. (buddy's reasoning, F035.8.)\n */\nexport function hasAnnouncedSecret(text: string): boolean {\n if (!text) return false;\n ANNOUNCED_SECRET.lastIndex = 0;\n return ANNOUNCED_SECRET.test(text);\n}\n\n/**\n * True if `text` contains at least one detectable secret. Honours\n * `opts.announced` — a caller who asks for the announced axis and is told\n * `false` must be able to believe it.\n */\nexport function hasSecret(text: string, opts?: RedactOptions): boolean {\n if (opts?.announced && hasAnnouncedSecret(text)) return true;\n return patternsFor(opts).some((p) => {\n p.regex.lastIndex = 0;\n return p.regex.test(text);\n });\n}\n\nexport interface ClassifyResult {\n /** the matching pattern's stable label (e.g. `openai-api-key`) */\n label: string;\n /** the matching pattern's human description (e.g. `OpenAI API key (sk-… / sk-proj-…)`) */\n description: string;\n}\n\n/**\n * Classify a SINGLE pasted token — the INVERSE of redaction. Returns the first\n * (most-specific) pattern the value matches, or `null`. Backs a \"paste a key →\n * detect its type\" UI (cardmem F214 Secrets Vault) so every consumer shares the\n * same classification, not just the same redaction.\n *\n * First-match-wins over the ordered `SECRET_PATTERNS`, so `sk-ant-…` classifies\n * as `anthropic-api-key`, never the generic `openai-api-key`. Field-anchored\n * context-only patterns (mistral / vimeo / cloudflare-api-token /\n * labeled-hex-secret / deepseek-fallback) only match when the pasted value\n * includes their `NAME=` context; a bare provider token classifies via its\n * prefix pattern, and a prefix-less bare token (e.g. a raw Mistral key) is\n * genuinely unidentifiable → `null`. `opts.extraPatterns` run AFTER the\n * canonical set (canonical attribution wins). Input is trimmed; empty /\n * whitespace-only → `null`.\n */\nexport function classify(value: string, opts?: RedactOptions): ClassifyResult | null {\n if (!value) return null;\n const v = value.trim();\n if (!v) return null;\n for (const p of patternsFor(opts)) {\n p.regex.lastIndex = 0;\n if (p.regex.test(v)) return { label: p.label, description: p.description };\n }\n return null;\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @broberg/secret-scan — fleet secret/credential redaction.\n *\n * `redactSecrets(text)` replaces every matched secret with `[REDACTED:<label>]`\n * and reports what it found. PURE + deterministic (regex/string only, no deps,\n * no I/O) so an engine write-gate, an egress scrub, a CLI, an admin preview UI,\n * and any repo all share the EXACT same detection — and it's trivially testable.\n *\n * Lifted verbatim from broberg/trail F197 (the second-brain safeguard); see\n * docs/features/F035-secret-scan.md. components owns + publishes this; @trail/shared\n * re-exports it.\n *\n * Design choices:\n * - Pattern-based, NOT entropy/generic-randomness — a redacted real fact would\n * corrupt knowledge, so we accept missing an exotic token over false positives.\n * - Order matters: most-specific patterns run first (e.g. `sk-ant-` before the\n * generic OpenAI `sk-`; `sk-or-v1-` before `sk-`), because each match is\n * consumed before the next pattern runs → order = attribution.\n * - Redact, never reject — the surrounding knowledge survives; only the\n * credential substring is neutralised.\n * - NEVER a bare high-entropy/hex pattern (it would hit git shas/hashes).\n * Prefix-less service secrets are caught only via `labeled-hex-secret` (a 40+\n * hex value assigned to a secret/token/password/api-key-named field).\n *\n * Two recommended integration shapes for consumers:\n * (a) write boundary — `redactSecrets(text)` before persist (ingest gate);\n * (b) egress — scrub before a value leaves to a user/LLM (highest-value guard).\n */\n\nexport interface SecretPattern {\n /** stable id shown in the redaction marker + findings */\n label: string;\n /** human description of what this matches */\n description: string;\n /** global regex (used for replace-all + counting) */\n regex: RegExp;\n}\n\n/** Ordered most-specific → least. Every regex carries the `g` flag. */\nexport const SECRET_PATTERNS: SecretPattern[] = [\n {\n label: 'private-key',\n description: 'PEM private key block (RSA/EC/OPENSSH/DSA/PGP)',\n regex:\n /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\\s\\S]*?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g,\n },\n {\n label: 'anthropic-api-key',\n description: 'Anthropic API key (sk-ant-…)',\n regex: /sk-ant-(?:api03-)?[A-Za-z0-9_-]{20,}/g,\n },\n {\n // OpenRouter — distinct from OpenAI; runs BEFORE the generic sk- (which would\n // otherwise also match + mislabel it).\n label: 'openrouter-api-key',\n description: 'OpenRouter API key (sk-or-v1- + 64 hex)',\n regex: /\\bsk-or-v1-[0-9a-f]{64}/g,\n },\n {\n // DeepSeek — shares the sk- prefix with OpenAI, so it MUST run before the\n // generic openai pattern (specific-before-generic = correct attribution).\n // DeepSeek's documented shape is sk- + 32 lowercase hex (GitGuardian confirms\n // an sk- prefix but hides the exact regex); the hex-only body + {32,} length\n // distinguishes it from OpenAI's mixed-case base62 keys, so a real OpenAI key\n // is never mislabelled. The field-anchored fallback below catches any\n // DEEPSEEK_API_KEY value that doesn't fit this canonical shape.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (sk- + 32 lowercase hex)',\n regex: /\\bsk-[0-9a-f]{32,}(?![0-9a-z])/g,\n },\n {\n label: 'openai-api-key',\n description: 'OpenAI API key (sk-… / sk-proj-…)',\n regex: /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/g,\n },\n {\n // ElevenLabs — sk_ with UNDERSCORE (vs OpenAI sk-), 48 hex.\n label: 'elevenlabs-api-key',\n description: 'ElevenLabs API key (sk_ + 48 hex)',\n regex: /\\bsk_[0-9a-f]{48}\\b/g,\n },\n {\n // fal.ai — uuid:hex32 (key_id:key_secret); the colon is the signal.\n label: 'fal-api-key',\n description: 'fal.ai key (uuid:hex32)',\n 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,\n },\n {\n // Black Forest Labs (FLUX) API key — bfl_ prefix + a long token (sample\n // bfl_Qo1…). The distinctive prefix + {20,} length keeps false positives near\n // zero; image-provider sibling of the fal key above.\n label: 'bfl-api-key',\n description: 'Black Forest Labs / FLUX API key (bfl_ + token)',\n regex: /\\bbfl_[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'google-api-key',\n description: 'Google / Gemini API key (AIza…)',\n regex: /AIza[0-9A-Za-z_-]{35}/g,\n },\n {\n label: 'google-oauth-secret',\n description: 'Google OAuth client secret (GOCSPX-…)',\n regex: /GOCSPX-[A-Za-z0-9_-]{28}/g,\n },\n {\n label: 'aws-access-key-id',\n description: 'AWS access key id (AKIA…)',\n regex: /\\bAKIA[0-9A-Z]{16}\\b/g,\n },\n {\n label: 'github-token',\n description: 'GitHub token (ghp_/gho_/ghs_/ghu_/ghr_…)',\n regex: /\\bgh[posru]_[A-Za-z0-9]{36,}\\b/g,\n },\n {\n // GitHub fine-grained PAT — distinct prefix `github_pat_` (not caught by the\n // classic gh[posru]_ above), then base62 + a `_` separator (~82 chars total).\n // The prefix is so distinctive that {50,} keeps false positives at zero.\n label: 'github-fine-grained-pat',\n description: 'GitHub fine-grained personal access token (github_pat_…)',\n regex: /\\bgithub_pat_[A-Za-z0-9_]{50,}/g,\n },\n {\n label: 'gitlab-token',\n description: 'GitLab personal access token (glpat-…)',\n regex: /\\bglpat-[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'slack-token',\n description: 'Slack token (xox[baprs]-…)',\n regex: /\\bxox[baprs]-[A-Za-z0-9-]{10,}/g,\n },\n {\n label: 'stripe-secret-key',\n description: 'Stripe live secret/restricted key (sk_live_/rk_live_…)',\n regex: /\\b[rs]k_live_[A-Za-z0-9]{20,}/g,\n },\n {\n // Filed by buddy, who found REAL ones sitting in plaintext in their own\n // transcription DB. Their scrub deliberately runs the format axis ONLY, so a\n // prefixed secret we do not match is a secret nobody catches — a precise\n // prefix is the only route that helps them. Zero false-positive risk: the\n // literal `whsec_` does not occur by accident.\n label: 'stripe-webhook-secret',\n description: 'Stripe webhook signing secret (whsec_…)',\n regex: /\\bwhsec_[A-Za-z0-9+/=_-]{20,}/g,\n },\n {\n // Resend (re_…). Lookahead requires a digit in the body so we don't redact\n // long snake_case identifiers like re_compute_the_thing.\n label: 'resend-api-key',\n description: 'Resend API key (re_ + token)',\n regex: /\\bre_(?=[A-Za-z0-9_]*\\d)[A-Za-z0-9_]{24,}\\b/g,\n },\n {\n label: 'supabase-access-token',\n description: 'Supabase personal/management access token (sbp_ + 40 hex)',\n regex: /\\bsbp_[0-9a-f]{40}/g,\n },\n {\n label: 'supabase-secret-key',\n description: 'Supabase secret API key (sb_secret_…)',\n regex: /\\bsb_secret_[A-Za-z0-9_-]{20,}/g,\n },\n {\n // Used by every @broberg/* publish — the highest-value leak from a .env / commit history.\n label: 'npm-token',\n description: 'npm publish/automation token (npm_ + 36 base62)',\n regex: /\\bnpm_[A-Za-z0-9]{36}\\b/g,\n },\n {\n label: 'fly-api-token',\n description: 'Fly.io API token (FlyV1 fm2_… / fo1_…)',\n regex: /(?:FlyV1 fm2_[A-Za-z0-9+/=_-]{20,}|\\bfo1_[A-Za-z0-9_-]{20,})/g,\n },\n {\n // Also covers Turso DB/platform auth tokens AND Supabase anon/service_role\n // keys — both are JWTs (eyJ…), so the single JWT pattern catches them.\n label: 'jwt',\n description: 'JSON Web Token (eyJ…) — incl. Turso + Supabase service_role tokens',\n regex: /\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}/g,\n },\n {\n // genApiKey = randomBytes(24).hex → uk_ + exactly 48 lowercase hex.\n label: 'upmetrics-key',\n description: 'Upmetrics project key (uk_ + 48 hex)',\n regex: /\\buk_[0-9a-f]{48}/g,\n },\n {\n label: 'cardmem-key',\n description: 'Cardmem personal/incident/project key (pa_/pi_/pk_ + 64 hex)',\n regex: /\\bp[aik]_[A-Za-z0-9]{20,}/g,\n },\n {\n // cardmem inbox-webhook key — piw_ isn't matched by p[aik]_ above (3rd char 'w' ≠ '_').\n label: 'cardmem-webhook-key',\n description: 'Cardmem inbox-webhook key (piw_ + 64 hex)',\n regex: /\\bpiw_[0-9a-f]{64}/g,\n },\n {\n label: 'trail-key',\n description: 'Trail personal API key (trail_…)',\n regex: /\\btrail_[A-Za-z0-9]{20,}/g,\n },\n {\n // Cronjobs API key (cronjobs.webhouse.net) — cj_ + randomBytes(32).base64url =\n // exactly 43 base64url chars (46 total). Prefix + fixed length = very low FP.\n // The UI's truncated cj_<8 chars>… preview is shorter than {43} → not matched.\n // Negative lookahead (not \\b) because base64url's `-` breaks a trailing \\b.\n label: 'cronjobs-api-key',\n description: 'Cronjobs API key (cj_ + 43 base64url)',\n regex: /\\bcj_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g,\n },\n {\n // randomBytes(32).hex → wh_ + 64 lowercase hex (67 chars total).\n label: 'cms-access-token',\n description: 'webhouse.app CMS access token (wh_ + 64 hex)',\n regex: /\\bwh_[0-9a-f]{64}/g,\n },\n {\n // Cloudflare API token (R2 / DNS management) — 40 base64url chars, NO prefix.\n // A bare {40} would false-positive broadly, so this is CONTEXT-ONLY: it only\n // fires next to a cf/cloudflare-api-token-named field. Runs before\n // labeled-hex-secret so a hex-valued CF token is attributed correctly.\n label: 'cloudflare-api-token',\n description: 'Cloudflare API token (cf/cloudflare-api-token field + 40 base64url)',\n regex: /\\b(?:cf|cloudflare)_?api_?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{40}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Mistral API key — prefix-less ~32 base62 (Christian-confirmed sample). A bare\n // [A-Za-z0-9]{32} would FP on every ID/hash, so CONTEXT-ONLY: anchored on a\n // mistral-(api-)key/token-named field. Runs before labeled-hex for attribution.\n label: 'mistral-api-key',\n description: 'Mistral API key (mistral-(api-)key/token field + 24+ base62)',\n regex: /\\bmistral(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{24,}(?![A-Za-z0-9])/gi,\n },\n {\n // DeepSeek — field-anchored fallback for any DEEPSEEK_API_KEY/TOKEN value that\n // doesn't fit the canonical sk-+hex shape (mirrors the Mistral context-only\n // approach). The field name is the signal → near-zero false positives. The\n // sk-+hex format pattern above already attributes the canonical shape; this\n // backstops a format change or an opaque token.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (deepseek-(api-)key/token field + 20+ token)',\n regex: /\\bdeepseek(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Vimeo personal access token — ~32 lowercase hex, no prefix (sanne). A bare\n // hex32 would FP massively (MD5/UUID), so CONTEXT-ONLY: anchored on a\n // vimeo-(access-)token-named field.\n label: 'vimeo-access-token',\n description: 'Vimeo access token (vimeo-(access-)token field + 20+ base62)',\n regex: /\\bvimeo(?:[_-]?access)?[_-]?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{20,}(?![A-Za-z0-9])/gi,\n },\n {\n // Context-based catch for prefix-less high-entropy service secrets\n // (CMS_JWT_SECRET, revalidateSecret, fleet openssl-rand-hex secrets): a 40+\n // hex value assigned to a field whose name contains\n // secret/token/password/api-key. The name requirement keeps the\n // false-positive rate near zero (a bare 40/64-hex would hit shas/hashes).\n label: 'labeled-hex-secret',\n description: 'A 40+ hex value assigned to a secret/token/password/api-key-named field',\n regex: /\\b[A-Za-z0-9_-]*(?:secret|token|password|api[_-]?key)\\b\\s*[:=]\\s*[\"'`]?[0-9a-f]{40,}/gi,\n },\n {\n // Discord bot token — three base64url segments. Anchored both sides so it\n // can't partial-match a longer dotted string.\n label: 'discord-bot-token',\n description: 'Discord bot token (3 base64url segments)',\n 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,\n },\n {\n label: 'discord-mfa-token',\n description: 'Discord MFA token (mfa. + 84 chars)',\n regex: /\\bmfa\\.[A-Za-z0-9_-]{84}\\b/g,\n },\n {\n // Cloudflare Turnstile PROD secret (sanne, verified 2/2) — 0x4 + 6×A prefix,\n // then 26 base64url (35 total). The 24-char SITE key + 1x/2x/3x TEST keys are\n // intentionally NOT matched (the {26} length gate misses them) so a public\n // key is never redacted.\n label: 'cloudflare-turnstile-secret',\n description: 'Cloudflare Turnstile secret key (0x4AAAAAA + 26 base64url, 35 total)',\n regex: /0x4AAAAAA[A-Za-z0-9_-]{26}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'cloudflare-global-key',\n description: 'Cloudflare global API key (37-hex)',\n regex: /\\b[0-9a-f]{37}\\b/g,\n },\n {\n // LAST on purpose: this is the only unprefixed shape in the list, so every\n // anchored pattern above must get first refusal.\n //\n // Philips Hue v2 application key — 40 chars of [A-Za-z0-9-] with NO prefix,\n // so there is nothing to anchor on. The negative lookahead is load-bearing,\n // not decoration: a bare [A-Za-z0-9-]{40} also matches a GIT COMMIT SHA, and\n // telemetry/error output is full of those. A redactor that eats commit\n // hashes gets switched off within a week, after which it protects nothing.\n //\n // F035.10 — THAT SENTENCE CAME TRUE ABOUT THIS VERY PATTERN. It was the only\n // prefix-less pattern here matching on ENTROPY ALONE, and base64 is\n // mixed-case alphanumeric, so it fired inside npm integrity digests:\n //\n // resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/…}\n // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ matched\n //\n // 33 hits in components' own pnpm-lock.yaml, 32 of them digests. (`-` is in\n // the class but is not a word character, so \\b anchors happily mid-digest.)\n // Reported by trail, whose gate then could not commit a lockfile change —\n // i.e. no dependency update at all. A gate nobody can satisfy is a gate\n // someone switches off, which costs more than the hole it closed.\n //\n // It is now CONTEXT-ONLY like every other prefix-less secret in this file\n // (cloudflare-api-token, mistral-api-key, vimeo-access-token,\n // labeled-hex-secret): the field name is the signal, not the randomness.\n // Deliberately given up: a bare 40-char Hue key in prose with no field name.\n // See the README's \"Deliberately NOT detected\" — do not remove the anchor to\n // \"fix\" that; a pattern that cannot tell a key from a checksum is worse than\n // no pattern. (Original pattern contributed by beacon, F035.7.)\n label: 'hue-application-key',\n description: 'Philips Hue application key (hue/bridge-named field + 40 chars)',\n // The `[\"'`]?` BEFORE the separator is not decoration: a Hue key most often\n // arrives as JSON — {\"hue_application_key\": \"…\"} — and the four older\n // context-only patterns in this file all omit it, so they miss the quoted\n // form. Noted rather than silently changed there; that is its own card.\n regex: /\\b(?:hue|bridge)[_-]?(?:application[_-]?key|username|user|key)\\b[\"'`]?\\s*[:=]\\s*[\"'`]?[A-Za-z0-9-]{40}(?![A-Za-z0-9-])/gi,\n },\n];\n\n/**\n * Shapes that identify a secret by its VALUE ALONE, with no field name.\n *\n * These are deliberately NOT in `SECRET_PATTERNS`, because a scanner runs over\n * arbitrary text where an unanchored entropy match is a disaster: the Hue shape\n * (40 mixed-case alphanumerics) is also what a 40-character window inside an npm\n * `sha512-…` digest looks like, which is how 0.5.0 blocked every lockfile commit\n * in every repo running the gate (F035.10).\n *\n * `classify()` is a different question, and that is the whole reason this list\n * exists. Its caller has ALREADY asserted the string is a secret — they pasted\n * it into a vault field and asked \"what kind?\" — so there is no checksum to\n * confuse it with and no text to corrupt. Answering \"unknown\" there costs a\n * consumer a working feature (cardmem's Secrets Vault type-detection) for a\n * false-positive risk that only exists when scanning.\n *\n * Same value, two questions: \"is there a secret in this text?\" and \"what kind of\n * secret is this?\" They do not deserve the same evidence bar.\n */\nconst VALUE_ONLY_PATTERNS: ReadonlyArray<SecretPattern> = [\n {\n label: 'hue-application-key',\n description: 'Philips Hue application key (40 chars, no prefix)',\n regex: /^(?=[A-Za-z0-9-]{40}$)(?=[A-Za-z0-9-]*[a-z])(?=[A-Za-z0-9-]*[A-Z])[A-Za-z0-9-]{40}$/,\n },\n];\n\n/**\n * WHY a finding was flagged — the two detection axes this package has.\n *\n * `format` the VALUE carries the signal: `sk-ant-…`, `ghp_…`, `AKIA…`. Shape\n * alone identifies it, so it is safe to run on anything.\n * `announced` the LABEL carries the signal: `Adgangskode: hunter2`. The value is\n * arbitrary human text with no shape to match, so the only evidence\n * is that someone wrote the word \"password\" next to it.\n */\nexport type SecretConfidence = 'format' | 'announced';\n\nexport interface RedactionFinding {\n label: string;\n count: number;\n /** which axis matched — see SecretConfidence. */\n confidence: SecretConfidence;\n}\n\nexport interface RedactionResult {\n /** input with every secret replaced by `[REDACTED:<label>]` */\n redacted: string;\n /** per-pattern counts of what was redacted (empty = nothing found ON THE AXES IN `scanned`) */\n findings: RedactionFinding[];\n /**\n * Which axes this call actually EXAMINED — always `['format']`, plus\n * `'announced'` when `opts.announced` was set.\n *\n * It exists because `findings: []` alone cannot tell you which question was\n * asked. `redactSecrets(\"Adgangskode: hunter2\")` and `redactSecrets(\"hello\")`\n * both return an empty `findings`, and until 0.3.0 nothing in the return value\n * distinguished \"we found nothing\" from \"we never looked there\".\n *\n * A caller that must be sure can now ASSERT rather than trust the docs:\n *\n * ```ts\n * const r = redactSecrets(body, { announced: true });\n * if (!r.scanned.includes('announced')) throw new Error('announced axis not scanned');\n * ```\n *\n * Note the honest limit: this does not PREVENT the mistake — someone who\n * forgets the flag can equally forget to check this. It makes the mistake\n * *detectable* instead of merely documented, which is the difference between a\n * check and an agreement. Filed by buddy, who had just declined the same\n * \"we'll agree to label things\" fix from another session on the grounds that\n * an agreement holds only until the first person forgets it, and said it would\n * be cheap to use that argument in one direction and not the other.\n */\n scanned: readonly SecretConfidence[];\n}\n\nexport interface RedactOptions {\n /**\n * Extra consumer/per-tenant patterns, run AFTER the canonical set (so canonical\n * attribution wins). Backs a future self-service \"paste a key → detector\" UI.\n */\n extraPatterns?: SecretPattern[];\n /**\n * Also detect ANNOUNCED secrets — `Adgangskode: hunter2` — where the label is\n * the only evidence. **Off by default, and it must stay that way.** See\n * ANNOUNCED_LABEL for the measurement that decided it.\n */\n announced?: boolean;\n}\n\n/** Marker label for a secret detected by its announcing label rather than shape. */\nexport const ANNOUNCED_LABEL = 'announced-secret';\n\n/**\n * Label + separator + value. The label list is deliberately short and concrete;\n * this is not a general \"looks like config\" detector.\n *\n * WHY THIS IS OPT-IN, MEASURED RATHER THAN GUESSED. Over this repo on\n * 2026-08-14 — 548 tracked files, 544 readable as text, containing essentially\n * no real secrets — this exact regex matched **97 times**, and every one was\n * noise. Per label: `secret` 61, `api key` 33, `password` 4, and every Danish\n * label 0. So 94 of the 97 are the two words that are also ordinary IDENTIFIERS\n * in source code (`secret: config.secret`, `apiKey: Record<…>`).\n *\n * That is the real finding, and it is sharper than \"the pattern is noisy\": its\n * precision depends entirely on WHAT IS BEING SCANNED. In an inbound mail body\n * — buddy's actual case — `Adgangskode:` is a strong signal. In a TypeScript\n * file it is a variable name. **The package cannot know which corpus it is\n * looking at; only the caller can.** So the caller makes the decision, and the\n * default cannot be on. (This is the opposite of this repo's usual defaults-ON\n * stance — webpush F067.1, lens-engine F065 — and the numbers above are why.)\n *\n * A broader label+separator+value pattern measured 305 on the same corpus, and\n * refining it only reached 202 — no amount of tuning makes a generic version\n * safe. A template/env-reference guard (`${FOO}`, `<your-key>`) was written and\n * then dropped: it changed the count by exactly 0, because the noise here is\n * identifiers, not templates.\n *\n * The value must not already be a redaction marker, so this can run AFTER the\n * format pass without flattening its more specific attribution.\n *\n * NOT IN THE LIST, AND DELIBERATELY (v0.4.0): bare `kode`. **In Danish, `kode`\n * mostly means SOURCE CODE** — the credential words are `kodeord` and\n * `adgangskode`, both still matched. Until 0.4.0 the bare form was included and\n * fired on ordinary technical prose; measured by buddy over 82,662 lines of real\n * Danish transcription: \"Det er min kode: se linje 40\" · \"Kode: const x = 1\" ·\n * \"Merge-kode: konflikten er løst\" · \"QR-kode: scan den\".\n *\n * And the behaviour was ARBITRARY, which is the part that settled it: `\\b` meant\n * `Landekode:` / `Postkode:` / `Fejlkode:` never matched (no word boundary inside\n * the word) while `QR-kode:` did (a hyphen IS one). Whether a compound was\n * flagged came down to whether someone happened to type a hyphen.\n *\n * THE COST, stated rather than hidden: `Her er min kode: hunter2` is no longer\n * detected, and that is a real Danish way to announce a password. Deliberate — a\n * token that means \"source code\" half the time is noise in every corpus, not\n * just buddy's. If you need it back, file it; do not re-add it locally.\n */\nconst ANNOUNCED_SECRET =\n /(\\b(?:adgangskode|kodeord|hemmelighed|password|passwd|api[ -]?key|apinøgle|secret|pwd)\\s*[:=]\\s*)(?!\\[REDACTED:)\\S+/gi;\n\n\n/** Replacement marker for a redacted secret. */\nexport const redactionMarker = (label: string): string => `[REDACTED:${label}]`;\n\nfunction patternsFor(opts?: RedactOptions): SecretPattern[] {\n return opts?.extraPatterns && opts.extraPatterns.length > 0\n ? [...SECRET_PATTERNS, ...opts.extraPatterns]\n : SECRET_PATTERNS;\n}\n\n/**\n * Scan `text` and replace every detected secret with its redaction marker.\n * Pure: clean input returns byte-identical (`findings: []`).\n *\n * ⚠️ **This does NOT catch an announced secret unless you pass\n * `{ announced: true }`.** `redactSecrets(\"Adgangskode: hunter2\")` returns the\n * password untouched with `findings: []` — which is indistinguishable from\n * \"this text is clean\", because the announced axis was never examined.\n *\n * The two axes are separate and only one is on by default (see\n * SecretConfidence). If you are gating untrusted inbound text, reach for\n * `hasAnnouncedSecret()` — or pass the flag. Do not assume an empty `findings`\n * means safe.\n *\n * Filed by buddy, who nearly reported this package as behaving wrongly: their\n * probe used the defaults and so could not see the axis they were testing. The\n * behaviour is right; the NAMES are the trap — two functions that sound\n * interchangeable, one of which is only complete with a flag.\n */\nexport function redactSecrets(text: string, opts?: RedactOptions): RedactionResult {\n // Computed from the OPTIONS, not from what was found — so it answers \"which\n // question did this call ask?\" identically on empty, clean and dirty input.\n // The empty-text path returns it too, deliberately: a caller asserting on\n // `scanned` must not get a different shape just because the body was blank.\n const scanned: readonly SecretConfidence[] = opts?.announced\n ? ['format', 'announced']\n : ['format'];\n if (!text) return { redacted: text, findings: [], scanned };\n let redacted = text;\n const findings: RedactionFinding[] = [];\n for (const p of patternsFor(opts)) {\n let count = 0;\n redacted = redacted.replace(p.regex, () => {\n count++;\n return redactionMarker(p.label);\n });\n if (count > 0) findings.push({ label: p.label, count, confidence: 'format' });\n }\n // Announced runs LAST, and only on request. Order is not cosmetic: the format\n // pass has already replaced everything it recognises, and this regex refuses a\n // value that is already a marker — so `API key: sk-ant-…` keeps its specific\n // `anthropic-api-key` attribution instead of being flattened to a generic one.\n // The announcing label itself is KEPT in the output; only the value goes, so\n // the redacted text still reads `Adgangskode: [REDACTED:announced-secret]` and\n // a human or model reading it can still tell what was removed.\n if (opts?.announced) {\n let count = 0;\n const redactedAnnounced = redacted.replace(ANNOUNCED_SECRET, (_match, prefix: string) => {\n count++;\n return prefix + redactionMarker(ANNOUNCED_LABEL);\n });\n if (count > 0) {\n redacted = redactedAnnounced;\n findings.push({ label: ANNOUNCED_LABEL, count, confidence: 'announced' });\n }\n }\n return { redacted, findings, scanned };\n}\n\n/**\n * True if `text` announces a credential by label — `Adgangskode: hunter2` —\n * without building a redaction. Cheap enough to run on every inbound message.\n *\n * This exists because for untrusted inbound text heading to a model, the right\n * response is often to REFUSE rather than redact: a false positive costs a\n * slightly worse classification, a false negative costs a leak. That use needs a\n * boolean, not a redactor. (buddy's reasoning, F035.8.)\n */\nexport function hasAnnouncedSecret(text: string): boolean {\n if (!text) return false;\n ANNOUNCED_SECRET.lastIndex = 0;\n return ANNOUNCED_SECRET.test(text);\n}\n\n/**\n * True if `text` contains at least one detectable secret. Honours\n * `opts.announced` — a caller who asks for the announced axis and is told\n * `false` must be able to believe it.\n */\nexport function hasSecret(text: string, opts?: RedactOptions): boolean {\n if (opts?.announced && hasAnnouncedSecret(text)) return true;\n return patternsFor(opts).some((p) => {\n p.regex.lastIndex = 0;\n return p.regex.test(text);\n });\n}\n\nexport interface ClassifyResult {\n /** the matching pattern's stable label (e.g. `openai-api-key`) */\n label: string;\n /** the matching pattern's human description (e.g. `OpenAI API key (sk-… / sk-proj-…)`) */\n description: string;\n}\n\n/**\n * Classify a SINGLE pasted token — the INVERSE of redaction. Returns the first\n * (most-specific) pattern the value matches, or `null`. Backs a \"paste a key →\n * detect its type\" UI (cardmem F214 Secrets Vault) so every consumer shares the\n * same classification, not just the same redaction.\n *\n * First-match-wins over the ordered `SECRET_PATTERNS`, so `sk-ant-…` classifies\n * as `anthropic-api-key`, never the generic `openai-api-key`. Field-anchored\n * context-only patterns (mistral / vimeo / cloudflare-api-token /\n * labeled-hex-secret / deepseek-fallback) only match when the pasted value\n * includes their `NAME=` context; a bare provider token classifies via its\n * prefix pattern, and a prefix-less bare token (e.g. a raw Mistral key) is\n * genuinely unidentifiable → `null`. `opts.extraPatterns` run AFTER the\n * canonical set (canonical attribution wins). Input is trimmed; empty /\n * whitespace-only → `null`.\n */\nexport function classify(value: string, opts?: RedactOptions): ClassifyResult | null {\n if (!value) return null;\n const v = value.trim();\n if (!v) return null;\n for (const p of patternsFor(opts)) {\n p.regex.lastIndex = 0;\n if (p.regex.test(v)) return { label: p.label, description: p.description };\n }\n // Only after every anchored pattern has declined: shapes that are safe to\n // name when the caller has already said \"this is a secret\". Anchored to the\n // WHOLE string (^…$), so this can never fire on a fragment of a longer value.\n for (const p of VALUE_ONLY_PATTERNS) {\n p.regex.lastIndex = 0;\n if (p.regex.test(v)) return { label: p.label, description: p.description };\n }\n return null;\n}\n"]}
|
package/dist/index.js
CHANGED
|
@@ -259,11 +259,41 @@ var SECRET_PATTERNS = [
|
|
|
259
259
|
// not decoration: a bare [A-Za-z0-9-]{40} also matches a GIT COMMIT SHA, and
|
|
260
260
|
// telemetry/error output is full of those. A redactor that eats commit
|
|
261
261
|
// hashes gets switched off within a week, after which it protects nothing.
|
|
262
|
-
//
|
|
263
|
-
//
|
|
262
|
+
//
|
|
263
|
+
// F035.10 — THAT SENTENCE CAME TRUE ABOUT THIS VERY PATTERN. It was the only
|
|
264
|
+
// prefix-less pattern here matching on ENTROPY ALONE, and base64 is
|
|
265
|
+
// mixed-case alphanumeric, so it fired inside npm integrity digests:
|
|
266
|
+
//
|
|
267
|
+
// resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/…}
|
|
268
|
+
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ matched
|
|
269
|
+
//
|
|
270
|
+
// 33 hits in components' own pnpm-lock.yaml, 32 of them digests. (`-` is in
|
|
271
|
+
// the class but is not a word character, so \b anchors happily mid-digest.)
|
|
272
|
+
// Reported by trail, whose gate then could not commit a lockfile change —
|
|
273
|
+
// i.e. no dependency update at all. A gate nobody can satisfy is a gate
|
|
274
|
+
// someone switches off, which costs more than the hole it closed.
|
|
275
|
+
//
|
|
276
|
+
// It is now CONTEXT-ONLY like every other prefix-less secret in this file
|
|
277
|
+
// (cloudflare-api-token, mistral-api-key, vimeo-access-token,
|
|
278
|
+
// labeled-hex-secret): the field name is the signal, not the randomness.
|
|
279
|
+
// Deliberately given up: a bare 40-char Hue key in prose with no field name.
|
|
280
|
+
// See the README's "Deliberately NOT detected" — do not remove the anchor to
|
|
281
|
+
// "fix" that; a pattern that cannot tell a key from a checksum is worse than
|
|
282
|
+
// no pattern. (Original pattern contributed by beacon, F035.7.)
|
|
264
283
|
label: "hue-application-key",
|
|
265
|
-
description: "Philips Hue
|
|
266
|
-
|
|
284
|
+
description: "Philips Hue application key (hue/bridge-named field + 40 chars)",
|
|
285
|
+
// The `["'`]?` BEFORE the separator is not decoration: a Hue key most often
|
|
286
|
+
// arrives as JSON — {"hue_application_key": "…"} — and the four older
|
|
287
|
+
// context-only patterns in this file all omit it, so they miss the quoted
|
|
288
|
+
// form. Noted rather than silently changed there; that is its own card.
|
|
289
|
+
regex: /\b(?:hue|bridge)[_-]?(?:application[_-]?key|username|user|key)\b["'`]?\s*[:=]\s*["'`]?[A-Za-z0-9-]{40}(?![A-Za-z0-9-])/gi
|
|
290
|
+
}
|
|
291
|
+
];
|
|
292
|
+
var VALUE_ONLY_PATTERNS = [
|
|
293
|
+
{
|
|
294
|
+
label: "hue-application-key",
|
|
295
|
+
description: "Philips Hue application key (40 chars, no prefix)",
|
|
296
|
+
regex: /^(?=[A-Za-z0-9-]{40}$)(?=[A-Za-z0-9-]*[a-z])(?=[A-Za-z0-9-]*[A-Z])[A-Za-z0-9-]{40}$/
|
|
267
297
|
}
|
|
268
298
|
];
|
|
269
299
|
var ANNOUNCED_LABEL = "announced-secret";
|
|
@@ -318,6 +348,10 @@ function classify(value, opts) {
|
|
|
318
348
|
p.regex.lastIndex = 0;
|
|
319
349
|
if (p.regex.test(v)) return { label: p.label, description: p.description };
|
|
320
350
|
}
|
|
351
|
+
for (const p of VALUE_ONLY_PATTERNS) {
|
|
352
|
+
p.regex.lastIndex = 0;
|
|
353
|
+
if (p.regex.test(v)) return { label: p.label, description: p.description };
|
|
354
|
+
}
|
|
321
355
|
return null;
|
|
322
356
|
}
|
|
323
357
|
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @broberg/secret-scan — fleet secret/credential redaction.\n *\n * `redactSecrets(text)` replaces every matched secret with `[REDACTED:<label>]`\n * and reports what it found. PURE + deterministic (regex/string only, no deps,\n * no I/O) so an engine write-gate, an egress scrub, a CLI, an admin preview UI,\n * and any repo all share the EXACT same detection — and it's trivially testable.\n *\n * Lifted verbatim from broberg/trail F197 (the second-brain safeguard); see\n * docs/features/F035-secret-scan.md. components owns + publishes this; @trail/shared\n * re-exports it.\n *\n * Design choices:\n * - Pattern-based, NOT entropy/generic-randomness — a redacted real fact would\n * corrupt knowledge, so we accept missing an exotic token over false positives.\n * - Order matters: most-specific patterns run first (e.g. `sk-ant-` before the\n * generic OpenAI `sk-`; `sk-or-v1-` before `sk-`), because each match is\n * consumed before the next pattern runs → order = attribution.\n * - Redact, never reject — the surrounding knowledge survives; only the\n * credential substring is neutralised.\n * - NEVER a bare high-entropy/hex pattern (it would hit git shas/hashes).\n * Prefix-less service secrets are caught only via `labeled-hex-secret` (a 40+\n * hex value assigned to a secret/token/password/api-key-named field).\n *\n * Two recommended integration shapes for consumers:\n * (a) write boundary — `redactSecrets(text)` before persist (ingest gate);\n * (b) egress — scrub before a value leaves to a user/LLM (highest-value guard).\n */\n\nexport interface SecretPattern {\n /** stable id shown in the redaction marker + findings */\n label: string;\n /** human description of what this matches */\n description: string;\n /** global regex (used for replace-all + counting) */\n regex: RegExp;\n}\n\n/** Ordered most-specific → least. Every regex carries the `g` flag. */\nexport const SECRET_PATTERNS: SecretPattern[] = [\n {\n label: 'private-key',\n description: 'PEM private key block (RSA/EC/OPENSSH/DSA/PGP)',\n regex:\n /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\\s\\S]*?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g,\n },\n {\n label: 'anthropic-api-key',\n description: 'Anthropic API key (sk-ant-…)',\n regex: /sk-ant-(?:api03-)?[A-Za-z0-9_-]{20,}/g,\n },\n {\n // OpenRouter — distinct from OpenAI; runs BEFORE the generic sk- (which would\n // otherwise also match + mislabel it).\n label: 'openrouter-api-key',\n description: 'OpenRouter API key (sk-or-v1- + 64 hex)',\n regex: /\\bsk-or-v1-[0-9a-f]{64}/g,\n },\n {\n // DeepSeek — shares the sk- prefix with OpenAI, so it MUST run before the\n // generic openai pattern (specific-before-generic = correct attribution).\n // DeepSeek's documented shape is sk- + 32 lowercase hex (GitGuardian confirms\n // an sk- prefix but hides the exact regex); the hex-only body + {32,} length\n // distinguishes it from OpenAI's mixed-case base62 keys, so a real OpenAI key\n // is never mislabelled. The field-anchored fallback below catches any\n // DEEPSEEK_API_KEY value that doesn't fit this canonical shape.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (sk- + 32 lowercase hex)',\n regex: /\\bsk-[0-9a-f]{32,}(?![0-9a-z])/g,\n },\n {\n label: 'openai-api-key',\n description: 'OpenAI API key (sk-… / sk-proj-…)',\n regex: /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/g,\n },\n {\n // ElevenLabs — sk_ with UNDERSCORE (vs OpenAI sk-), 48 hex.\n label: 'elevenlabs-api-key',\n description: 'ElevenLabs API key (sk_ + 48 hex)',\n regex: /\\bsk_[0-9a-f]{48}\\b/g,\n },\n {\n // fal.ai — uuid:hex32 (key_id:key_secret); the colon is the signal.\n label: 'fal-api-key',\n description: 'fal.ai key (uuid:hex32)',\n 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,\n },\n {\n // Black Forest Labs (FLUX) API key — bfl_ prefix + a long token (sample\n // bfl_Qo1…). The distinctive prefix + {20,} length keeps false positives near\n // zero; image-provider sibling of the fal key above.\n label: 'bfl-api-key',\n description: 'Black Forest Labs / FLUX API key (bfl_ + token)',\n regex: /\\bbfl_[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'google-api-key',\n description: 'Google / Gemini API key (AIza…)',\n regex: /AIza[0-9A-Za-z_-]{35}/g,\n },\n {\n label: 'google-oauth-secret',\n description: 'Google OAuth client secret (GOCSPX-…)',\n regex: /GOCSPX-[A-Za-z0-9_-]{28}/g,\n },\n {\n label: 'aws-access-key-id',\n description: 'AWS access key id (AKIA…)',\n regex: /\\bAKIA[0-9A-Z]{16}\\b/g,\n },\n {\n label: 'github-token',\n description: 'GitHub token (ghp_/gho_/ghs_/ghu_/ghr_…)',\n regex: /\\bgh[posru]_[A-Za-z0-9]{36,}\\b/g,\n },\n {\n // GitHub fine-grained PAT — distinct prefix `github_pat_` (not caught by the\n // classic gh[posru]_ above), then base62 + a `_` separator (~82 chars total).\n // The prefix is so distinctive that {50,} keeps false positives at zero.\n label: 'github-fine-grained-pat',\n description: 'GitHub fine-grained personal access token (github_pat_…)',\n regex: /\\bgithub_pat_[A-Za-z0-9_]{50,}/g,\n },\n {\n label: 'gitlab-token',\n description: 'GitLab personal access token (glpat-…)',\n regex: /\\bglpat-[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'slack-token',\n description: 'Slack token (xox[baprs]-…)',\n regex: /\\bxox[baprs]-[A-Za-z0-9-]{10,}/g,\n },\n {\n label: 'stripe-secret-key',\n description: 'Stripe live secret/restricted key (sk_live_/rk_live_…)',\n regex: /\\b[rs]k_live_[A-Za-z0-9]{20,}/g,\n },\n {\n // Filed by buddy, who found REAL ones sitting in plaintext in their own\n // transcription DB. Their scrub deliberately runs the format axis ONLY, so a\n // prefixed secret we do not match is a secret nobody catches — a precise\n // prefix is the only route that helps them. Zero false-positive risk: the\n // literal `whsec_` does not occur by accident.\n label: 'stripe-webhook-secret',\n description: 'Stripe webhook signing secret (whsec_…)',\n regex: /\\bwhsec_[A-Za-z0-9+/=_-]{20,}/g,\n },\n {\n // Resend (re_…). Lookahead requires a digit in the body so we don't redact\n // long snake_case identifiers like re_compute_the_thing.\n label: 'resend-api-key',\n description: 'Resend API key (re_ + token)',\n regex: /\\bre_(?=[A-Za-z0-9_]*\\d)[A-Za-z0-9_]{24,}\\b/g,\n },\n {\n label: 'supabase-access-token',\n description: 'Supabase personal/management access token (sbp_ + 40 hex)',\n regex: /\\bsbp_[0-9a-f]{40}/g,\n },\n {\n label: 'supabase-secret-key',\n description: 'Supabase secret API key (sb_secret_…)',\n regex: /\\bsb_secret_[A-Za-z0-9_-]{20,}/g,\n },\n {\n // Used by every @broberg/* publish — the highest-value leak from a .env / commit history.\n label: 'npm-token',\n description: 'npm publish/automation token (npm_ + 36 base62)',\n regex: /\\bnpm_[A-Za-z0-9]{36}\\b/g,\n },\n {\n label: 'fly-api-token',\n description: 'Fly.io API token (FlyV1 fm2_… / fo1_…)',\n regex: /(?:FlyV1 fm2_[A-Za-z0-9+/=_-]{20,}|\\bfo1_[A-Za-z0-9_-]{20,})/g,\n },\n {\n // Also covers Turso DB/platform auth tokens AND Supabase anon/service_role\n // keys — both are JWTs (eyJ…), so the single JWT pattern catches them.\n label: 'jwt',\n description: 'JSON Web Token (eyJ…) — incl. Turso + Supabase service_role tokens',\n regex: /\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}/g,\n },\n {\n // genApiKey = randomBytes(24).hex → uk_ + exactly 48 lowercase hex.\n label: 'upmetrics-key',\n description: 'Upmetrics project key (uk_ + 48 hex)',\n regex: /\\buk_[0-9a-f]{48}/g,\n },\n {\n label: 'cardmem-key',\n description: 'Cardmem personal/incident/project key (pa_/pi_/pk_ + 64 hex)',\n regex: /\\bp[aik]_[A-Za-z0-9]{20,}/g,\n },\n {\n // cardmem inbox-webhook key — piw_ isn't matched by p[aik]_ above (3rd char 'w' ≠ '_').\n label: 'cardmem-webhook-key',\n description: 'Cardmem inbox-webhook key (piw_ + 64 hex)',\n regex: /\\bpiw_[0-9a-f]{64}/g,\n },\n {\n label: 'trail-key',\n description: 'Trail personal API key (trail_…)',\n regex: /\\btrail_[A-Za-z0-9]{20,}/g,\n },\n {\n // Cronjobs API key (cronjobs.webhouse.net) — cj_ + randomBytes(32).base64url =\n // exactly 43 base64url chars (46 total). Prefix + fixed length = very low FP.\n // The UI's truncated cj_<8 chars>… preview is shorter than {43} → not matched.\n // Negative lookahead (not \\b) because base64url's `-` breaks a trailing \\b.\n label: 'cronjobs-api-key',\n description: 'Cronjobs API key (cj_ + 43 base64url)',\n regex: /\\bcj_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g,\n },\n {\n // randomBytes(32).hex → wh_ + 64 lowercase hex (67 chars total).\n label: 'cms-access-token',\n description: 'webhouse.app CMS access token (wh_ + 64 hex)',\n regex: /\\bwh_[0-9a-f]{64}/g,\n },\n {\n // Cloudflare API token (R2 / DNS management) — 40 base64url chars, NO prefix.\n // A bare {40} would false-positive broadly, so this is CONTEXT-ONLY: it only\n // fires next to a cf/cloudflare-api-token-named field. Runs before\n // labeled-hex-secret so a hex-valued CF token is attributed correctly.\n label: 'cloudflare-api-token',\n description: 'Cloudflare API token (cf/cloudflare-api-token field + 40 base64url)',\n regex: /\\b(?:cf|cloudflare)_?api_?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{40}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Mistral API key — prefix-less ~32 base62 (Christian-confirmed sample). A bare\n // [A-Za-z0-9]{32} would FP on every ID/hash, so CONTEXT-ONLY: anchored on a\n // mistral-(api-)key/token-named field. Runs before labeled-hex for attribution.\n label: 'mistral-api-key',\n description: 'Mistral API key (mistral-(api-)key/token field + 24+ base62)',\n regex: /\\bmistral(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{24,}(?![A-Za-z0-9])/gi,\n },\n {\n // DeepSeek — field-anchored fallback for any DEEPSEEK_API_KEY/TOKEN value that\n // doesn't fit the canonical sk-+hex shape (mirrors the Mistral context-only\n // approach). The field name is the signal → near-zero false positives. The\n // sk-+hex format pattern above already attributes the canonical shape; this\n // backstops a format change or an opaque token.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (deepseek-(api-)key/token field + 20+ token)',\n regex: /\\bdeepseek(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Vimeo personal access token — ~32 lowercase hex, no prefix (sanne). A bare\n // hex32 would FP massively (MD5/UUID), so CONTEXT-ONLY: anchored on a\n // vimeo-(access-)token-named field.\n label: 'vimeo-access-token',\n description: 'Vimeo access token (vimeo-(access-)token field + 20+ base62)',\n regex: /\\bvimeo(?:[_-]?access)?[_-]?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{20,}(?![A-Za-z0-9])/gi,\n },\n {\n // Context-based catch for prefix-less high-entropy service secrets\n // (CMS_JWT_SECRET, revalidateSecret, fleet openssl-rand-hex secrets): a 40+\n // hex value assigned to a field whose name contains\n // secret/token/password/api-key. The name requirement keeps the\n // false-positive rate near zero (a bare 40/64-hex would hit shas/hashes).\n label: 'labeled-hex-secret',\n description: 'A 40+ hex value assigned to a secret/token/password/api-key-named field',\n regex: /\\b[A-Za-z0-9_-]*(?:secret|token|password|api[_-]?key)\\b\\s*[:=]\\s*[\"'`]?[0-9a-f]{40,}/gi,\n },\n {\n // Discord bot token — three base64url segments. Anchored both sides so it\n // can't partial-match a longer dotted string.\n label: 'discord-bot-token',\n description: 'Discord bot token (3 base64url segments)',\n 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,\n },\n {\n label: 'discord-mfa-token',\n description: 'Discord MFA token (mfa. + 84 chars)',\n regex: /\\bmfa\\.[A-Za-z0-9_-]{84}\\b/g,\n },\n {\n // Cloudflare Turnstile PROD secret (sanne, verified 2/2) — 0x4 + 6×A prefix,\n // then 26 base64url (35 total). The 24-char SITE key + 1x/2x/3x TEST keys are\n // intentionally NOT matched (the {26} length gate misses them) so a public\n // key is never redacted.\n label: 'cloudflare-turnstile-secret',\n description: 'Cloudflare Turnstile secret key (0x4AAAAAA + 26 base64url, 35 total)',\n regex: /0x4AAAAAA[A-Za-z0-9_-]{26}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'cloudflare-global-key',\n description: 'Cloudflare global API key (37-hex)',\n regex: /\\b[0-9a-f]{37}\\b/g,\n },\n {\n // LAST on purpose: this is the only unprefixed shape in the list, so every\n // anchored pattern above must get first refusal.\n //\n // Philips Hue v2 application key — 40 chars of [A-Za-z0-9-] with NO prefix,\n // so there is nothing to anchor on. The negative lookahead is load-bearing,\n // not decoration: a bare [A-Za-z0-9-]{40} also matches a GIT COMMIT SHA, and\n // telemetry/error output is full of those. A redactor that eats commit\n // hashes gets switched off within a week, after which it protects nothing.\n // Hue keys are mixed-case; SHAs are lowercase hex — that asymmetry is the\n // whole guard. (Pattern contributed + field-tested by beacon, F035.7.)\n label: 'hue-application-key',\n description: 'Philips Hue v2 application key (40 chars, no prefix)',\n regex: /\\b(?![0-9a-f]{40}\\b)[A-Za-z0-9-]{40}\\b/g,\n },\n];\n\n/**\n * WHY a finding was flagged — the two detection axes this package has.\n *\n * `format` the VALUE carries the signal: `sk-ant-…`, `ghp_…`, `AKIA…`. Shape\n * alone identifies it, so it is safe to run on anything.\n * `announced` the LABEL carries the signal: `Adgangskode: hunter2`. The value is\n * arbitrary human text with no shape to match, so the only evidence\n * is that someone wrote the word \"password\" next to it.\n */\nexport type SecretConfidence = 'format' | 'announced';\n\nexport interface RedactionFinding {\n label: string;\n count: number;\n /** which axis matched — see SecretConfidence. */\n confidence: SecretConfidence;\n}\n\nexport interface RedactionResult {\n /** input with every secret replaced by `[REDACTED:<label>]` */\n redacted: string;\n /** per-pattern counts of what was redacted (empty = nothing found ON THE AXES IN `scanned`) */\n findings: RedactionFinding[];\n /**\n * Which axes this call actually EXAMINED — always `['format']`, plus\n * `'announced'` when `opts.announced` was set.\n *\n * It exists because `findings: []` alone cannot tell you which question was\n * asked. `redactSecrets(\"Adgangskode: hunter2\")` and `redactSecrets(\"hello\")`\n * both return an empty `findings`, and until 0.3.0 nothing in the return value\n * distinguished \"we found nothing\" from \"we never looked there\".\n *\n * A caller that must be sure can now ASSERT rather than trust the docs:\n *\n * ```ts\n * const r = redactSecrets(body, { announced: true });\n * if (!r.scanned.includes('announced')) throw new Error('announced axis not scanned');\n * ```\n *\n * Note the honest limit: this does not PREVENT the mistake — someone who\n * forgets the flag can equally forget to check this. It makes the mistake\n * *detectable* instead of merely documented, which is the difference between a\n * check and an agreement. Filed by buddy, who had just declined the same\n * \"we'll agree to label things\" fix from another session on the grounds that\n * an agreement holds only until the first person forgets it, and said it would\n * be cheap to use that argument in one direction and not the other.\n */\n scanned: readonly SecretConfidence[];\n}\n\nexport interface RedactOptions {\n /**\n * Extra consumer/per-tenant patterns, run AFTER the canonical set (so canonical\n * attribution wins). Backs a future self-service \"paste a key → detector\" UI.\n */\n extraPatterns?: SecretPattern[];\n /**\n * Also detect ANNOUNCED secrets — `Adgangskode: hunter2` — where the label is\n * the only evidence. **Off by default, and it must stay that way.** See\n * ANNOUNCED_LABEL for the measurement that decided it.\n */\n announced?: boolean;\n}\n\n/** Marker label for a secret detected by its announcing label rather than shape. */\nexport const ANNOUNCED_LABEL = 'announced-secret';\n\n/**\n * Label + separator + value. The label list is deliberately short and concrete;\n * this is not a general \"looks like config\" detector.\n *\n * WHY THIS IS OPT-IN, MEASURED RATHER THAN GUESSED. Over this repo on\n * 2026-08-14 — 548 tracked files, 544 readable as text, containing essentially\n * no real secrets — this exact regex matched **97 times**, and every one was\n * noise. Per label: `secret` 61, `api key` 33, `password` 4, and every Danish\n * label 0. So 94 of the 97 are the two words that are also ordinary IDENTIFIERS\n * in source code (`secret: config.secret`, `apiKey: Record<…>`).\n *\n * That is the real finding, and it is sharper than \"the pattern is noisy\": its\n * precision depends entirely on WHAT IS BEING SCANNED. In an inbound mail body\n * — buddy's actual case — `Adgangskode:` is a strong signal. In a TypeScript\n * file it is a variable name. **The package cannot know which corpus it is\n * looking at; only the caller can.** So the caller makes the decision, and the\n * default cannot be on. (This is the opposite of this repo's usual defaults-ON\n * stance — webpush F067.1, lens-engine F065 — and the numbers above are why.)\n *\n * A broader label+separator+value pattern measured 305 on the same corpus, and\n * refining it only reached 202 — no amount of tuning makes a generic version\n * safe. A template/env-reference guard (`${FOO}`, `<your-key>`) was written and\n * then dropped: it changed the count by exactly 0, because the noise here is\n * identifiers, not templates.\n *\n * The value must not already be a redaction marker, so this can run AFTER the\n * format pass without flattening its more specific attribution.\n *\n * NOT IN THE LIST, AND DELIBERATELY (v0.4.0): bare `kode`. **In Danish, `kode`\n * mostly means SOURCE CODE** — the credential words are `kodeord` and\n * `adgangskode`, both still matched. Until 0.4.0 the bare form was included and\n * fired on ordinary technical prose; measured by buddy over 82,662 lines of real\n * Danish transcription: \"Det er min kode: se linje 40\" · \"Kode: const x = 1\" ·\n * \"Merge-kode: konflikten er løst\" · \"QR-kode: scan den\".\n *\n * And the behaviour was ARBITRARY, which is the part that settled it: `\\b` meant\n * `Landekode:` / `Postkode:` / `Fejlkode:` never matched (no word boundary inside\n * the word) while `QR-kode:` did (a hyphen IS one). Whether a compound was\n * flagged came down to whether someone happened to type a hyphen.\n *\n * THE COST, stated rather than hidden: `Her er min kode: hunter2` is no longer\n * detected, and that is a real Danish way to announce a password. Deliberate — a\n * token that means \"source code\" half the time is noise in every corpus, not\n * just buddy's. If you need it back, file it; do not re-add it locally.\n */\nconst ANNOUNCED_SECRET =\n /(\\b(?:adgangskode|kodeord|hemmelighed|password|passwd|api[ -]?key|apinøgle|secret|pwd)\\s*[:=]\\s*)(?!\\[REDACTED:)\\S+/gi;\n\n\n/** Replacement marker for a redacted secret. */\nexport const redactionMarker = (label: string): string => `[REDACTED:${label}]`;\n\nfunction patternsFor(opts?: RedactOptions): SecretPattern[] {\n return opts?.extraPatterns && opts.extraPatterns.length > 0\n ? [...SECRET_PATTERNS, ...opts.extraPatterns]\n : SECRET_PATTERNS;\n}\n\n/**\n * Scan `text` and replace every detected secret with its redaction marker.\n * Pure: clean input returns byte-identical (`findings: []`).\n *\n * ⚠️ **This does NOT catch an announced secret unless you pass\n * `{ announced: true }`.** `redactSecrets(\"Adgangskode: hunter2\")` returns the\n * password untouched with `findings: []` — which is indistinguishable from\n * \"this text is clean\", because the announced axis was never examined.\n *\n * The two axes are separate and only one is on by default (see\n * SecretConfidence). If you are gating untrusted inbound text, reach for\n * `hasAnnouncedSecret()` — or pass the flag. Do not assume an empty `findings`\n * means safe.\n *\n * Filed by buddy, who nearly reported this package as behaving wrongly: their\n * probe used the defaults and so could not see the axis they were testing. The\n * behaviour is right; the NAMES are the trap — two functions that sound\n * interchangeable, one of which is only complete with a flag.\n */\nexport function redactSecrets(text: string, opts?: RedactOptions): RedactionResult {\n // Computed from the OPTIONS, not from what was found — so it answers \"which\n // question did this call ask?\" identically on empty, clean and dirty input.\n // The empty-text path returns it too, deliberately: a caller asserting on\n // `scanned` must not get a different shape just because the body was blank.\n const scanned: readonly SecretConfidence[] = opts?.announced\n ? ['format', 'announced']\n : ['format'];\n if (!text) return { redacted: text, findings: [], scanned };\n let redacted = text;\n const findings: RedactionFinding[] = [];\n for (const p of patternsFor(opts)) {\n let count = 0;\n redacted = redacted.replace(p.regex, () => {\n count++;\n return redactionMarker(p.label);\n });\n if (count > 0) findings.push({ label: p.label, count, confidence: 'format' });\n }\n // Announced runs LAST, and only on request. Order is not cosmetic: the format\n // pass has already replaced everything it recognises, and this regex refuses a\n // value that is already a marker — so `API key: sk-ant-…` keeps its specific\n // `anthropic-api-key` attribution instead of being flattened to a generic one.\n // The announcing label itself is KEPT in the output; only the value goes, so\n // the redacted text still reads `Adgangskode: [REDACTED:announced-secret]` and\n // a human or model reading it can still tell what was removed.\n if (opts?.announced) {\n let count = 0;\n const redactedAnnounced = redacted.replace(ANNOUNCED_SECRET, (_match, prefix: string) => {\n count++;\n return prefix + redactionMarker(ANNOUNCED_LABEL);\n });\n if (count > 0) {\n redacted = redactedAnnounced;\n findings.push({ label: ANNOUNCED_LABEL, count, confidence: 'announced' });\n }\n }\n return { redacted, findings, scanned };\n}\n\n/**\n * True if `text` announces a credential by label — `Adgangskode: hunter2` —\n * without building a redaction. Cheap enough to run on every inbound message.\n *\n * This exists because for untrusted inbound text heading to a model, the right\n * response is often to REFUSE rather than redact: a false positive costs a\n * slightly worse classification, a false negative costs a leak. That use needs a\n * boolean, not a redactor. (buddy's reasoning, F035.8.)\n */\nexport function hasAnnouncedSecret(text: string): boolean {\n if (!text) return false;\n ANNOUNCED_SECRET.lastIndex = 0;\n return ANNOUNCED_SECRET.test(text);\n}\n\n/**\n * True if `text` contains at least one detectable secret. Honours\n * `opts.announced` — a caller who asks for the announced axis and is told\n * `false` must be able to believe it.\n */\nexport function hasSecret(text: string, opts?: RedactOptions): boolean {\n if (opts?.announced && hasAnnouncedSecret(text)) return true;\n return patternsFor(opts).some((p) => {\n p.regex.lastIndex = 0;\n return p.regex.test(text);\n });\n}\n\nexport interface ClassifyResult {\n /** the matching pattern's stable label (e.g. `openai-api-key`) */\n label: string;\n /** the matching pattern's human description (e.g. `OpenAI API key (sk-… / sk-proj-…)`) */\n description: string;\n}\n\n/**\n * Classify a SINGLE pasted token — the INVERSE of redaction. Returns the first\n * (most-specific) pattern the value matches, or `null`. Backs a \"paste a key →\n * detect its type\" UI (cardmem F214 Secrets Vault) so every consumer shares the\n * same classification, not just the same redaction.\n *\n * First-match-wins over the ordered `SECRET_PATTERNS`, so `sk-ant-…` classifies\n * as `anthropic-api-key`, never the generic `openai-api-key`. Field-anchored\n * context-only patterns (mistral / vimeo / cloudflare-api-token /\n * labeled-hex-secret / deepseek-fallback) only match when the pasted value\n * includes their `NAME=` context; a bare provider token classifies via its\n * prefix pattern, and a prefix-less bare token (e.g. a raw Mistral key) is\n * genuinely unidentifiable → `null`. `opts.extraPatterns` run AFTER the\n * canonical set (canonical attribution wins). Input is trimmed; empty /\n * whitespace-only → `null`.\n */\nexport function classify(value: string, opts?: RedactOptions): ClassifyResult | null {\n if (!value) return null;\n const v = value.trim();\n if (!v) return null;\n for (const p of patternsFor(opts)) {\n p.regex.lastIndex = 0;\n if (p.regex.test(v)) return { label: p.label, description: p.description };\n }\n return null;\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @broberg/secret-scan — fleet secret/credential redaction.\n *\n * `redactSecrets(text)` replaces every matched secret with `[REDACTED:<label>]`\n * and reports what it found. PURE + deterministic (regex/string only, no deps,\n * no I/O) so an engine write-gate, an egress scrub, a CLI, an admin preview UI,\n * and any repo all share the EXACT same detection — and it's trivially testable.\n *\n * Lifted verbatim from broberg/trail F197 (the second-brain safeguard); see\n * docs/features/F035-secret-scan.md. components owns + publishes this; @trail/shared\n * re-exports it.\n *\n * Design choices:\n * - Pattern-based, NOT entropy/generic-randomness — a redacted real fact would\n * corrupt knowledge, so we accept missing an exotic token over false positives.\n * - Order matters: most-specific patterns run first (e.g. `sk-ant-` before the\n * generic OpenAI `sk-`; `sk-or-v1-` before `sk-`), because each match is\n * consumed before the next pattern runs → order = attribution.\n * - Redact, never reject — the surrounding knowledge survives; only the\n * credential substring is neutralised.\n * - NEVER a bare high-entropy/hex pattern (it would hit git shas/hashes).\n * Prefix-less service secrets are caught only via `labeled-hex-secret` (a 40+\n * hex value assigned to a secret/token/password/api-key-named field).\n *\n * Two recommended integration shapes for consumers:\n * (a) write boundary — `redactSecrets(text)` before persist (ingest gate);\n * (b) egress — scrub before a value leaves to a user/LLM (highest-value guard).\n */\n\nexport interface SecretPattern {\n /** stable id shown in the redaction marker + findings */\n label: string;\n /** human description of what this matches */\n description: string;\n /** global regex (used for replace-all + counting) */\n regex: RegExp;\n}\n\n/** Ordered most-specific → least. Every regex carries the `g` flag. */\nexport const SECRET_PATTERNS: SecretPattern[] = [\n {\n label: 'private-key',\n description: 'PEM private key block (RSA/EC/OPENSSH/DSA/PGP)',\n regex:\n /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\\s\\S]*?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g,\n },\n {\n label: 'anthropic-api-key',\n description: 'Anthropic API key (sk-ant-…)',\n regex: /sk-ant-(?:api03-)?[A-Za-z0-9_-]{20,}/g,\n },\n {\n // OpenRouter — distinct from OpenAI; runs BEFORE the generic sk- (which would\n // otherwise also match + mislabel it).\n label: 'openrouter-api-key',\n description: 'OpenRouter API key (sk-or-v1- + 64 hex)',\n regex: /\\bsk-or-v1-[0-9a-f]{64}/g,\n },\n {\n // DeepSeek — shares the sk- prefix with OpenAI, so it MUST run before the\n // generic openai pattern (specific-before-generic = correct attribution).\n // DeepSeek's documented shape is sk- + 32 lowercase hex (GitGuardian confirms\n // an sk- prefix but hides the exact regex); the hex-only body + {32,} length\n // distinguishes it from OpenAI's mixed-case base62 keys, so a real OpenAI key\n // is never mislabelled. The field-anchored fallback below catches any\n // DEEPSEEK_API_KEY value that doesn't fit this canonical shape.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (sk- + 32 lowercase hex)',\n regex: /\\bsk-[0-9a-f]{32,}(?![0-9a-z])/g,\n },\n {\n label: 'openai-api-key',\n description: 'OpenAI API key (sk-… / sk-proj-…)',\n regex: /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/g,\n },\n {\n // ElevenLabs — sk_ with UNDERSCORE (vs OpenAI sk-), 48 hex.\n label: 'elevenlabs-api-key',\n description: 'ElevenLabs API key (sk_ + 48 hex)',\n regex: /\\bsk_[0-9a-f]{48}\\b/g,\n },\n {\n // fal.ai — uuid:hex32 (key_id:key_secret); the colon is the signal.\n label: 'fal-api-key',\n description: 'fal.ai key (uuid:hex32)',\n 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,\n },\n {\n // Black Forest Labs (FLUX) API key — bfl_ prefix + a long token (sample\n // bfl_Qo1…). The distinctive prefix + {20,} length keeps false positives near\n // zero; image-provider sibling of the fal key above.\n label: 'bfl-api-key',\n description: 'Black Forest Labs / FLUX API key (bfl_ + token)',\n regex: /\\bbfl_[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'google-api-key',\n description: 'Google / Gemini API key (AIza…)',\n regex: /AIza[0-9A-Za-z_-]{35}/g,\n },\n {\n label: 'google-oauth-secret',\n description: 'Google OAuth client secret (GOCSPX-…)',\n regex: /GOCSPX-[A-Za-z0-9_-]{28}/g,\n },\n {\n label: 'aws-access-key-id',\n description: 'AWS access key id (AKIA…)',\n regex: /\\bAKIA[0-9A-Z]{16}\\b/g,\n },\n {\n label: 'github-token',\n description: 'GitHub token (ghp_/gho_/ghs_/ghu_/ghr_…)',\n regex: /\\bgh[posru]_[A-Za-z0-9]{36,}\\b/g,\n },\n {\n // GitHub fine-grained PAT — distinct prefix `github_pat_` (not caught by the\n // classic gh[posru]_ above), then base62 + a `_` separator (~82 chars total).\n // The prefix is so distinctive that {50,} keeps false positives at zero.\n label: 'github-fine-grained-pat',\n description: 'GitHub fine-grained personal access token (github_pat_…)',\n regex: /\\bgithub_pat_[A-Za-z0-9_]{50,}/g,\n },\n {\n label: 'gitlab-token',\n description: 'GitLab personal access token (glpat-…)',\n regex: /\\bglpat-[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'slack-token',\n description: 'Slack token (xox[baprs]-…)',\n regex: /\\bxox[baprs]-[A-Za-z0-9-]{10,}/g,\n },\n {\n label: 'stripe-secret-key',\n description: 'Stripe live secret/restricted key (sk_live_/rk_live_…)',\n regex: /\\b[rs]k_live_[A-Za-z0-9]{20,}/g,\n },\n {\n // Filed by buddy, who found REAL ones sitting in plaintext in their own\n // transcription DB. Their scrub deliberately runs the format axis ONLY, so a\n // prefixed secret we do not match is a secret nobody catches — a precise\n // prefix is the only route that helps them. Zero false-positive risk: the\n // literal `whsec_` does not occur by accident.\n label: 'stripe-webhook-secret',\n description: 'Stripe webhook signing secret (whsec_…)',\n regex: /\\bwhsec_[A-Za-z0-9+/=_-]{20,}/g,\n },\n {\n // Resend (re_…). Lookahead requires a digit in the body so we don't redact\n // long snake_case identifiers like re_compute_the_thing.\n label: 'resend-api-key',\n description: 'Resend API key (re_ + token)',\n regex: /\\bre_(?=[A-Za-z0-9_]*\\d)[A-Za-z0-9_]{24,}\\b/g,\n },\n {\n label: 'supabase-access-token',\n description: 'Supabase personal/management access token (sbp_ + 40 hex)',\n regex: /\\bsbp_[0-9a-f]{40}/g,\n },\n {\n label: 'supabase-secret-key',\n description: 'Supabase secret API key (sb_secret_…)',\n regex: /\\bsb_secret_[A-Za-z0-9_-]{20,}/g,\n },\n {\n // Used by every @broberg/* publish — the highest-value leak from a .env / commit history.\n label: 'npm-token',\n description: 'npm publish/automation token (npm_ + 36 base62)',\n regex: /\\bnpm_[A-Za-z0-9]{36}\\b/g,\n },\n {\n label: 'fly-api-token',\n description: 'Fly.io API token (FlyV1 fm2_… / fo1_…)',\n regex: /(?:FlyV1 fm2_[A-Za-z0-9+/=_-]{20,}|\\bfo1_[A-Za-z0-9_-]{20,})/g,\n },\n {\n // Also covers Turso DB/platform auth tokens AND Supabase anon/service_role\n // keys — both are JWTs (eyJ…), so the single JWT pattern catches them.\n label: 'jwt',\n description: 'JSON Web Token (eyJ…) — incl. Turso + Supabase service_role tokens',\n regex: /\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}/g,\n },\n {\n // genApiKey = randomBytes(24).hex → uk_ + exactly 48 lowercase hex.\n label: 'upmetrics-key',\n description: 'Upmetrics project key (uk_ + 48 hex)',\n regex: /\\buk_[0-9a-f]{48}/g,\n },\n {\n label: 'cardmem-key',\n description: 'Cardmem personal/incident/project key (pa_/pi_/pk_ + 64 hex)',\n regex: /\\bp[aik]_[A-Za-z0-9]{20,}/g,\n },\n {\n // cardmem inbox-webhook key — piw_ isn't matched by p[aik]_ above (3rd char 'w' ≠ '_').\n label: 'cardmem-webhook-key',\n description: 'Cardmem inbox-webhook key (piw_ + 64 hex)',\n regex: /\\bpiw_[0-9a-f]{64}/g,\n },\n {\n label: 'trail-key',\n description: 'Trail personal API key (trail_…)',\n regex: /\\btrail_[A-Za-z0-9]{20,}/g,\n },\n {\n // Cronjobs API key (cronjobs.webhouse.net) — cj_ + randomBytes(32).base64url =\n // exactly 43 base64url chars (46 total). Prefix + fixed length = very low FP.\n // The UI's truncated cj_<8 chars>… preview is shorter than {43} → not matched.\n // Negative lookahead (not \\b) because base64url's `-` breaks a trailing \\b.\n label: 'cronjobs-api-key',\n description: 'Cronjobs API key (cj_ + 43 base64url)',\n regex: /\\bcj_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g,\n },\n {\n // randomBytes(32).hex → wh_ + 64 lowercase hex (67 chars total).\n label: 'cms-access-token',\n description: 'webhouse.app CMS access token (wh_ + 64 hex)',\n regex: /\\bwh_[0-9a-f]{64}/g,\n },\n {\n // Cloudflare API token (R2 / DNS management) — 40 base64url chars, NO prefix.\n // A bare {40} would false-positive broadly, so this is CONTEXT-ONLY: it only\n // fires next to a cf/cloudflare-api-token-named field. Runs before\n // labeled-hex-secret so a hex-valued CF token is attributed correctly.\n label: 'cloudflare-api-token',\n description: 'Cloudflare API token (cf/cloudflare-api-token field + 40 base64url)',\n regex: /\\b(?:cf|cloudflare)_?api_?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{40}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Mistral API key — prefix-less ~32 base62 (Christian-confirmed sample). A bare\n // [A-Za-z0-9]{32} would FP on every ID/hash, so CONTEXT-ONLY: anchored on a\n // mistral-(api-)key/token-named field. Runs before labeled-hex for attribution.\n label: 'mistral-api-key',\n description: 'Mistral API key (mistral-(api-)key/token field + 24+ base62)',\n regex: /\\bmistral(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{24,}(?![A-Za-z0-9])/gi,\n },\n {\n // DeepSeek — field-anchored fallback for any DEEPSEEK_API_KEY/TOKEN value that\n // doesn't fit the canonical sk-+hex shape (mirrors the Mistral context-only\n // approach). The field name is the signal → near-zero false positives. The\n // sk-+hex format pattern above already attributes the canonical shape; this\n // backstops a format change or an opaque token.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (deepseek-(api-)key/token field + 20+ token)',\n regex: /\\bdeepseek(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Vimeo personal access token — ~32 lowercase hex, no prefix (sanne). A bare\n // hex32 would FP massively (MD5/UUID), so CONTEXT-ONLY: anchored on a\n // vimeo-(access-)token-named field.\n label: 'vimeo-access-token',\n description: 'Vimeo access token (vimeo-(access-)token field + 20+ base62)',\n regex: /\\bvimeo(?:[_-]?access)?[_-]?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{20,}(?![A-Za-z0-9])/gi,\n },\n {\n // Context-based catch for prefix-less high-entropy service secrets\n // (CMS_JWT_SECRET, revalidateSecret, fleet openssl-rand-hex secrets): a 40+\n // hex value assigned to a field whose name contains\n // secret/token/password/api-key. The name requirement keeps the\n // false-positive rate near zero (a bare 40/64-hex would hit shas/hashes).\n label: 'labeled-hex-secret',\n description: 'A 40+ hex value assigned to a secret/token/password/api-key-named field',\n regex: /\\b[A-Za-z0-9_-]*(?:secret|token|password|api[_-]?key)\\b\\s*[:=]\\s*[\"'`]?[0-9a-f]{40,}/gi,\n },\n {\n // Discord bot token — three base64url segments. Anchored both sides so it\n // can't partial-match a longer dotted string.\n label: 'discord-bot-token',\n description: 'Discord bot token (3 base64url segments)',\n 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,\n },\n {\n label: 'discord-mfa-token',\n description: 'Discord MFA token (mfa. + 84 chars)',\n regex: /\\bmfa\\.[A-Za-z0-9_-]{84}\\b/g,\n },\n {\n // Cloudflare Turnstile PROD secret (sanne, verified 2/2) — 0x4 + 6×A prefix,\n // then 26 base64url (35 total). The 24-char SITE key + 1x/2x/3x TEST keys are\n // intentionally NOT matched (the {26} length gate misses them) so a public\n // key is never redacted.\n label: 'cloudflare-turnstile-secret',\n description: 'Cloudflare Turnstile secret key (0x4AAAAAA + 26 base64url, 35 total)',\n regex: /0x4AAAAAA[A-Za-z0-9_-]{26}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'cloudflare-global-key',\n description: 'Cloudflare global API key (37-hex)',\n regex: /\\b[0-9a-f]{37}\\b/g,\n },\n {\n // LAST on purpose: this is the only unprefixed shape in the list, so every\n // anchored pattern above must get first refusal.\n //\n // Philips Hue v2 application key — 40 chars of [A-Za-z0-9-] with NO prefix,\n // so there is nothing to anchor on. The negative lookahead is load-bearing,\n // not decoration: a bare [A-Za-z0-9-]{40} also matches a GIT COMMIT SHA, and\n // telemetry/error output is full of those. A redactor that eats commit\n // hashes gets switched off within a week, after which it protects nothing.\n //\n // F035.10 — THAT SENTENCE CAME TRUE ABOUT THIS VERY PATTERN. It was the only\n // prefix-less pattern here matching on ENTROPY ALONE, and base64 is\n // mixed-case alphanumeric, so it fired inside npm integrity digests:\n //\n // resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/…}\n // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ matched\n //\n // 33 hits in components' own pnpm-lock.yaml, 32 of them digests. (`-` is in\n // the class but is not a word character, so \\b anchors happily mid-digest.)\n // Reported by trail, whose gate then could not commit a lockfile change —\n // i.e. no dependency update at all. A gate nobody can satisfy is a gate\n // someone switches off, which costs more than the hole it closed.\n //\n // It is now CONTEXT-ONLY like every other prefix-less secret in this file\n // (cloudflare-api-token, mistral-api-key, vimeo-access-token,\n // labeled-hex-secret): the field name is the signal, not the randomness.\n // Deliberately given up: a bare 40-char Hue key in prose with no field name.\n // See the README's \"Deliberately NOT detected\" — do not remove the anchor to\n // \"fix\" that; a pattern that cannot tell a key from a checksum is worse than\n // no pattern. (Original pattern contributed by beacon, F035.7.)\n label: 'hue-application-key',\n description: 'Philips Hue application key (hue/bridge-named field + 40 chars)',\n // The `[\"'`]?` BEFORE the separator is not decoration: a Hue key most often\n // arrives as JSON — {\"hue_application_key\": \"…\"} — and the four older\n // context-only patterns in this file all omit it, so they miss the quoted\n // form. Noted rather than silently changed there; that is its own card.\n regex: /\\b(?:hue|bridge)[_-]?(?:application[_-]?key|username|user|key)\\b[\"'`]?\\s*[:=]\\s*[\"'`]?[A-Za-z0-9-]{40}(?![A-Za-z0-9-])/gi,\n },\n];\n\n/**\n * Shapes that identify a secret by its VALUE ALONE, with no field name.\n *\n * These are deliberately NOT in `SECRET_PATTERNS`, because a scanner runs over\n * arbitrary text where an unanchored entropy match is a disaster: the Hue shape\n * (40 mixed-case alphanumerics) is also what a 40-character window inside an npm\n * `sha512-…` digest looks like, which is how 0.5.0 blocked every lockfile commit\n * in every repo running the gate (F035.10).\n *\n * `classify()` is a different question, and that is the whole reason this list\n * exists. Its caller has ALREADY asserted the string is a secret — they pasted\n * it into a vault field and asked \"what kind?\" — so there is no checksum to\n * confuse it with and no text to corrupt. Answering \"unknown\" there costs a\n * consumer a working feature (cardmem's Secrets Vault type-detection) for a\n * false-positive risk that only exists when scanning.\n *\n * Same value, two questions: \"is there a secret in this text?\" and \"what kind of\n * secret is this?\" They do not deserve the same evidence bar.\n */\nconst VALUE_ONLY_PATTERNS: ReadonlyArray<SecretPattern> = [\n {\n label: 'hue-application-key',\n description: 'Philips Hue application key (40 chars, no prefix)',\n regex: /^(?=[A-Za-z0-9-]{40}$)(?=[A-Za-z0-9-]*[a-z])(?=[A-Za-z0-9-]*[A-Z])[A-Za-z0-9-]{40}$/,\n },\n];\n\n/**\n * WHY a finding was flagged — the two detection axes this package has.\n *\n * `format` the VALUE carries the signal: `sk-ant-…`, `ghp_…`, `AKIA…`. Shape\n * alone identifies it, so it is safe to run on anything.\n * `announced` the LABEL carries the signal: `Adgangskode: hunter2`. The value is\n * arbitrary human text with no shape to match, so the only evidence\n * is that someone wrote the word \"password\" next to it.\n */\nexport type SecretConfidence = 'format' | 'announced';\n\nexport interface RedactionFinding {\n label: string;\n count: number;\n /** which axis matched — see SecretConfidence. */\n confidence: SecretConfidence;\n}\n\nexport interface RedactionResult {\n /** input with every secret replaced by `[REDACTED:<label>]` */\n redacted: string;\n /** per-pattern counts of what was redacted (empty = nothing found ON THE AXES IN `scanned`) */\n findings: RedactionFinding[];\n /**\n * Which axes this call actually EXAMINED — always `['format']`, plus\n * `'announced'` when `opts.announced` was set.\n *\n * It exists because `findings: []` alone cannot tell you which question was\n * asked. `redactSecrets(\"Adgangskode: hunter2\")` and `redactSecrets(\"hello\")`\n * both return an empty `findings`, and until 0.3.0 nothing in the return value\n * distinguished \"we found nothing\" from \"we never looked there\".\n *\n * A caller that must be sure can now ASSERT rather than trust the docs:\n *\n * ```ts\n * const r = redactSecrets(body, { announced: true });\n * if (!r.scanned.includes('announced')) throw new Error('announced axis not scanned');\n * ```\n *\n * Note the honest limit: this does not PREVENT the mistake — someone who\n * forgets the flag can equally forget to check this. It makes the mistake\n * *detectable* instead of merely documented, which is the difference between a\n * check and an agreement. Filed by buddy, who had just declined the same\n * \"we'll agree to label things\" fix from another session on the grounds that\n * an agreement holds only until the first person forgets it, and said it would\n * be cheap to use that argument in one direction and not the other.\n */\n scanned: readonly SecretConfidence[];\n}\n\nexport interface RedactOptions {\n /**\n * Extra consumer/per-tenant patterns, run AFTER the canonical set (so canonical\n * attribution wins). Backs a future self-service \"paste a key → detector\" UI.\n */\n extraPatterns?: SecretPattern[];\n /**\n * Also detect ANNOUNCED secrets — `Adgangskode: hunter2` — where the label is\n * the only evidence. **Off by default, and it must stay that way.** See\n * ANNOUNCED_LABEL for the measurement that decided it.\n */\n announced?: boolean;\n}\n\n/** Marker label for a secret detected by its announcing label rather than shape. */\nexport const ANNOUNCED_LABEL = 'announced-secret';\n\n/**\n * Label + separator + value. The label list is deliberately short and concrete;\n * this is not a general \"looks like config\" detector.\n *\n * WHY THIS IS OPT-IN, MEASURED RATHER THAN GUESSED. Over this repo on\n * 2026-08-14 — 548 tracked files, 544 readable as text, containing essentially\n * no real secrets — this exact regex matched **97 times**, and every one was\n * noise. Per label: `secret` 61, `api key` 33, `password` 4, and every Danish\n * label 0. So 94 of the 97 are the two words that are also ordinary IDENTIFIERS\n * in source code (`secret: config.secret`, `apiKey: Record<…>`).\n *\n * That is the real finding, and it is sharper than \"the pattern is noisy\": its\n * precision depends entirely on WHAT IS BEING SCANNED. In an inbound mail body\n * — buddy's actual case — `Adgangskode:` is a strong signal. In a TypeScript\n * file it is a variable name. **The package cannot know which corpus it is\n * looking at; only the caller can.** So the caller makes the decision, and the\n * default cannot be on. (This is the opposite of this repo's usual defaults-ON\n * stance — webpush F067.1, lens-engine F065 — and the numbers above are why.)\n *\n * A broader label+separator+value pattern measured 305 on the same corpus, and\n * refining it only reached 202 — no amount of tuning makes a generic version\n * safe. A template/env-reference guard (`${FOO}`, `<your-key>`) was written and\n * then dropped: it changed the count by exactly 0, because the noise here is\n * identifiers, not templates.\n *\n * The value must not already be a redaction marker, so this can run AFTER the\n * format pass without flattening its more specific attribution.\n *\n * NOT IN THE LIST, AND DELIBERATELY (v0.4.0): bare `kode`. **In Danish, `kode`\n * mostly means SOURCE CODE** — the credential words are `kodeord` and\n * `adgangskode`, both still matched. Until 0.4.0 the bare form was included and\n * fired on ordinary technical prose; measured by buddy over 82,662 lines of real\n * Danish transcription: \"Det er min kode: se linje 40\" · \"Kode: const x = 1\" ·\n * \"Merge-kode: konflikten er løst\" · \"QR-kode: scan den\".\n *\n * And the behaviour was ARBITRARY, which is the part that settled it: `\\b` meant\n * `Landekode:` / `Postkode:` / `Fejlkode:` never matched (no word boundary inside\n * the word) while `QR-kode:` did (a hyphen IS one). Whether a compound was\n * flagged came down to whether someone happened to type a hyphen.\n *\n * THE COST, stated rather than hidden: `Her er min kode: hunter2` is no longer\n * detected, and that is a real Danish way to announce a password. Deliberate — a\n * token that means \"source code\" half the time is noise in every corpus, not\n * just buddy's. If you need it back, file it; do not re-add it locally.\n */\nconst ANNOUNCED_SECRET =\n /(\\b(?:adgangskode|kodeord|hemmelighed|password|passwd|api[ -]?key|apinøgle|secret|pwd)\\s*[:=]\\s*)(?!\\[REDACTED:)\\S+/gi;\n\n\n/** Replacement marker for a redacted secret. */\nexport const redactionMarker = (label: string): string => `[REDACTED:${label}]`;\n\nfunction patternsFor(opts?: RedactOptions): SecretPattern[] {\n return opts?.extraPatterns && opts.extraPatterns.length > 0\n ? [...SECRET_PATTERNS, ...opts.extraPatterns]\n : SECRET_PATTERNS;\n}\n\n/**\n * Scan `text` and replace every detected secret with its redaction marker.\n * Pure: clean input returns byte-identical (`findings: []`).\n *\n * ⚠️ **This does NOT catch an announced secret unless you pass\n * `{ announced: true }`.** `redactSecrets(\"Adgangskode: hunter2\")` returns the\n * password untouched with `findings: []` — which is indistinguishable from\n * \"this text is clean\", because the announced axis was never examined.\n *\n * The two axes are separate and only one is on by default (see\n * SecretConfidence). If you are gating untrusted inbound text, reach for\n * `hasAnnouncedSecret()` — or pass the flag. Do not assume an empty `findings`\n * means safe.\n *\n * Filed by buddy, who nearly reported this package as behaving wrongly: their\n * probe used the defaults and so could not see the axis they were testing. The\n * behaviour is right; the NAMES are the trap — two functions that sound\n * interchangeable, one of which is only complete with a flag.\n */\nexport function redactSecrets(text: string, opts?: RedactOptions): RedactionResult {\n // Computed from the OPTIONS, not from what was found — so it answers \"which\n // question did this call ask?\" identically on empty, clean and dirty input.\n // The empty-text path returns it too, deliberately: a caller asserting on\n // `scanned` must not get a different shape just because the body was blank.\n const scanned: readonly SecretConfidence[] = opts?.announced\n ? ['format', 'announced']\n : ['format'];\n if (!text) return { redacted: text, findings: [], scanned };\n let redacted = text;\n const findings: RedactionFinding[] = [];\n for (const p of patternsFor(opts)) {\n let count = 0;\n redacted = redacted.replace(p.regex, () => {\n count++;\n return redactionMarker(p.label);\n });\n if (count > 0) findings.push({ label: p.label, count, confidence: 'format' });\n }\n // Announced runs LAST, and only on request. Order is not cosmetic: the format\n // pass has already replaced everything it recognises, and this regex refuses a\n // value that is already a marker — so `API key: sk-ant-…` keeps its specific\n // `anthropic-api-key` attribution instead of being flattened to a generic one.\n // The announcing label itself is KEPT in the output; only the value goes, so\n // the redacted text still reads `Adgangskode: [REDACTED:announced-secret]` and\n // a human or model reading it can still tell what was removed.\n if (opts?.announced) {\n let count = 0;\n const redactedAnnounced = redacted.replace(ANNOUNCED_SECRET, (_match, prefix: string) => {\n count++;\n return prefix + redactionMarker(ANNOUNCED_LABEL);\n });\n if (count > 0) {\n redacted = redactedAnnounced;\n findings.push({ label: ANNOUNCED_LABEL, count, confidence: 'announced' });\n }\n }\n return { redacted, findings, scanned };\n}\n\n/**\n * True if `text` announces a credential by label — `Adgangskode: hunter2` —\n * without building a redaction. Cheap enough to run on every inbound message.\n *\n * This exists because for untrusted inbound text heading to a model, the right\n * response is often to REFUSE rather than redact: a false positive costs a\n * slightly worse classification, a false negative costs a leak. That use needs a\n * boolean, not a redactor. (buddy's reasoning, F035.8.)\n */\nexport function hasAnnouncedSecret(text: string): boolean {\n if (!text) return false;\n ANNOUNCED_SECRET.lastIndex = 0;\n return ANNOUNCED_SECRET.test(text);\n}\n\n/**\n * True if `text` contains at least one detectable secret. Honours\n * `opts.announced` — a caller who asks for the announced axis and is told\n * `false` must be able to believe it.\n */\nexport function hasSecret(text: string, opts?: RedactOptions): boolean {\n if (opts?.announced && hasAnnouncedSecret(text)) return true;\n return patternsFor(opts).some((p) => {\n p.regex.lastIndex = 0;\n return p.regex.test(text);\n });\n}\n\nexport interface ClassifyResult {\n /** the matching pattern's stable label (e.g. `openai-api-key`) */\n label: string;\n /** the matching pattern's human description (e.g. `OpenAI API key (sk-… / sk-proj-…)`) */\n description: string;\n}\n\n/**\n * Classify a SINGLE pasted token — the INVERSE of redaction. Returns the first\n * (most-specific) pattern the value matches, or `null`. Backs a \"paste a key →\n * detect its type\" UI (cardmem F214 Secrets Vault) so every consumer shares the\n * same classification, not just the same redaction.\n *\n * First-match-wins over the ordered `SECRET_PATTERNS`, so `sk-ant-…` classifies\n * as `anthropic-api-key`, never the generic `openai-api-key`. Field-anchored\n * context-only patterns (mistral / vimeo / cloudflare-api-token /\n * labeled-hex-secret / deepseek-fallback) only match when the pasted value\n * includes their `NAME=` context; a bare provider token classifies via its\n * prefix pattern, and a prefix-less bare token (e.g. a raw Mistral key) is\n * genuinely unidentifiable → `null`. `opts.extraPatterns` run AFTER the\n * canonical set (canonical attribution wins). Input is trimmed; empty /\n * whitespace-only → `null`.\n */\nexport function classify(value: string, opts?: RedactOptions): ClassifyResult | null {\n if (!value) return null;\n const v = value.trim();\n if (!v) return null;\n for (const p of patternsFor(opts)) {\n p.regex.lastIndex = 0;\n if (p.regex.test(v)) return { label: p.label, description: p.description };\n }\n // Only after every anchored pattern has declined: shapes that are safe to\n // name when the caller has already said \"this is a secret\". Anchored to the\n // WHOLE string (^…$), so this can never fire on a fragment of a longer value.\n for (const p of VALUE_ONLY_PATTERNS) {\n p.regex.lastIndex = 0;\n if (p.regex.test(v)) return { label: p.label, description: p.description };\n }\n return null;\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@broberg/secret-scan",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Pure, dependency-free secret/credential redaction for the broberg.ai fleet
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "Pure, dependency-free secret/credential redaction for the broberg.ai fleet \u2014 redactSecrets / hasSecret over a curated, ordered SECRET_PATTERNS set. Redact at write + egress boundaries so keys never land in a DB, chat, or KB. Lifted from broberg/trail F197.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"sideEffects": false,
|