@broberg/secret-scan 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -103,6 +103,14 @@ regexes — most-specific first so attribution is correct:
103
103
  secrets are caught only via the `labeled-hex-secret` name-context rule.
104
104
  - **Order is API** — specific patterns run before generic ones (`sk-ant-` before
105
105
  `sk-`); a test asserts it.
106
+ - **`hue-application-key` is the one unprefixed shape, and it runs LAST.** A Hue
107
+ v2 key is 40 chars of `[A-Za-z0-9-]` with nothing to anchor on, so the regex
108
+ carries a negative lookahead — `\b(?![0-9a-f]{40}\b)[A-Za-z0-9-]{40}\b` — that
109
+ excludes **git commit SHAs**. This is not an optimisation: telemetry and error
110
+ output are full of SHAs, and a redactor that mangles commit hashes gets turned
111
+ off within a week, after which it protects nothing. Hue keys are mixed-case,
112
+ SHAs are lowercase hex. Do not "simplify" the lookahead away; `test/hue-key.test.ts`
113
+ asserts real SHAs stay untouched, standalone and in prose.
106
114
 
107
115
  ## API
108
116
 
package/dist/index.cjs CHANGED
@@ -241,6 +241,21 @@ var SECRET_PATTERNS = [
241
241
  label: "cloudflare-global-key",
242
242
  description: "Cloudflare global API key (37-hex)",
243
243
  regex: /\b[0-9a-f]{37}\b/g
244
+ },
245
+ {
246
+ // LAST on purpose: this is the only unprefixed shape in the list, so every
247
+ // anchored pattern above must get first refusal.
248
+ //
249
+ // Philips Hue v2 application key — 40 chars of [A-Za-z0-9-] with NO prefix,
250
+ // so there is nothing to anchor on. The negative lookahead is load-bearing,
251
+ // not decoration: a bare [A-Za-z0-9-]{40} also matches a GIT COMMIT SHA, and
252
+ // telemetry/error output is full of those. A redactor that eats commit
253
+ // hashes gets switched off within a week, after which it protects nothing.
254
+ // Hue keys are mixed-case; SHAs are lowercase hex — that asymmetry is the
255
+ // whole guard. (Pattern contributed + field-tested by beacon, F035.7.)
256
+ label: "hue-application-key",
257
+ description: "Philips Hue v2 application key (40 chars, no prefix)",
258
+ regex: /\b(?![0-9a-f]{40}\b)[A-Za-z0-9-]{40}\b/g
244
259
  }
245
260
  ];
246
261
  var redactionMarker = (label) => `[REDACTED:${label}]`;
