@broberg/secret-scan 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -162,8 +162,21 @@ var SECRET_PATTERNS = [
162
162
  regex: /\bpiw_[0-9a-f]{64}/g
163
163
  },
164
164
  {
165
+ // VERIFIED WITH THE OWNER, 2026-08-28: `trail_` + exactly 64 LOWERCASE HEX.
166
+ // trail generated 2000 keys through @broberg/apikey's generateKey('trail')
167
+ // and counted the alphabet: 0-9a-f only, length 64-64, no `-`, no `_`. Source
168
+ // is `${prefix}_${randomBytes(bytes).toString("hex")}`, bytes=32, with a hard
169
+ // floor of 16 — so even a future caller asking for the minimum yields 32 hex
170
+ // chars, still above {20,}.
171
+ //
172
+ // Recorded because the QUESTION is easy to re-ask and the ANSWER is not: this
173
+ // is one of the few patterns here assuming alphanumerics only, and had trail
174
+ // used base64url (the more common one-liner) the `-` and `_` would break the
175
+ // run, {20,} would never be satisfied, and the WHOLE key would pass through
176
+ // unredacted — not partially, entirely. Measured rather than assumed, because
177
+ // two sampled keys cannot tell hex from base64url that has not hit a `-` yet.
165
178
  label: "trail-key",
166
- description: "Trail personal API key (trail_\u2026)",
179
+ description: "Trail personal API key (trail_ + 64 hex; verified 2026-08-28)",
167
180
  regex: /\btrail_[A-Za-z0-9]{20,}/g
168
181
  },
169
182
  {
@@ -299,7 +312,10 @@ var VALUE_ONLY_PATTERNS = [
299
312
  }
300
313
  ];
301
314
  var ANNOUNCED_LABEL = "announced-secret";