@@ -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 // 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\nexport interface RedactionFinding {\n label: string;\n count: number;\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 = clean) */\n findings: RedactionFinding[];\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\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 */\nexport function redactSecrets(text: string, opts?: RedactOptions): RedactionResult {\n if (!text) return { redacted: text, findings: [] };\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 });\n }\n return { redacted, findings };\n}\n\n/** True if `text` contains at least one detectable secret. */\nexport function hasSecret(text: string, opts?: RedactOptions): boolean {\n return patternsFor(opts).some((p) => {\n p.regex.lastIndex = 0;\n return p.regex.test(text);\n });\n}\n\nexport interface ClassifyResult {\n /** the matching pattern's stable label (e.g. `openai-api-key`) */\n label: string;\n /** the matching pattern's human description (e.g. `OpenAI API key (sk-… / sk-proj-…)`) */\n description: string;\n}\n\n/**\n * Classify a SINGLE pasted token — the INVERSE of redaction. Returns the first\n * (most-specific) pattern the value matches, or `null`. Backs a \"paste a key →\n * detect its type\" UI (cardmem F214 Secrets Vault) so every consumer shares the\n * same classification, not just the same redaction.\n *\n * First-match-wins over the ordered `SECRET_PATTERNS`, so `sk-ant-…` classifies\n * as `anthropic-api-key`, never the generic `openai-api-key`. Field-anchored\n * context-only patterns (mistral / vimeo / cloudflare-api-token /\n * labeled-hex-secret / deepseek-fallback) only match when the pasted value\n * includes their `NAME=` context; a bare provider token classifies via its\n * prefix pattern, and a prefix-less bare token (e.g. a raw Mistral key) is\n * genuinely unidentifiable → `null`. `opts.extraPatterns` run AFTER the\n * canonical set (canonical attribution wins). Input is trimmed; empty /\n * whitespace-only → `null`.\n */\nexport function classify(value: string, opts?: RedactOptions): ClassifyResult | null {\n if (!value) return null;\n const v = value.trim();\n if (!v) return null;\n for (const p of patternsFor(opts)) {\n p.regex.lastIndex = 0;\n if (p.regex.test(v)) return { label: p.label, description: p.description };\n }\n return null;\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @broberg/secret-scan — fleet secret/credential redaction.\n *\n * `redactSecrets(text)` replaces every matched secret with `[REDACTED:<label>]`\n * and reports what it found. PURE + deterministic (regex/string only, no deps,\n * no I/O) so an engine write-gate, an egress scrub, a CLI, an admin preview UI,\n * and any repo all share the EXACT same detection — and it's trivially testable.\n *\n * Lifted verbatim from broberg/trail F197 (the second-brain safeguard); see\n * docs/features/F035-secret-scan.md. components owns + publishes this; @trail/shared\n * re-exports it.\n *\n * Design choices:\n * - Pattern-based, NOT entropy/generic-randomness — a redacted real fact would\n * corrupt knowledge, so we accept missing an exotic token over false positives.\n * - Order matters: most-specific patterns run first (e.g. `sk-ant-` before the\n * generic OpenAI `sk-`; `sk-or-v1-` before `sk-`), because each match is\n * consumed before the next pattern runs → order = attribution.\n * - Redact, never reject — the surrounding knowledge survives; only the\n * credential substring is neutralised.\n * - NEVER a bare high-entropy/hex pattern (it would hit git shas/hashes).\n * Prefix-less service secrets are caught only via `labeled-hex-secret` (a 40+\n * hex value assigned to a secret/token/password/api-key-named field).\n *\n * Two recommended integration shapes for consumers:\n * (a) write boundary — `redactSecrets(text)` before persist (ingest gate);\n * (b) egress — scrub before a value leaves to a user/LLM (highest-value guard).\n */\n\nexport interface SecretPattern {\n /** stable id shown in the redaction marker + findings */\n label: string;\n /** human description of what this matches */\n description: string;\n /** global regex (used for replace-all + counting) */\n regex: RegExp;\n}\n\n/** Ordered most-specific → least. Every regex carries the `g` flag. */\nexport const SECRET_PATTERNS: SecretPattern[] = [\n {\n label: 'private-key',\n description: 'PEM private key block (RSA/EC/OPENSSH/DSA/PGP)',\n regex:\n /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\\s\\S]*?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g,\n },\n {\n label: 'anthropic-api-key',\n description: 'Anthropic API key (sk-ant-…)',\n regex: /sk-ant-(?:api03-)?[A-Za-z0-9_-]{20,}/g,\n },\n {\n // OpenRouter — distinct from OpenAI; runs BEFORE the generic sk- (which would\n // otherwise also match + mislabel it).\n label: 'openrouter-api-key',\n description: 'OpenRouter API key (sk-or-v1- + 64 hex)',\n regex: /\\bsk-or-v1-[0-9a-f]{64}/g,\n },\n {\n // DeepSeek — shares the sk- prefix with OpenAI, so it MUST run before the\n // generic openai pattern (specific-before-generic = correct attribution).\n // DeepSeek's documented shape is sk- + 32 lowercase hex (GitGuardian confirms\n // an sk- prefix but hides the exact regex); the hex-only body + {32,} length\n // distinguishes it from OpenAI's mixed-case base62 keys, so a real OpenAI key\n // is never mislabelled. The field-anchored fallback below catches any\n // DEEPSEEK_API_KEY value that doesn't fit this canonical shape.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (sk- + 32 lowercase hex)',\n regex: /\\bsk-[0-9a-f]{32,}(?![0-9a-z])/g,\n },\n {\n label: 'openai-api-key',\n description: 'OpenAI API key (sk-… / sk-proj-…)',\n regex: /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/g,\n },\n {\n // ElevenLabs — sk_ with UNDERSCORE (vs OpenAI sk-), 48 hex.\n label: 'elevenlabs-api-key',\n description: 'ElevenLabs API key (sk_ + 48 hex)',\n regex: /\\bsk_[0-9a-f]{48}\\b/g,\n },\n {\n // fal.ai — uuid:hex32 (key_id:key_secret); the colon is the signal.\n label: 'fal-api-key',\n description: 'fal.ai key (uuid:hex32)',\n regex: /\\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:[0-9a-f]{32}\\b/g,\n },\n {\n // Black Forest Labs (FLUX) API key — bfl_ prefix + a long token (sample\n // bfl_Qo1…). The distinctive prefix + {20,} length keeps false positives near\n // zero; image-provider sibling of the fal key above.\n label: 'bfl-api-key',\n description: 'Black Forest Labs / FLUX API key (bfl_ + token)',\n regex: /\\bbfl_[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'google-api-key',\n description: 'Google / Gemini API key (AIza…)',\n regex: /AIza[0-9A-Za-z_-]{35}/g,\n },\n {\n label: 'google-oauth-secret',\n description: 'Google OAuth client secret (GOCSPX-…)',\n regex: /GOCSPX-[A-Za-z0-9_-]{28}/g,\n },\n {\n label: 'aws-access-key-id',\n description: 'AWS access key id (AKIA…)',\n regex: /\\bAKIA[0-9A-Z]{16}\\b/g,\n },\n {\n label: 'github-token',\n description: 'GitHub token (ghp_/gho_/ghs_/ghu_/ghr_…)',\n regex: /\\bgh[posru]_[A-Za-z0-9]{36,}\\b/g,\n },\n {\n // GitHub fine-grained PAT — distinct prefix `github_pat_` (not caught by the\n // classic gh[posru]_ above), then base62 + a `_` separator (~82 chars total).\n // The prefix is so distinctive that {50,} keeps false positives at zero.\n label: 'github-fine-grained-pat',\n description: 'GitHub fine-grained personal access token (github_pat_…)',\n regex: /\\bgithub_pat_[A-Za-z0-9_]{50,}/g,\n },\n {\n label: 'gitlab-token',\n description: 'GitLab personal access token (glpat-…)',\n regex: /\\bglpat-[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'slack-token',\n description: 'Slack token (xox[baprs]-…)',\n regex: /\\bxox[baprs]-[A-Za-z0-9-]{10,}/g,\n },\n {\n label: 'stripe-secret-key',\n description: 'Stripe live secret/restricted key (sk_live_/rk_live_…)',\n regex: /\\b[rs]k_live_[A-Za-z0-9]{20,}/g,\n },\n {\n // Resend (re_…). Lookahead requires a digit in the body so we don't redact\n // long snake_case identifiers like re_compute_the_thing.\n label: 'resend-api-key',\n description: 'Resend API key (re_ + token)',\n regex: /\\bre_(?=[A-Za-z0-9_]*\\d)[A-Za-z0-9_]{24,}\\b/g,\n },\n {\n label: 'supabase-access-token',\n description: 'Supabase personal/management access token (sbp_ + 40 hex)',\n regex: /\\bsbp_[0-9a-f]{40}/g,\n },\n {\n label: 'supabase-secret-key',\n description: 'Supabase secret API key (sb_secret_…)',\n regex: /\\bsb_secret_[A-Za-z0-9_-]{20,}/g,\n },\n {\n // Used by every @broberg/* publish — the highest-value leak from a .env / commit history.\n label: 'npm-token',\n description: 'npm publish/automation token (npm_ + 36 base62)',\n regex: /\\bnpm_[A-Za-z0-9]{36}\\b/g,\n },\n {\n label: 'fly-api-token',\n description: 'Fly.io API token (FlyV1 fm2_… / fo1_…)',\n regex: /(?:FlyV1 fm2_[A-Za-z0-9+/=_-]{20,}|\\bfo1_[A-Za-z0-9_-]{20,})/g,\n },\n {\n // Also covers Turso DB/platform auth tokens AND Supabase anon/service_role\n // keys — both are JWTs (eyJ…), so the single JWT pattern catches them.\n label: 'jwt',\n description: 'JSON Web Token (eyJ…) — incl. Turso + Supabase service_role tokens',\n regex: /\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}/g,\n },\n {\n // genApiKey = randomBytes(24).hex → uk_ + exactly 48 lowercase hex.\n label: 'upmetrics-key',\n description: 'Upmetrics project key (uk_ + 48 hex)',\n regex: /\\buk_[0-9a-f]{48}/g,\n },\n {\n label: 'cardmem-key',\n description: 'Cardmem personal/incident/project key (pa_/pi_/pk_ + 64 hex)',\n regex: /\\bp[aik]_[A-Za-z0-9]{20,}/g,\n },\n {\n // cardmem inbox-webhook key — piw_ isn't matched by p[aik]_ above (3rd char 'w' ≠ '_').\n label: 'cardmem-webhook-key',\n description: 'Cardmem inbox-webhook key (piw_ + 64 hex)',\n regex: /\\bpiw_[0-9a-f]{64}/g,\n },\n {\n label: 'trail-key',\n description: 'Trail personal API key (trail_…)',\n regex: /\\btrail_[A-Za-z0-9]{20,}/g,\n },\n {\n // Cronjobs API key (cronjobs.webhouse.net) — cj_ + randomBytes(32).base64url =\n // exactly 43 base64url chars (46 total). Prefix + fixed length = very low FP.\n // The UI's truncated cj_<8 chars>… preview is shorter than {43} → not matched.\n // Negative lookahead (not \\b) because base64url's `-` breaks a trailing \\b.\n label: 'cronjobs-api-key',\n description: 'Cronjobs API key (cj_ + 43 base64url)',\n regex: /\\bcj_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g,\n },\n {\n // randomBytes(32).hex → wh_ + 64 lowercase hex (67 chars total).\n label: 'cms-access-token',\n description: 'webhouse.app CMS access token (wh_ + 64 hex)',\n regex: /\\bwh_[0-9a-f]{64}/g,\n },\n {\n // Cloudflare API token (R2 / DNS management) — 40 base64url chars, NO prefix.\n // A bare {40} would false-positive broadly, so this is CONTEXT-ONLY: it only\n // fires next to a cf/cloudflare-api-token-named field. Runs before\n // labeled-hex-secret so a hex-valued CF token is attributed correctly.\n label: 'cloudflare-api-token',\n description: 'Cloudflare API token (cf/cloudflare-api-token field + 40 base64url)',\n regex: /\\b(?:cf|cloudflare)_?api_?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{40}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Mistral API key — prefix-less ~32 base62 (Christian-confirmed sample). A bare\n // [A-Za-z0-9]{32} would FP on every ID/hash, so CONTEXT-ONLY: anchored on a\n // mistral-(api-)key/token-named field. Runs before labeled-hex for attribution.\n label: 'mistral-api-key',\n description: 'Mistral API key (mistral-(api-)key/token field + 24+ base62)',\n regex: /\\bmistral(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{24,}(?![A-Za-z0-9])/gi,\n },\n {\n // DeepSeek — field-anchored fallback for any DEEPSEEK_API_KEY/TOKEN value that\n // doesn't fit the canonical sk-+hex shape (mirrors the Mistral context-only\n // approach). The field name is the signal → near-zero false positives. The\n // sk-+hex format pattern above already attributes the canonical shape; this\n // backstops a format change or an opaque token.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (deepseek-(api-)key/token field + 20+ token)',\n regex: /\\bdeepseek(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Vimeo personal access token — ~32 lowercase hex, no prefix (sanne). A bare\n // hex32 would FP massively (MD5/UUID), so CONTEXT-ONLY: anchored on a\n // vimeo-(access-)token-named field.\n label: 'vimeo-access-token',\n description: 'Vimeo access token (vimeo-(access-)token field + 20+ base62)',\n regex: /\\bvimeo(?:[_-]?access)?[_-]?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{20,}(?![A-Za-z0-9])/gi,\n },\n {\n // Context-based catch for prefix-less high-entropy service secrets\n // (CMS_JWT_SECRET, revalidateSecret, fleet openssl-rand-hex secrets): a 40+\n // hex value assigned to a field whose name contains\n // secret/token/password/api-key. The name requirement keeps the\n // false-positive rate near zero (a bare 40/64-hex would hit shas/hashes).\n label: 'labeled-hex-secret',\n description: 'A 40+ hex value assigned to a secret/token/password/api-key-named field',\n regex: /\\b[A-Za-z0-9_-]*(?:secret|token|password|api[_-]?key)\\b\\s*[:=]\\s*[\"'`]?[0-9a-f]{40,}/gi,\n },\n {\n // Discord bot token — three base64url segments. Anchored both sides so it\n // can't partial-match a longer dotted string.\n label: 'discord-bot-token',\n description: 'Discord bot token (3 base64url segments)',\n regex: /(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{24,26}\\.[A-Za-z0-9_-]{6}\\.[A-Za-z0-9_-]{27,40}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'discord-mfa-token',\n description: 'Discord MFA token (mfa. + 84 chars)',\n regex: /\\bmfa\\.[A-Za-z0-9_-]{84}\\b/g,\n },\n {\n // Cloudflare Turnstile PROD secret (sanne, verified 2/2) — 0x4 + 6×A prefix,\n // then 26 base64url (35 total). The 24-char SITE key + 1x/2x/3x TEST keys are\n // intentionally NOT matched (the {26} length gate misses them) so a public\n // key is never redacted.\n label: 'cloudflare-turnstile-secret',\n description: 'Cloudflare Turnstile secret key (0x4AAAAAA + 26 base64url, 35 total)',\n regex: /0x4AAAAAA[A-Za-z0-9_-]{26}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'cloudflare-global-key',\n description: 'Cloudflare global API key (37-hex)',\n regex: /\\b[0-9a-f]{37}\\b/g,\n },\n {\n // LAST on purpose: this is the only unprefixed shape in the list, so every\n // anchored pattern above must get first refusal.\n //\n // Philips Hue v2 application key — 40 chars of [A-Za-z0-9-] with NO prefix,\n // so there is nothing to anchor on. The negative lookahead is load-bearing,\n // not decoration: a bare [A-Za-z0-9-]{40} also matches a GIT COMMIT SHA, and\n // telemetry/error output is full of those. A redactor that eats commit\n // hashes gets switched off within a week, after which it protects nothing.\n // Hue keys are mixed-case; SHAs are lowercase hex — that asymmetry is the\n // whole guard. (Pattern contributed + field-tested by beacon, F035.7.)\n label: 'hue-application-key',\n description: 'Philips Hue v2 application key (40 chars, no prefix)',\n regex: /\\b(?![0-9a-f]{40}\\b)[A-Za-z0-9-]{40}\\b/g,\n },\n];\n\nexport interface RedactionFinding {\n label: string;\n count: number;\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 = clean) */\n findings: RedactionFinding[];\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\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 */\nexport function redactSecrets(text: string, opts?: RedactOptions): RedactionResult {\n if (!text) return { redacted: text, findings: [] };\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 });\n }\n return { redacted, findings };\n}\n\n/** True if `text` contains at least one detectable secret. */\nexport function hasSecret(text: string, opts?: RedactOptions): boolean {\n return patternsFor(opts).some((p) => {\n p.regex.lastIndex = 0;\n return p.regex.test(text);\n });\n}\n\nexport interface ClassifyResult {\n /** the matching pattern's stable label (e.g. `openai-api-key`) */\n label: string;\n /** the matching pattern's human description (e.g. `OpenAI API key (sk-… / sk-proj-…)`) */\n description: string;\n}\n\n/**\n * Classify a SINGLE pasted token — the INVERSE of redaction. Returns the first\n * (most-specific) pattern the value matches, or `null`. Backs a \"paste a key →\n * detect its type\" UI (cardmem F214 Secrets Vault) so every consumer shares the\n * same classification, not just the same redaction.\n *\n * First-match-wins over the ordered `SECRET_PATTERNS`, so `sk-ant-…` classifies\n * as `anthropic-api-key`, never the generic `openai-api-key`. Field-anchored\n * context-only patterns (mistral / vimeo / cloudflare-api-token /\n * labeled-hex-secret / deepseek-fallback) only match when the pasted value\n * includes their `NAME=` context; a bare provider token classifies via its\n * prefix pattern, and a prefix-less bare token (e.g. a raw Mistral key) is\n * genuinely unidentifiable → `null`. `opts.extraPatterns` run AFTER the\n * canonical set (canonical attribution wins). Input is trimmed; empty /\n * whitespace-only → `null`.\n */\nexport function classify(value: string, opts?: RedactOptions): ClassifyResult | null {\n if (!value) return null;\n const v = value.trim();\n if (!v) return null;\n for (const p of patternsFor(opts)) {\n p.regex.lastIndex = 0;\n if (p.regex.test(v)) return { label: p.label, description: p.description };\n }\n return null;\n}\n"]}
package/dist/index.js CHANGED
@@ -239,6 +239,21 @@ var SECRET_PATTERNS = [
239
239
  label: "cloudflare-global-key",
240
240
  description: "Cloudflare global API key (37-hex)",
241
241
  regex: /\b[0-9a-f]{37}\b/g
242
+ },
243
+ {
244
+ // LAST on purpose: this is the only unprefixed shape in the list, so every
245
+ // anchored pattern above must get first refusal.
246
+ //
247
+ // Philips Hue v2 application key — 40 chars of [A-Za-z0-9-] with NO prefix,
248
+ // so there is nothing to anchor on. The negative lookahead is load-bearing,
249
+ // not decoration: a bare [A-Za-z0-9-]{40} also matches a GIT COMMIT SHA, and
250
+ // telemetry/error output is full of those. A redactor that eats commit
251
+ // hashes gets switched off within a week, after which it protects nothing.
252
+ // Hue keys are mixed-case; SHAs are lowercase hex — that asymmetry is the
253
+ // whole guard. (Pattern contributed + field-tested by beacon, F035.7.)
254
+ label: "hue-application-key",
255
+ description: "Philips Hue v2 application key (40 chars, no prefix)",
256
+ regex: /\b(?![0-9a-f]{40}\b)[A-Za-z0-9-]{40}\b/g
242
257
  }