302
- var ANNOUNCED_SECRET = /(\b(?:adgangskode|kodeord|hemmelighed|password|passwd|api[ -]?key|apinøgle|secret|pwd)\s*[:=]\s*)(?!\[REDACTED:)\S+/gi;
315
+ var ANNOUNCED_SECRET = /(\b(?:adgangskode|kodeord|hemmelighed|password|passwd|api[ -]?key|apinøgle|secret|pwd)\s*[:=]\s*)(?!\[REDACTED:)(\S+)/gi;
316
+ function plausibleSecretValue(candidate) {
317
+ return /\d/.test(candidate) || candidate.length >= 16;
318
+ }
303
319
  var redactionMarker = (label) => `[REDACTED:${label}]`;
304
320
  function patternsFor(opts) {
305
321
  return opts?.extraPatterns && opts.extraPatterns.length > 0 ? [...SECRET_PATTERNS, ...opts.extraPatterns] : SECRET_PATTERNS;
@@ -319,10 +335,14 @@ function redactSecrets(text, opts) {
319
335
  }
320
336
  if (opts?.announced) {
321
337
  let count = 0;
322
- const redactedAnnounced = redacted.replace(ANNOUNCED_SECRET, (_match, prefix) => {
323
- count++;
324
- return prefix + redactionMarker(ANNOUNCED_LABEL);
325
- });
338
+ const redactedAnnounced = redacted.replace(
339
+ ANNOUNCED_SECRET,
340
+ (match, prefix, value) => {
341
+ if (!plausibleSecretValue(value)) return match;
342
+ count++;
343
+ return prefix + redactionMarker(ANNOUNCED_LABEL);
344
+ }
345
+ );
326
346
  if (count > 0) {
327
347
  redacted = redactedAnnounced;
328
348
  findings.push({ label: ANNOUNCED_LABEL, count, confidence: "announced" });
@@ -333,7 +353,13 @@ function redactSecrets(text, opts) {
333
353
  function hasAnnouncedSecret(text) {
334
354
  if (!text) return false;
335
355
  ANNOUNCED_SECRET.lastIndex = 0;
336
- return ANNOUNCED_SECRET.test(text);
356
+ for (let m = ANNOUNCED_SECRET.exec(text); m !== null; m = ANNOUNCED_SECRET.exec(text)) {
357
+ if (plausibleSecretValue(m[2] ?? "")) {
358
+ ANNOUNCED_SECRET.lastIndex = 0;
359
+ return true;
360
+ }
361
+ }
362
+ return false;
337
363
  }
338
364
  function hasSecret(text, opts) {
339
365
  if (opts?.announced && hasAnnouncedSecret(text)) return true;
@@ -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 //\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"]}
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 // VERIFIED WITH THE OWNER, 2026-08-28: `trail_` + exactly 64 LOWERCASE HEX.\n // trail generated 2000 keys through @broberg/apikey's generateKey('trail')\n // and counted the alphabet: 0-9a-f only, length 64-64, no `-`, no `_`. Source\n // is `${prefix}_${randomBytes(bytes).toString(\"hex\")}`, bytes=32, with a hard\n // floor of 16 — so even a future caller asking for the minimum yields 32 hex\n // chars, still above {20,}.\n //\n // Recorded because the QUESTION is easy to re-ask and the ANSWER is not: this\n // is one of the few patterns here assuming alphanumerics only, and had trail\n // used base64url (the more common one-liner) the `-` and `_` would break the\n // run, {20,} would never be satisfied, and the WHOLE key would pass through\n // unredacted — not partially, entirely. Measured rather than assumed, because\n // two sampled keys cannot tell hex from base64url that has not hit a `-` yet.\n label: 'trail-key',\n description: 'Trail personal API key (trail_ + 64 hex; verified 2026-08-28)',\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 * Is this candidate plausibly a secret VALUE, or just the next word in a\n * sentence?\n *\n * F035.11 — WITHOUT THIS, THE AXIS EATS PROSE. The pattern above is\n * label + separator + `\\S+`, and in Danish and English «secret:» is ordinary\n * text. Measured on the published 0.5.1:\n *\n * 'Set som secret: gh secret set MYPAT'\n * -> 'Set som secret: [REDACTED:announced-secret] secret set MYPAT'\n * 'jeg siger det aldrig — secret: ALDRIG'\n * -> 'jeg siger det aldrig — secret: [REDACTED:announced-secret]'\n *\n * THE RULE IS DERIVED FROM buddy's NUMBERS, not chosen. Over 40,369 rows of real\n * fleet prose (23,801 intercom messages + 16,568 conversation turns) they found\n * 49 unique candidates after an announcing label. **35 of them were prose, and\n * every one of those 35 was under 16 characters with no digit** — \"kun\", \"jeg\",\n * \"gh\", \"aldrig\", \"ALDRIG\", \"»\". Zero were hex-like. And the axis caught ZERO\n * real secrets that the format rules had not already caught.\n *\n * So: a candidate with no digit, shorter than 16 characters, is prose.\n *\n * THE COST, STATED RATHER THAN HIDDEN, in the house style of the `kode` removal\n * above: `Adgangskode: correcthorse` is no longer detected. That is a real way to\n * write a real password. It is accepted deliberately — an over-broad redaction\n * destroys a corpus as effectively as a narrow one leaks it (buddy's framing),\n * and this axis is the one running over human prose. A value with any digit, or\n * any value of real key length, is unaffected: `hunter2` still goes.\n *\n * The judgement is on the CANDIDATE, never on the label. Narrowing the label list\n * would leave the same greedy `\\S+` behind every label that remained.\n */\nfunction plausibleSecretValue(candidate: string): boolean {\n return /\\d/.test(candidate) || candidate.length >= 16;\n}\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(\n ANNOUNCED_SECRET,\n (match: string, prefix: string, value: string) => {\n // An implausible candidate is left EXACTLY as it was — byte for byte,\n // including the label. `match` rather than `prefix + value` on purpose:\n // the two are identical today (the two groups ARE the whole match, which\n // is why the mutation pass records this as equivalent and unkillable),\n // and they stop being identical the moment anyone adds a group or lets\n // the regex match something the groups do not cover. Returning what was\n // actually matched cannot drift; rebuilding it can.\n if (!plausibleSecretValue(value)) return match;\n count++;\n return prefix + redactionMarker(ANNOUNCED_LABEL);\n },\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 // ROUTED THROUGH THE SAME PREDICATE as redactSecrets on purpose. A bare\n // `.test()` here would answer \"yes\" for a string redactSecrets leaves\n // untouched, and the two would disagree about the same input — which is worse\n // than either answer, because a caller can only ever ask one of them.\n ANNOUNCED_SECRET.lastIndex = 0;\n for (let m = ANNOUNCED_SECRET.exec(text); m !== null; m = ANNOUNCED_SECRET.exec(text)) {\n if (plausibleSecretValue(m[2] ?? '')) {\n ANNOUNCED_SECRET.lastIndex = 0;\n return true;\n }\n }\n return false;\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
@@ -160,8 +160,21 @@ var SECRET_PATTERNS = [
160
160
  regex: /\bpiw_[0-9a-f]{64}/g
161
161
  },
162
162
  {
163
+ // VERIFIED WITH THE OWNER, 2026-08-28: `trail_` + exactly 64 LOWERCASE HEX.
164
+ // trail generated 2000 keys through @broberg/apikey's generateKey('trail')
165
+ // and counted the alphabet: 0-9a-f only, length 64-64, no `-`, no `_`. Source
166
+ // is `${prefix}_${randomBytes(bytes).toString("hex")}`, bytes=32, with a hard
167
+ // floor of 16 — so even a future caller asking for the minimum yields 32 hex
168
+ // chars, still above {20,}.
169
+ //
170
+ // Recorded because the QUESTION is easy to re-ask and the ANSWER is not: this
171
+ // is one of the few patterns here assuming alphanumerics only, and had trail
172
+ // used base64url (the more common one-liner) the `-` and `_` would break the
173
+ // run, {20,} would never be satisfied, and the WHOLE key would pass through
174
+ // unredacted — not partially, entirely. Measured rather than assumed, because
175
+ // two sampled keys cannot tell hex from base64url that has not hit a `-` yet.
163
176
  label: "trail-key",
164
- description: "Trail personal API key (trail_\u2026)",
177
+ description: "Trail personal API key (trail_ + 64 hex; verified 2026-08-28)",
165
178
  regex: /\btrail_[A-Za-z0-9]{20,}/g
166
179
  },
167
180
  {
@@ -297,7 +310,10 @@ var VALUE_ONLY_PATTERNS = [
297
310
  }
298
311
  ];
299
312
  var ANNOUNCED_LABEL = "announced-secret";
300
- var ANNOUNCED_SECRET = /(\b(?:adgangskode|kodeord|hemmelighed|password|passwd|api[ -]?key|apinøgle|secret|pwd)\s*[:=]\s*)(?!\[REDACTED:)\S+/gi;
313
+ var ANNOUNCED_SECRET = /(\b(?:adgangskode|kodeord|hemmelighed|password|passwd|api[ -]?key|apinøgle|secret|pwd)\s*[:=]\s*)(?!\[REDACTED:)(\S+)/gi;
314
+ function plausibleSecretValue(candidate) {
315
+ return /\d/.test(candidate) || candidate.length >= 16;
316
+ }
301
317
  var redactionMarker = (label) => `[REDACTED:${label}]`;
302
318
  function patternsFor(opts) {
303
319
  return opts?.extraPatterns && opts.extraPatterns.length > 0 ? [...SECRET_PATTERNS, ...opts.extraPatterns] : SECRET_PATTERNS;
@@ -317,10 +333,14 @@ function redactSecrets(text, opts) {
317
333
  }
318
334
  if (opts?.announced) {
319
335
  let count = 0;
320
- const redactedAnnounced = redacted.replace(ANNOUNCED_SECRET, (_match, prefix) => {
321
- count++;
322
- return prefix + redactionMarker(ANNOUNCED_LABEL);
323
- });
336
+ const redactedAnnounced = redacted.replace(
337
+ ANNOUNCED_SECRET,
338
+ (match, prefix, value) => {
339
+ if (!plausibleSecretValue(value)) return match;
340
+ count++;
341
+ return prefix + redactionMarker(ANNOUNCED_LABEL);
342
+ }
343
+ );
324
344
  if (count > 0) {
325
345
  redacted = redactedAnnounced;
326
346
  findings.push({ label: ANNOUNCED_LABEL, count, confidence: "announced" });
@@ -331,7 +351,13 @@ function redactSecrets(text, opts) {
331
351
  function hasAnnouncedSecret(text) {
332
352
  if (!text) return false;
333
353
  ANNOUNCED_SECRET.lastIndex = 0;
334
- return ANNOUNCED_SECRET.test(text);
354
+ for (let m = ANNOUNCED_SECRET.exec(text); m !== null; m = ANNOUNCED_SECRET.exec(text)) {
355
+ if (plausibleSecretValue(m[2] ?? "")) {
356
+ ANNOUNCED_SECRET.lastIndex = 0;
357
+ return true;
358
+ }
359
+ }
360
+ return false;
335
361
  }
336
362
  function hasSecret(text, opts) {
337
363
  if (opts?.announced && hasAnnouncedSecret(text)) return true;
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 //\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"]}
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 // VERIFIED WITH THE OWNER, 2026-08-28: `trail_` + exactly 64 LOWERCASE HEX.\n // trail generated 2000 keys through @broberg/apikey's generateKey('trail')\n // and counted the alphabet: 0-9a-f only, length 64-64, no `-`, no `_`. Source\n // is `${prefix}_${randomBytes(bytes).toString(\"hex\")}`, bytes=32, with a hard\n // floor of 16 — so even a future caller asking for the minimum yields 32 hex\n // chars, still above {20,}.\n //\n // Recorded because the QUESTION is easy to re-ask and the ANSWER is not: this\n // is one of the few patterns here assuming alphanumerics only, and had trail\n // used base64url (the more common one-liner) the `-` and `_` would break the\n // run, {20,} would never be satisfied, and the WHOLE key would pass through\n // unredacted — not partially, entirely. Measured rather than assumed, because\n // two sampled keys cannot tell hex from base64url that has not hit a `-` yet.\n label: 'trail-key',\n description: 'Trail personal API key (trail_ + 64 hex; verified 2026-08-28)',\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 * Is this candidate plausibly a secret VALUE, or just the next word in a\n * sentence?\n *\n * F035.11 — WITHOUT THIS, THE AXIS EATS PROSE. The pattern above is\n * label + separator + `\\S+`, and in Danish and English «secret:» is ordinary\n * text. Measured on the published 0.5.1:\n *\n * 'Set som secret: gh secret set MYPAT'\n * -> 'Set som secret: [REDACTED:announced-secret] secret set MYPAT'\n * 'jeg siger det aldrig — secret: ALDRIG'\n * -> 'jeg siger det aldrig — secret: [REDACTED:announced-secret]'\n *\n * THE RULE IS DERIVED FROM buddy's NUMBERS, not chosen. Over 40,369 rows of real\n * fleet prose (23,801 intercom messages + 16,568 conversation turns) they found\n * 49 unique candidates after an announcing label. **35 of them were prose, and\n * every one of those 35 was under 16 characters with no digit** — \"kun\", \"jeg\",\n * \"gh\", \"aldrig\", \"ALDRIG\", \"»\". Zero were hex-like. And the axis caught ZERO\n * real secrets that the format rules had not already caught.\n *\n * So: a candidate with no digit, shorter than 16 characters, is prose.\n *\n * THE COST, STATED RATHER THAN HIDDEN, in the house style of the `kode` removal\n * above: `Adgangskode: correcthorse` is no longer detected. That is a real way to\n * write a real password. It is accepted deliberately — an over-broad redaction\n * destroys a corpus as effectively as a narrow one leaks it (buddy's framing),\n * and this axis is the one running over human prose. A value with any digit, or\n * any value of real key length, is unaffected: `hunter2` still goes.\n *\n * The judgement is on the CANDIDATE, never on the label. Narrowing the label list\n * would leave the same greedy `\\S+` behind every label that remained.\n */\nfunction plausibleSecretValue(candidate: string): boolean {\n return /\\d/.test(candidate) || candidate.length >= 16;\n}\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(\n ANNOUNCED_SECRET,\n (match: string, prefix: string, value: string) => {\n // An implausible candidate is left EXACTLY as it was — byte for byte,\n // including the label. `match` rather than `prefix + value` on purpose:\n // the two are identical today (the two groups ARE the whole match, which\n // is why the mutation pass records this as equivalent and unkillable),\n // and they stop being identical the moment anyone adds a group or lets\n // the regex match something the groups do not cover. Returning what was\n // actually matched cannot drift; rebuilding it can.\n if (!plausibleSecretValue(value)) return match;\n count++;\n return prefix + redactionMarker(ANNOUNCED_LABEL);\n },\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 // ROUTED THROUGH THE SAME PREDICATE as redactSecrets on purpose. A bare\n // `.test()` here would answer \"yes\" for a string redactSecrets leaves\n // untouched, and the two would disagree about the same input — which is worse\n // than either answer, because a caller can only ever ask one of them.\n ANNOUNCED_SECRET.lastIndex = 0;\n for (let m = ANNOUNCED_SECRET.exec(text); m !== null; m = ANNOUNCED_SECRET.exec(text)) {\n if (plausibleSecretValue(m[2] ?? '')) {\n ANNOUNCED_SECRET.lastIndex = 0;\n return true;\n }\n }\n return false;\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.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.",
3
+ "version": "0.6.0",
4
+ "description": "Pure, dependency-free secret/credential redaction for the broberg.ai fleet — 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,
@@ -17,7 +17,8 @@
17
17
  "types": "./dist/index.d.ts",
18
18
  "import": "./dist/index.js",
19
19
  "require": "./dist/index.cjs"
20
- }
20
+ },
21
+ "./package.json": "./package.json"
21
22
  },
22
23
  "scripts": {
23
24
  "build": "tsup",