243
258
  ];
244
259
  var redactionMarker = (label) => `[REDACTED:${label}]`;
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 // 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\nexport interface RedactionFinding {\n label: string;\n count: number;\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 = clean) */\n findings: RedactionFinding[];\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\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 */\nexport function redactSecrets(text: string, opts?: RedactOptions): RedactionResult {\n if (!text) return { redacted: text, findings: [] };\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 });\n }\n return { redacted, findings };\n}\n\n/** True if `text` contains at least one detectable secret. */\nexport function hasSecret(text: string, opts?: RedactOptions): boolean {\n return patternsFor(opts).some((p) => {\n p.regex.lastIndex = 0;\n return p.regex.test(text);\n });\n}\n\nexport interface ClassifyResult {\n /** the matching pattern's stable label (e.g. `openai-api-key`) */\n label: string;\n /** the matching pattern's human description (e.g. `OpenAI API key (sk-… / sk-proj-…)`) */\n description: string;\n}\n\n/**\n * Classify a SINGLE pasted token — the INVERSE of redaction. Returns the first\n * (most-specific) pattern the value matches, or `null`. Backs a \"paste a key →\n * detect its type\" UI (cardmem F214 Secrets Vault) so every consumer shares the\n * same classification, not just the same redaction.\n *\n * First-match-wins over the ordered `SECRET_PATTERNS`, so `sk-ant-…` classifies\n * as `anthropic-api-key`, never the generic `openai-api-key`. Field-anchored\n * context-only patterns (mistral / vimeo / cloudflare-api-token /\n * labeled-hex-secret / deepseek-fallback) only match when the pasted value\n * includes their `NAME=` context; a bare provider token classifies via its\n * prefix pattern, and a prefix-less bare token (e.g. a raw Mistral key) is\n * genuinely unidentifiable → `null`. `opts.extraPatterns` run AFTER the\n * canonical set (canonical attribution wins). Input is trimmed; empty /\n * whitespace-only → `null`.\n */\nexport function classify(value: string, opts?: RedactOptions): ClassifyResult | null {\n if (!value) return null;\n const v = value.trim();\n if (!v) return null;\n for (const p of patternsFor(opts)) {\n p.regex.lastIndex = 0;\n if (p.regex.test(v)) return { label: p.label, description: p.description };\n }\n return null;\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @broberg/secret-scan — fleet secret/credential redaction.\n *\n * `redactSecrets(text)` replaces every matched secret with `[REDACTED:<label>]`\n * and reports what it found. PURE + deterministic (regex/string only, no deps,\n * no I/O) so an engine write-gate, an egress scrub, a CLI, an admin preview UI,\n * and any repo all share the EXACT same detection — and it's trivially testable.\n *\n * Lifted verbatim from broberg/trail F197 (the second-brain safeguard); see\n * docs/features/F035-secret-scan.md. components owns + publishes this; @trail/shared\n * re-exports it.\n *\n * Design choices:\n * - Pattern-based, NOT entropy/generic-randomness — a redacted real fact would\n * corrupt knowledge, so we accept missing an exotic token over false positives.\n * - Order matters: most-specific patterns run first (e.g. `sk-ant-` before the\n * generic OpenAI `sk-`; `sk-or-v1-` before `sk-`), because each match is\n * consumed before the next pattern runs → order = attribution.\n * - Redact, never reject — the surrounding knowledge survives; only the\n * credential substring is neutralised.\n * - NEVER a bare high-entropy/hex pattern (it would hit git shas/hashes).\n * Prefix-less service secrets are caught only via `labeled-hex-secret` (a 40+\n * hex value assigned to a secret/token/password/api-key-named field).\n *\n * Two recommended integration shapes for consumers:\n * (a) write boundary — `redactSecrets(text)` before persist (ingest gate);\n * (b) egress — scrub before a value leaves to a user/LLM (highest-value guard).\n */\n\nexport interface SecretPattern {\n /** stable id shown in the redaction marker + findings */\n label: string;\n /** human description of what this matches */\n description: string;\n /** global regex (used for replace-all + counting) */\n regex: RegExp;\n}\n\n/** Ordered most-specific → least. Every regex carries the `g` flag. */\nexport const SECRET_PATTERNS: SecretPattern[] = [\n {\n label: 'private-key',\n description: 'PEM private key block (RSA/EC/OPENSSH/DSA/PGP)',\n regex:\n /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\\s\\S]*?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g,\n },\n {\n label: 'anthropic-api-key',\n description: 'Anthropic API key (sk-ant-…)',\n regex: /sk-ant-(?:api03-)?[A-Za-z0-9_-]{20,}/g,\n },\n {\n // OpenRouter — distinct from OpenAI; runs BEFORE the generic sk- (which would\n // otherwise also match + mislabel it).\n label: 'openrouter-api-key',\n description: 'OpenRouter API key (sk-or-v1- + 64 hex)',\n regex: /\\bsk-or-v1-[0-9a-f]{64}/g,\n },\n {\n // DeepSeek — shares the sk- prefix with OpenAI, so it MUST run before the\n // generic openai pattern (specific-before-generic = correct attribution).\n // DeepSeek's documented shape is sk- + 32 lowercase hex (GitGuardian confirms\n // an sk- prefix but hides the exact regex); the hex-only body + {32,} length\n // distinguishes it from OpenAI's mixed-case base62 keys, so a real OpenAI key\n // is never mislabelled. The field-anchored fallback below catches any\n // DEEPSEEK_API_KEY value that doesn't fit this canonical shape.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (sk- + 32 lowercase hex)',\n regex: /\\bsk-[0-9a-f]{32,}(?![0-9a-z])/g,\n },\n {\n label: 'openai-api-key',\n description: 'OpenAI API key (sk-… / sk-proj-…)',\n regex: /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/g,\n },\n {\n // ElevenLabs — sk_ with UNDERSCORE (vs OpenAI sk-), 48 hex.\n label: 'elevenlabs-api-key',\n description: 'ElevenLabs API key (sk_ + 48 hex)',\n regex: /\\bsk_[0-9a-f]{48}\\b/g,\n },\n {\n // fal.ai — uuid:hex32 (key_id:key_secret); the colon is the signal.\n label: 'fal-api-key',\n description: 'fal.ai key (uuid:hex32)',\n regex: /\\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:[0-9a-f]{32}\\b/g,\n },\n {\n // Black Forest Labs (FLUX) API key — bfl_ prefix + a long token (sample\n // bfl_Qo1…). The distinctive prefix + {20,} length keeps false positives near\n // zero; image-provider sibling of the fal key above.\n label: 'bfl-api-key',\n description: 'Black Forest Labs / FLUX API key (bfl_ + token)',\n regex: /\\bbfl_[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'google-api-key',\n description: 'Google / Gemini API key (AIza…)',\n regex: /AIza[0-9A-Za-z_-]{35}/g,\n },\n {\n label: 'google-oauth-secret',\n description: 'Google OAuth client secret (GOCSPX-…)',\n regex: /GOCSPX-[A-Za-z0-9_-]{28}/g,\n },\n {\n label: 'aws-access-key-id',\n description: 'AWS access key id (AKIA…)',\n regex: /\\bAKIA[0-9A-Z]{16}\\b/g,\n },\n {\n label: 'github-token',\n description: 'GitHub token (ghp_/gho_/ghs_/ghu_/ghr_…)',\n regex: /\\bgh[posru]_[A-Za-z0-9]{36,}\\b/g,\n },\n {\n // GitHub fine-grained PAT — distinct prefix `github_pat_` (not caught by the\n // classic gh[posru]_ above), then base62 + a `_` separator (~82 chars total).\n // The prefix is so distinctive that {50,} keeps false positives at zero.\n label: 'github-fine-grained-pat',\n description: 'GitHub fine-grained personal access token (github_pat_…)',\n regex: /\\bgithub_pat_[A-Za-z0-9_]{50,}/g,\n },\n {\n label: 'gitlab-token',\n description: 'GitLab personal access token (glpat-…)',\n regex: /\\bglpat-[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'slack-token',\n description: 'Slack token (xox[baprs]-…)',\n regex: /\\bxox[baprs]-[A-Za-z0-9-]{10,}/g,\n },\n {\n label: 'stripe-secret-key',\n description: 'Stripe live secret/restricted key (sk_live_/rk_live_…)',\n regex: /\\b[rs]k_live_[A-Za-z0-9]{20,}/g,\n },\n {\n // Resend (re_…). Lookahead requires a digit in the body so we don't redact\n // long snake_case identifiers like re_compute_the_thing.\n label: 'resend-api-key',\n description: 'Resend API key (re_ + token)',\n regex: /\\bre_(?=[A-Za-z0-9_]*\\d)[A-Za-z0-9_]{24,}\\b/g,\n },\n {\n label: 'supabase-access-token',\n description: 'Supabase personal/management access token (sbp_ + 40 hex)',\n regex: /\\bsbp_[0-9a-f]{40}/g,\n },\n {\n label: 'supabase-secret-key',\n description: 'Supabase secret API key (sb_secret_…)',\n regex: /\\bsb_secret_[A-Za-z0-9_-]{20,}/g,\n },\n {\n // Used by every @broberg/* publish — the highest-value leak from a .env / commit history.\n label: 'npm-token',\n description: 'npm publish/automation token (npm_ + 36 base62)',\n regex: /\\bnpm_[A-Za-z0-9]{36}\\b/g,\n },\n {\n label: 'fly-api-token',\n description: 'Fly.io API token (FlyV1 fm2_… / fo1_…)',\n regex: /(?:FlyV1 fm2_[A-Za-z0-9+/=_-]{20,}|\\bfo1_[A-Za-z0-9_-]{20,})/g,\n },\n {\n // Also covers Turso DB/platform auth tokens AND Supabase anon/service_role\n // keys — both are JWTs (eyJ…), so the single JWT pattern catches them.\n label: 'jwt',\n description: 'JSON Web Token (eyJ…) — incl. Turso + Supabase service_role tokens',\n regex: /\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}/g,\n },\n {\n // genApiKey = randomBytes(24).hex → uk_ + exactly 48 lowercase hex.\n label: 'upmetrics-key',\n description: 'Upmetrics project key (uk_ + 48 hex)',\n regex: /\\buk_[0-9a-f]{48}/g,\n },\n {\n label: 'cardmem-key',\n description: 'Cardmem personal/incident/project key (pa_/pi_/pk_ + 64 hex)',\n regex: /\\bp[aik]_[A-Za-z0-9]{20,}/g,\n },\n {\n // cardmem inbox-webhook key — piw_ isn't matched by p[aik]_ above (3rd char 'w' ≠ '_').\n label: 'cardmem-webhook-key',\n description: 'Cardmem inbox-webhook key (piw_ + 64 hex)',\n regex: /\\bpiw_[0-9a-f]{64}/g,\n },\n {\n label: 'trail-key',\n description: 'Trail personal API key (trail_…)',\n regex: /\\btrail_[A-Za-z0-9]{20,}/g,\n },\n {\n // Cronjobs API key (cronjobs.webhouse.net) — cj_ + randomBytes(32).base64url =\n // exactly 43 base64url chars (46 total). Prefix + fixed length = very low FP.\n // The UI's truncated cj_<8 chars>… preview is shorter than {43} → not matched.\n // Negative lookahead (not \\b) because base64url's `-` breaks a trailing \\b.\n label: 'cronjobs-api-key',\n description: 'Cronjobs API key (cj_ + 43 base64url)',\n regex: /\\bcj_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g,\n },\n {\n // randomBytes(32).hex → wh_ + 64 lowercase hex (67 chars total).\n label: 'cms-access-token',\n description: 'webhouse.app CMS access token (wh_ + 64 hex)',\n regex: /\\bwh_[0-9a-f]{64}/g,\n },\n {\n // Cloudflare API token (R2 / DNS management) — 40 base64url chars, NO prefix.\n // A bare {40} would false-positive broadly, so this is CONTEXT-ONLY: it only\n // fires next to a cf/cloudflare-api-token-named field. Runs before\n // labeled-hex-secret so a hex-valued CF token is attributed correctly.\n label: 'cloudflare-api-token',\n description: 'Cloudflare API token (cf/cloudflare-api-token field + 40 base64url)',\n regex: /\\b(?:cf|cloudflare)_?api_?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{40}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Mistral API key — prefix-less ~32 base62 (Christian-confirmed sample). A bare\n // [A-Za-z0-9]{32} would FP on every ID/hash, so CONTEXT-ONLY: anchored on a\n // mistral-(api-)key/token-named field. Runs before labeled-hex for attribution.\n label: 'mistral-api-key',\n description: 'Mistral API key (mistral-(api-)key/token field + 24+ base62)',\n regex: /\\bmistral(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{24,}(?![A-Za-z0-9])/gi,\n },\n {\n // DeepSeek — field-anchored fallback for any DEEPSEEK_API_KEY/TOKEN value that\n // doesn't fit the canonical sk-+hex shape (mirrors the Mistral context-only\n // approach). The field name is the signal → near-zero false positives. The\n // sk-+hex format pattern above already attributes the canonical shape; this\n // backstops a format change or an opaque token.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (deepseek-(api-)key/token field + 20+ token)',\n regex: /\\bdeepseek(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Vimeo personal access token — ~32 lowercase hex, no prefix (sanne). A bare\n // hex32 would FP massively (MD5/UUID), so CONTEXT-ONLY: anchored on a\n // vimeo-(access-)token-named field.\n label: 'vimeo-access-token',\n description: 'Vimeo access token (vimeo-(access-)token field + 20+ base62)',\n regex: /\\bvimeo(?:[_-]?access)?[_-]?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{20,}(?![A-Za-z0-9])/gi,\n },\n {\n // Context-based catch for prefix-less high-entropy service secrets\n // (CMS_JWT_SECRET, revalidateSecret, fleet openssl-rand-hex secrets): a 40+\n // hex value assigned to a field whose name contains\n // secret/token/password/api-key. The name requirement keeps the\n // false-positive rate near zero (a bare 40/64-hex would hit shas/hashes).\n label: 'labeled-hex-secret',\n description: 'A 40+ hex value assigned to a secret/token/password/api-key-named field',\n regex: /\\b[A-Za-z0-9_-]*(?:secret|token|password|api[_-]?key)\\b\\s*[:=]\\s*[\"'`]?[0-9a-f]{40,}/gi,\n },\n {\n // Discord bot token — three base64url segments. Anchored both sides so it\n // can't partial-match a longer dotted string.\n label: 'discord-bot-token',\n description: 'Discord bot token (3 base64url segments)',\n regex: /(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{24,26}\\.[A-Za-z0-9_-]{6}\\.[A-Za-z0-9_-]{27,40}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'discord-mfa-token',\n description: 'Discord MFA token (mfa. + 84 chars)',\n regex: /\\bmfa\\.[A-Za-z0-9_-]{84}\\b/g,\n },\n {\n // Cloudflare Turnstile PROD secret (sanne, verified 2/2) — 0x4 + 6×A prefix,\n // then 26 base64url (35 total). The 24-char SITE key + 1x/2x/3x TEST keys are\n // intentionally NOT matched (the {26} length gate misses them) so a public\n // key is never redacted.\n label: 'cloudflare-turnstile-secret',\n description: 'Cloudflare Turnstile secret key (0x4AAAAAA + 26 base64url, 35 total)',\n regex: /0x4AAAAAA[A-Za-z0-9_-]{26}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'cloudflare-global-key',\n description: 'Cloudflare global API key (37-hex)',\n regex: /\\b[0-9a-f]{37}\\b/g,\n },\n {\n // LAST on purpose: this is the only unprefixed shape in the list, so every\n // anchored pattern above must get first refusal.\n //\n // Philips Hue v2 application key — 40 chars of [A-Za-z0-9-] with NO prefix,\n // so there is nothing to anchor on. The negative lookahead is load-bearing,\n // not decoration: a bare [A-Za-z0-9-]{40} also matches a GIT COMMIT SHA, and\n // telemetry/error output is full of those. A redactor that eats commit\n // hashes gets switched off within a week, after which it protects nothing.\n // Hue keys are mixed-case; SHAs are lowercase hex — that asymmetry is the\n // whole guard. (Pattern contributed + field-tested by beacon, F035.7.)\n label: 'hue-application-key',\n description: 'Philips Hue v2 application key (40 chars, no prefix)',\n regex: /\\b(?![0-9a-f]{40}\\b)[A-Za-z0-9-]{40}\\b/g,\n },\n];\n\nexport interface RedactionFinding {\n label: string;\n count: number;\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 = clean) */\n findings: RedactionFinding[];\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\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 */\nexport function redactSecrets(text: string, opts?: RedactOptions): RedactionResult {\n if (!text) return { redacted: text, findings: [] };\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 });\n }\n return { redacted, findings };\n}\n\n/** True if `text` contains at least one detectable secret. */\nexport function hasSecret(text: string, opts?: RedactOptions): boolean {\n return patternsFor(opts).some((p) => {\n p.regex.lastIndex = 0;\n return p.regex.test(text);\n });\n}\n\nexport interface ClassifyResult {\n /** the matching pattern's stable label (e.g. `openai-api-key`) */\n label: string;\n /** the matching pattern's human description (e.g. `OpenAI API key (sk-… / sk-proj-…)`) */\n description: string;\n}\n\n/**\n * Classify a SINGLE pasted token — the INVERSE of redaction. Returns the first\n * (most-specific) pattern the value matches, or `null`. Backs a \"paste a key →\n * detect its type\" UI (cardmem F214 Secrets Vault) so every consumer shares the\n * same classification, not just the same redaction.\n *\n * First-match-wins over the ordered `SECRET_PATTERNS`, so `sk-ant-…` classifies\n * as `anthropic-api-key`, never the generic `openai-api-key`. Field-anchored\n * context-only patterns (mistral / vimeo / cloudflare-api-token /\n * labeled-hex-secret / deepseek-fallback) only match when the pasted value\n * includes their `NAME=` context; a bare provider token classifies via its\n * prefix pattern, and a prefix-less bare token (e.g. a raw Mistral key) is\n * genuinely unidentifiable → `null`. `opts.extraPatterns` run AFTER the\n * canonical set (canonical attribution wins). Input is trimmed; empty /\n * whitespace-only → `null`.\n */\nexport function classify(value: string, opts?: RedactOptions): ClassifyResult | null {\n if (!value) return null;\n const v = value.trim();\n if (!v) return null;\n for (const p of patternsFor(opts)) {\n p.regex.lastIndex = 0;\n if (p.regex.test(v)) return { label: p.label, description: p.description };\n }\n return null;\n}\n"]}
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@broberg/secret-scan",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
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,
8
- "files": ["dist", "README.md"],
8
+ "files": [
9
+ "dist",
10
+ "README.md"
11
+ ],
9
12
  "main": "./dist/index.cjs",
10
13
  "module": "./dist/index.js",
11
14
  "types": "./dist/index.d.ts",
@@ -41,5 +44,7 @@
41
44
  "url": "https://github.com/broberg-ai/components",
42
45
  "directory": "packages/secret-scan"
43
46
  },
44
- "publishConfig": { "access": "public" }
47
+ "publishConfig": {
48
+ "access": "public"
49
+ }
45
50
  }