@broberg/secret-scan 0.2.2 → 0.3.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/README.md CHANGED
@@ -38,7 +38,24 @@ and never blocks the write — the surrounding knowledge survives.
38
38
  >
39
39
  > There are **two detection axes** and only the format one is on by default.
40
40
  > `findings: []` here does not mean "clean" — it means the announced axis was
41
- > never examined, and those two are indistinguishable from the return value.
41
+ > never examined.
42
+ >
43
+ > **Since 0.3.0 you can check that instead of remembering it.** Every result
44
+ > carries `scanned` — which axes the call actually examined:
45
+ >
46
+ > ```ts
47
+ > redactSecrets("Adgangskode: hunter2").scanned // ["format"]
48
+ > redactSecrets(body, { announced: true }).scanned // ["format", "announced"]
49
+ >
50
+ > const r = redactSecrets(body, { announced: true });
51
+ > if (!r.scanned.includes("announced")) throw new Error("announced axis not scanned");
52
+ > ```
53
+ >
54
+ > It is computed from the **options**, not from what was found, so a clean scan
55
+ > and an unscanned one never look alike. **Honest limit:** this does not
56
+ > *prevent* the mistake — someone who forgets the flag can equally forget to
57
+ > check `scanned`. It makes the mistake **detectable** rather than merely
58
+ > documented, which is the difference between a check and an agreement.
42
59
  >
43
60
  > Gating untrusted inbound text? Use **`hasAnnouncedSecret(text)`**, or pass
44
61
  > `{ announced: true }`. Do not treat an empty `findings` as safe.
@@ -213,7 +230,11 @@ regexes — most-specific first so attribution is correct:
213
230
  interface SecretPattern { label: string; description: string; regex: RegExp; }
214
231
  type SecretConfidence = "format" | "announced";
215
232
  interface RedactionFinding { label: string; count: number; confidence: SecretConfidence; }
216
- interface RedactionResult { redacted: string; findings: RedactionFinding[]; }
233
+ interface RedactionResult {
234
+ redacted: string;
235
+ findings: RedactionFinding[];
236
+ scanned: readonly SecretConfidence[]; // 0.3.0 — which axes were examined
237
+ }
217
238
  interface RedactOptions { extraPatterns?: SecretPattern[]; announced?: boolean; }
218
239
  interface ClassifyResult { label: string; description: string; }
219
240
 
@@ -226,4 +247,13 @@ function classify(value: string, opts?: RedactOptions): ClassifyResult | null; /
226
247
  function redactionMarker(label: string): string; // `[REDACTED:${label}]`
227
248
  ```
228
249
 
250
+ ## Upgrading to 0.3.0
251
+
252
+ `RedactionResult` gained a required `scanned` field. Additive for anyone reading
253
+ `redacted` / `findings` — **but a deep-equality assertion on the whole result
254
+ object will fail**, e.g. `expect(redactSecrets("")).toEqual({ redacted: "", findings: [] })`.
255
+ That is exactly the one test in this package's own suite that broke, and it was
256
+ kept as a whole-object compare rather than loosened, because it is the only
257
+ thing that shows a consumer what they will feel.
258
+
229
259
  MIT · part of the [`@broberg/*`](https://github.com/broberg-ai/components) shared-library family.
package/dist/index.cjs CHANGED
@@ -265,7 +265,8 @@ function patternsFor(opts) {
265
265
  return opts?.extraPatterns && opts.extraPatterns.length > 0 ? [...SECRET_PATTERNS, ...opts.extraPatterns] : SECRET_PATTERNS;
266
266
  }
267
267
  function redactSecrets(text, opts) {
268
- if (!text) return { redacted: text, findings: [] };
268
+ const scanned = opts?.announced ? ["format", "announced"] : ["format"];
269
+ if (!text) return { redacted: text, findings: [], scanned };
269
270
  let redacted = text;
270
271
  const findings = [];
271
272
  for (const p of patternsFor(opts)) {
@@ -287,7 +288,7 @@ function redactSecrets(text, opts) {
287
288
  findings.push({ label: ANNOUNCED_LABEL, count, confidence: "announced" });
288
289
  }
289
290
  }
290
- return { redacted, findings };
291
+ return { redacted, findings, scanned };
291
292
  }
292
293
  function hasAnnouncedSecret(text) {
293
294
  if (!text) return false;
@@ -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 // LAST on purpose: this is the only unprefixed shape in the list, so every\n // anchored pattern above must get first refusal.\n //\n // Philips Hue v2 application key — 40 chars of [A-Za-z0-9-] with NO prefix,\n // so there is nothing to anchor on. The negative lookahead is load-bearing,\n // not decoration: a bare [A-Za-z0-9-]{40} also matches a GIT COMMIT SHA, and\n // telemetry/error output is full of those. A redactor that eats commit\n // hashes gets switched off within a week, after which it protects nothing.\n // Hue keys are mixed-case; SHAs are lowercase hex — that asymmetry is the\n // whole guard. (Pattern contributed + field-tested by beacon, F035.7.)\n label: 'hue-application-key',\n description: 'Philips Hue v2 application key (40 chars, no prefix)',\n regex: /\\b(?![0-9a-f]{40}\\b)[A-Za-z0-9-]{40}\\b/g,\n },\n];\n\n/**\n * WHY a finding was flagged — the two detection axes this package has.\n *\n * `format` the VALUE carries the signal: `sk-ant-…`, `ghp_…`, `AKIA…`. Shape\n * alone identifies it, so it is safe to run on anything.\n * `announced` the LABEL carries the signal: `Adgangskode: hunter2`. The value is\n * arbitrary human text with no shape to match, so the only evidence\n * is that someone wrote the word \"password\" next to it.\n */\nexport type SecretConfidence = 'format' | 'announced';\n\nexport interface RedactionFinding {\n label: string;\n count: number;\n /** which axis matched — see SecretConfidence. */\n confidence: SecretConfidence;\n}\n\nexport interface RedactionResult {\n /** input with every secret replaced by `[REDACTED:<label>]` */\n redacted: string;\n /** per-pattern counts of what was redacted (empty = 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 * 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 */\nconst ANNOUNCED_SECRET =\n /(\\b(?:adgangskode|kodeord|hemmelighed|password|passwd|api[ -]?key|apinøgle|secret|kode|pwd)\\s*[:=]\\s*)(?!\\[REDACTED:)\\S+/gi;\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 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, 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 };\n}\n\n/**\n * True if `text` announces a credential by label — `Adgangskode: hunter2` —\n * without building a redaction. Cheap enough to run on every inbound message.\n *\n * This exists because for untrusted inbound text heading to a model, the right\n * response is often to REFUSE rather than redact: a false positive costs a\n * slightly worse classification, a false negative costs a leak. That use needs a\n * boolean, not a redactor. (buddy's reasoning, F035.8.)\n */\nexport function hasAnnouncedSecret(text: string): boolean {\n if (!text) return false;\n ANNOUNCED_SECRET.lastIndex = 0;\n return ANNOUNCED_SECRET.test(text);\n}\n\n/**\n * True if `text` contains at least one detectable secret. Honours\n * `opts.announced` — a caller who asks for the announced axis and is told\n * `false` must be able to believe it.\n */\nexport function hasSecret(text: string, opts?: RedactOptions): boolean {\n if (opts?.announced && hasAnnouncedSecret(text)) return true;\n return patternsFor(opts).some((p) => {\n p.regex.lastIndex = 0;\n return p.regex.test(text);\n });\n}\n\nexport interface ClassifyResult {\n /** the matching pattern's stable label (e.g. `openai-api-key`) */\n label: string;\n /** the matching pattern's human description (e.g. `OpenAI API key (sk-… / sk-proj-…)`) */\n description: string;\n}\n\n/**\n * Classify a SINGLE pasted token — the INVERSE of redaction. Returns the first\n * (most-specific) pattern the value matches, or `null`. Backs a \"paste a key →\n * detect its type\" UI (cardmem F214 Secrets Vault) so every consumer shares the\n * same classification, not just the same redaction.\n *\n * First-match-wins over the ordered `SECRET_PATTERNS`, so `sk-ant-…` classifies\n * as `anthropic-api-key`, never the generic `openai-api-key`. Field-anchored\n * context-only patterns (mistral / vimeo / cloudflare-api-token /\n * labeled-hex-secret / deepseek-fallback) only match when the pasted value\n * includes their `NAME=` context; a bare provider token classifies via its\n * prefix pattern, and a prefix-less bare token (e.g. a raw Mistral key) is\n * genuinely unidentifiable → `null`. `opts.extraPatterns` run AFTER the\n * canonical set (canonical attribution wins). Input is trimmed; empty /\n * whitespace-only → `null`.\n */\nexport function classify(value: string, opts?: RedactOptions): ClassifyResult | null {\n if (!value) return null;\n const v = value.trim();\n if (!v) return null;\n for (const p of patternsFor(opts)) {\n p.regex.lastIndex = 0;\n if (p.regex.test(v)) return { label: p.label, description: p.description };\n }\n return null;\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @broberg/secret-scan — fleet secret/credential redaction.\n *\n * `redactSecrets(text)` replaces every matched secret with `[REDACTED:<label>]`\n * and reports what it found. PURE + deterministic (regex/string only, no deps,\n * no I/O) so an engine write-gate, an egress scrub, a CLI, an admin preview UI,\n * and any repo all share the EXACT same detection — and it's trivially testable.\n *\n * Lifted verbatim from broberg/trail F197 (the second-brain safeguard); see\n * docs/features/F035-secret-scan.md. components owns + publishes this; @trail/shared\n * re-exports it.\n *\n * Design choices:\n * - Pattern-based, NOT entropy/generic-randomness — a redacted real fact would\n * corrupt knowledge, so we accept missing an exotic token over false positives.\n * - Order matters: most-specific patterns run first (e.g. `sk-ant-` before the\n * generic OpenAI `sk-`; `sk-or-v1-` before `sk-`), because each match is\n * consumed before the next pattern runs → order = attribution.\n * - Redact, never reject — the surrounding knowledge survives; only the\n * credential substring is neutralised.\n * - NEVER a bare high-entropy/hex pattern (it would hit git shas/hashes).\n * Prefix-less service secrets are caught only via `labeled-hex-secret` (a 40+\n * hex value assigned to a secret/token/password/api-key-named field).\n *\n * Two recommended integration shapes for consumers:\n * (a) write boundary — `redactSecrets(text)` before persist (ingest gate);\n * (b) egress — scrub before a value leaves to a user/LLM (highest-value guard).\n */\n\nexport interface SecretPattern {\n /** stable id shown in the redaction marker + findings */\n label: string;\n /** human description of what this matches */\n description: string;\n /** global regex (used for replace-all + counting) */\n regex: RegExp;\n}\n\n/** Ordered most-specific → least. Every regex carries the `g` flag. */\nexport const SECRET_PATTERNS: SecretPattern[] = [\n {\n label: 'private-key',\n description: 'PEM private key block (RSA/EC/OPENSSH/DSA/PGP)',\n regex:\n /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\\s\\S]*?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g,\n },\n {\n label: 'anthropic-api-key',\n description: 'Anthropic API key (sk-ant-…)',\n regex: /sk-ant-(?:api03-)?[A-Za-z0-9_-]{20,}/g,\n },\n {\n // OpenRouter — distinct from OpenAI; runs BEFORE the generic sk- (which would\n // otherwise also match + mislabel it).\n label: 'openrouter-api-key',\n description: 'OpenRouter API key (sk-or-v1- + 64 hex)',\n regex: /\\bsk-or-v1-[0-9a-f]{64}/g,\n },\n {\n // DeepSeek — shares the sk- prefix with OpenAI, so it MUST run before the\n // generic openai pattern (specific-before-generic = correct attribution).\n // DeepSeek's documented shape is sk- + 32 lowercase hex (GitGuardian confirms\n // an sk- prefix but hides the exact regex); the hex-only body + {32,} length\n // distinguishes it from OpenAI's mixed-case base62 keys, so a real OpenAI key\n // is never mislabelled. The field-anchored fallback below catches any\n // DEEPSEEK_API_KEY value that doesn't fit this canonical shape.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (sk- + 32 lowercase hex)',\n regex: /\\bsk-[0-9a-f]{32,}(?![0-9a-z])/g,\n },\n {\n label: 'openai-api-key',\n description: 'OpenAI API key (sk-… / sk-proj-…)',\n regex: /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/g,\n },\n {\n // ElevenLabs — sk_ with UNDERSCORE (vs OpenAI sk-), 48 hex.\n label: 'elevenlabs-api-key',\n description: 'ElevenLabs API key (sk_ + 48 hex)',\n regex: /\\bsk_[0-9a-f]{48}\\b/g,\n },\n {\n // fal.ai — uuid:hex32 (key_id:key_secret); the colon is the signal.\n label: 'fal-api-key',\n description: 'fal.ai key (uuid:hex32)',\n regex: /\\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:[0-9a-f]{32}\\b/g,\n },\n {\n // Black Forest Labs (FLUX) API key — bfl_ prefix + a long token (sample\n // bfl_Qo1…). The distinctive prefix + {20,} length keeps false positives near\n // zero; image-provider sibling of the fal key above.\n label: 'bfl-api-key',\n description: 'Black Forest Labs / FLUX API key (bfl_ + token)',\n regex: /\\bbfl_[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'google-api-key',\n description: 'Google / Gemini API key (AIza…)',\n regex: /AIza[0-9A-Za-z_-]{35}/g,\n },\n {\n label: 'google-oauth-secret',\n description: 'Google OAuth client secret (GOCSPX-…)',\n regex: /GOCSPX-[A-Za-z0-9_-]{28}/g,\n },\n {\n label: 'aws-access-key-id',\n description: 'AWS access key id (AKIA…)',\n regex: /\\bAKIA[0-9A-Z]{16}\\b/g,\n },\n {\n label: 'github-token',\n description: 'GitHub token (ghp_/gho_/ghs_/ghu_/ghr_…)',\n regex: /\\bgh[posru]_[A-Za-z0-9]{36,}\\b/g,\n },\n {\n // GitHub fine-grained PAT — distinct prefix `github_pat_` (not caught by the\n // classic gh[posru]_ above), then base62 + a `_` separator (~82 chars total).\n // The prefix is so distinctive that {50,} keeps false positives at zero.\n label: 'github-fine-grained-pat',\n description: 'GitHub fine-grained personal access token (github_pat_…)',\n regex: /\\bgithub_pat_[A-Za-z0-9_]{50,}/g,\n },\n {\n label: 'gitlab-token',\n description: 'GitLab personal access token (glpat-…)',\n regex: /\\bglpat-[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'slack-token',\n description: 'Slack token (xox[baprs]-…)',\n regex: /\\bxox[baprs]-[A-Za-z0-9-]{10,}/g,\n },\n {\n label: 'stripe-secret-key',\n description: 'Stripe live secret/restricted key (sk_live_/rk_live_…)',\n regex: /\\b[rs]k_live_[A-Za-z0-9]{20,}/g,\n },\n {\n // Resend (re_…). Lookahead requires a digit in the body so we don't redact\n // long snake_case identifiers like re_compute_the_thing.\n label: 'resend-api-key',\n description: 'Resend API key (re_ + token)',\n regex: /\\bre_(?=[A-Za-z0-9_]*\\d)[A-Za-z0-9_]{24,}\\b/g,\n },\n {\n label: 'supabase-access-token',\n description: 'Supabase personal/management access token (sbp_ + 40 hex)',\n regex: /\\bsbp_[0-9a-f]{40}/g,\n },\n {\n label: 'supabase-secret-key',\n description: 'Supabase secret API key (sb_secret_…)',\n regex: /\\bsb_secret_[A-Za-z0-9_-]{20,}/g,\n },\n {\n // Used by every @broberg/* publish — the highest-value leak from a .env / commit history.\n label: 'npm-token',\n description: 'npm publish/automation token (npm_ + 36 base62)',\n regex: /\\bnpm_[A-Za-z0-9]{36}\\b/g,\n },\n {\n label: 'fly-api-token',\n description: 'Fly.io API token (FlyV1 fm2_… / fo1_…)',\n regex: /(?:FlyV1 fm2_[A-Za-z0-9+/=_-]{20,}|\\bfo1_[A-Za-z0-9_-]{20,})/g,\n },\n {\n // Also covers Turso DB/platform auth tokens AND Supabase anon/service_role\n // keys — both are JWTs (eyJ…), so the single JWT pattern catches them.\n label: 'jwt',\n description: 'JSON Web Token (eyJ…) — incl. Turso + Supabase service_role tokens',\n regex: /\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}/g,\n },\n {\n // genApiKey = randomBytes(24).hex → uk_ + exactly 48 lowercase hex.\n label: 'upmetrics-key',\n description: 'Upmetrics project key (uk_ + 48 hex)',\n regex: /\\buk_[0-9a-f]{48}/g,\n },\n {\n label: 'cardmem-key',\n description: 'Cardmem personal/incident/project key (pa_/pi_/pk_ + 64 hex)',\n regex: /\\bp[aik]_[A-Za-z0-9]{20,}/g,\n },\n {\n // cardmem inbox-webhook key — piw_ isn't matched by p[aik]_ above (3rd char 'w' ≠ '_').\n label: 'cardmem-webhook-key',\n description: 'Cardmem inbox-webhook key (piw_ + 64 hex)',\n regex: /\\bpiw_[0-9a-f]{64}/g,\n },\n {\n label: 'trail-key',\n description: 'Trail personal API key (trail_…)',\n regex: /\\btrail_[A-Za-z0-9]{20,}/g,\n },\n {\n // Cronjobs API key (cronjobs.webhouse.net) — cj_ + randomBytes(32).base64url =\n // exactly 43 base64url chars (46 total). Prefix + fixed length = very low FP.\n // The UI's truncated cj_<8 chars>… preview is shorter than {43} → not matched.\n // Negative lookahead (not \\b) because base64url's `-` breaks a trailing \\b.\n label: 'cronjobs-api-key',\n description: 'Cronjobs API key (cj_ + 43 base64url)',\n regex: /\\bcj_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g,\n },\n {\n // randomBytes(32).hex → wh_ + 64 lowercase hex (67 chars total).\n label: 'cms-access-token',\n description: 'webhouse.app CMS access token (wh_ + 64 hex)',\n regex: /\\bwh_[0-9a-f]{64}/g,\n },\n {\n // Cloudflare API token (R2 / DNS management) — 40 base64url chars, NO prefix.\n // A bare {40} would false-positive broadly, so this is CONTEXT-ONLY: it only\n // fires next to a cf/cloudflare-api-token-named field. Runs before\n // labeled-hex-secret so a hex-valued CF token is attributed correctly.\n label: 'cloudflare-api-token',\n description: 'Cloudflare API token (cf/cloudflare-api-token field + 40 base64url)',\n regex: /\\b(?:cf|cloudflare)_?api_?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{40}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Mistral API key — prefix-less ~32 base62 (Christian-confirmed sample). A bare\n // [A-Za-z0-9]{32} would FP on every ID/hash, so CONTEXT-ONLY: anchored on a\n // mistral-(api-)key/token-named field. Runs before labeled-hex for attribution.\n label: 'mistral-api-key',\n description: 'Mistral API key (mistral-(api-)key/token field + 24+ base62)',\n regex: /\\bmistral(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{24,}(?![A-Za-z0-9])/gi,\n },\n {\n // DeepSeek — field-anchored fallback for any DEEPSEEK_API_KEY/TOKEN value that\n // doesn't fit the canonical sk-+hex shape (mirrors the Mistral context-only\n // approach). The field name is the signal → near-zero false positives. The\n // sk-+hex format pattern above already attributes the canonical shape; this\n // backstops a format change or an opaque token.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (deepseek-(api-)key/token field + 20+ token)',\n regex: /\\bdeepseek(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Vimeo personal access token — ~32 lowercase hex, no prefix (sanne). A bare\n // hex32 would FP massively (MD5/UUID), so CONTEXT-ONLY: anchored on a\n // vimeo-(access-)token-named field.\n label: 'vimeo-access-token',\n description: 'Vimeo access token (vimeo-(access-)token field + 20+ base62)',\n regex: /\\bvimeo(?:[_-]?access)?[_-]?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{20,}(?![A-Za-z0-9])/gi,\n },\n {\n // Context-based catch for prefix-less high-entropy service secrets\n // (CMS_JWT_SECRET, revalidateSecret, fleet openssl-rand-hex secrets): a 40+\n // hex value assigned to a field whose name contains\n // secret/token/password/api-key. The name requirement keeps the\n // false-positive rate near zero (a bare 40/64-hex would hit shas/hashes).\n label: 'labeled-hex-secret',\n description: 'A 40+ hex value assigned to a secret/token/password/api-key-named field',\n regex: /\\b[A-Za-z0-9_-]*(?:secret|token|password|api[_-]?key)\\b\\s*[:=]\\s*[\"'`]?[0-9a-f]{40,}/gi,\n },\n {\n // Discord bot token — three base64url segments. Anchored both sides so it\n // can't partial-match a longer dotted string.\n label: 'discord-bot-token',\n description: 'Discord bot token (3 base64url segments)',\n regex: /(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{24,26}\\.[A-Za-z0-9_-]{6}\\.[A-Za-z0-9_-]{27,40}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'discord-mfa-token',\n description: 'Discord MFA token (mfa. + 84 chars)',\n regex: /\\bmfa\\.[A-Za-z0-9_-]{84}\\b/g,\n },\n {\n // Cloudflare Turnstile PROD secret (sanne, verified 2/2) — 0x4 + 6×A prefix,\n // then 26 base64url (35 total). The 24-char SITE key + 1x/2x/3x TEST keys are\n // intentionally NOT matched (the {26} length gate misses them) so a public\n // key is never redacted.\n label: 'cloudflare-turnstile-secret',\n description: 'Cloudflare Turnstile secret key (0x4AAAAAA + 26 base64url, 35 total)',\n regex: /0x4AAAAAA[A-Za-z0-9_-]{26}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'cloudflare-global-key',\n description: 'Cloudflare global API key (37-hex)',\n regex: /\\b[0-9a-f]{37}\\b/g,\n },\n {\n // LAST on purpose: this is the only unprefixed shape in the list, so every\n // anchored pattern above must get first refusal.\n //\n // Philips Hue v2 application key — 40 chars of [A-Za-z0-9-] with NO prefix,\n // so there is nothing to anchor on. The negative lookahead is load-bearing,\n // not decoration: a bare [A-Za-z0-9-]{40} also matches a GIT COMMIT SHA, and\n // telemetry/error output is full of those. A redactor that eats commit\n // hashes gets switched off within a week, after which it protects nothing.\n // Hue keys are mixed-case; SHAs are lowercase hex — that asymmetry is the\n // whole guard. (Pattern contributed + field-tested by beacon, F035.7.)\n label: 'hue-application-key',\n description: 'Philips Hue v2 application key (40 chars, no prefix)',\n regex: /\\b(?![0-9a-f]{40}\\b)[A-Za-z0-9-]{40}\\b/g,\n },\n];\n\n/**\n * WHY a finding was flagged — the two detection axes this package has.\n *\n * `format` the VALUE carries the signal: `sk-ant-…`, `ghp_…`, `AKIA…`. Shape\n * alone identifies it, so it is safe to run on anything.\n * `announced` the LABEL carries the signal: `Adgangskode: hunter2`. The value is\n * arbitrary human text with no shape to match, so the only evidence\n * is that someone wrote the word \"password\" next to it.\n */\nexport type SecretConfidence = 'format' | 'announced';\n\nexport interface RedactionFinding {\n label: string;\n count: number;\n /** which axis matched — see SecretConfidence. */\n confidence: SecretConfidence;\n}\n\nexport interface RedactionResult {\n /** input with every secret replaced by `[REDACTED:<label>]` */\n redacted: string;\n /** per-pattern counts of what was redacted (empty = nothing found ON THE AXES IN `scanned`) */\n findings: RedactionFinding[];\n /**\n * Which axes this call actually EXAMINED — always `['format']`, plus\n * `'announced'` when `opts.announced` was set.\n *\n * It exists because `findings: []` alone cannot tell you which question was\n * asked. `redactSecrets(\"Adgangskode: hunter2\")` and `redactSecrets(\"hello\")`\n * both return an empty `findings`, and until 0.3.0 nothing in the return value\n * distinguished \"we found nothing\" from \"we never looked there\".\n *\n * A caller that must be sure can now ASSERT rather than trust the docs:\n *\n * ```ts\n * const r = redactSecrets(body, { announced: true });\n * if (!r.scanned.includes('announced')) throw new Error('announced axis not scanned');\n * ```\n *\n * Note the honest limit: this does not PREVENT the mistake — someone who\n * forgets the flag can equally forget to check this. It makes the mistake\n * *detectable* instead of merely documented, which is the difference between a\n * check and an agreement. Filed by buddy, who had just declined the same\n * \"we'll agree to label things\" fix from another session on the grounds that\n * an agreement holds only until the first person forgets it, and said it would\n * be cheap to use that argument in one direction and not the other.\n */\n scanned: readonly SecretConfidence[];\n}\n\nexport interface RedactOptions {\n /**\n * Extra consumer/per-tenant patterns, run AFTER the canonical set (so canonical\n * attribution wins). Backs a future self-service \"paste a key → detector\" UI.\n */\n extraPatterns?: SecretPattern[];\n /**\n * Also detect ANNOUNCED secrets — `Adgangskode: hunter2` — where the label is\n * the only evidence. **Off by default, and it must stay that way.** See\n * ANNOUNCED_LABEL for the measurement that decided it.\n */\n announced?: boolean;\n}\n\n/** Marker label for a secret detected by its announcing label rather than shape. */\nexport const ANNOUNCED_LABEL = 'announced-secret';\n\n/**\n * Label + separator + value. The label list is deliberately short and concrete;\n * this is not a general \"looks like config\" detector.\n *\n * WHY THIS IS OPT-IN, MEASURED RATHER THAN GUESSED. Over this repo on\n * 2026-08-14 — 548 tracked files, 544 readable as text, containing essentially\n * no real secrets — this exact regex matched **97 times**, and every one was\n * noise. Per label: `secret` 61, `api key` 33, `password` 4, and every Danish\n * label 0. So 94 of the 97 are the two words that are also ordinary IDENTIFIERS\n * in source code (`secret: config.secret`, `apiKey: Record<…>`).\n *\n * That is the real finding, and it is sharper than \"the pattern is noisy\": its\n * precision depends entirely on WHAT IS BEING SCANNED. In an inbound mail body\n * — buddy's actual case — `Adgangskode:` is a strong signal. In a TypeScript\n * file it is a variable name. **The package cannot know which corpus it is\n * looking at; only the caller can.** So the caller makes the decision, and the\n * default cannot be on. (This is the opposite of this repo's usual defaults-ON\n * stance — webpush F067.1, lens-engine F065 — and the numbers above are why.)\n *\n * A broader label+separator+value pattern measured 305 on the same corpus, and\n * refining it only reached 202 — no amount of tuning makes a generic version\n * safe. A template/env-reference guard (`${FOO}`, `<your-key>`) was written and\n * then dropped: it changed the count by exactly 0, because the noise here is\n * identifiers, not templates.\n *\n * The value must not already be a redaction marker, so this can run AFTER the\n * format pass without flattening its more specific attribution.\n */\nconst ANNOUNCED_SECRET =\n /(\\b(?:adgangskode|kodeord|hemmelighed|password|passwd|api[ -]?key|apinøgle|secret|kode|pwd)\\s*[:=]\\s*)(?!\\[REDACTED:)\\S+/gi;\n\n/** Replacement marker for a redacted secret. */\nexport const redactionMarker = (label: string): string => `[REDACTED:${label}]`;\n\nfunction patternsFor(opts?: RedactOptions): SecretPattern[] {\n return opts?.extraPatterns && opts.extraPatterns.length > 0\n ? [...SECRET_PATTERNS, ...opts.extraPatterns]\n : SECRET_PATTERNS;\n}\n\n/**\n * Scan `text` and replace every detected secret with its redaction marker.\n * Pure: clean input returns byte-identical (`findings: []`).\n *\n * ⚠️ **This does NOT catch an announced secret unless you pass\n * `{ announced: true }`.** `redactSecrets(\"Adgangskode: hunter2\")` returns the\n * password untouched with `findings: []` — which is indistinguishable from\n * \"this text is clean\", because the announced axis was never examined.\n *\n * The two axes are separate and only one is on by default (see\n * SecretConfidence). If you are gating untrusted inbound text, reach for\n * `hasAnnouncedSecret()` — or pass the flag. Do not assume an empty `findings`\n * means safe.\n *\n * Filed by buddy, who nearly reported this package as behaving wrongly: their\n * probe used the defaults and so could not see the axis they were testing. The\n * behaviour is right; the NAMES are the trap — two functions that sound\n * interchangeable, one of which is only complete with a flag.\n */\nexport function redactSecrets(text: string, opts?: RedactOptions): RedactionResult {\n // Computed from the OPTIONS, not from what was found — so it answers \"which\n // question did this call ask?\" identically on empty, clean and dirty input.\n // The empty-text path returns it too, deliberately: a caller asserting on\n // `scanned` must not get a different shape just because the body was blank.\n const scanned: readonly SecretConfidence[] = opts?.announced\n ? ['format', 'announced']\n : ['format'];\n if (!text) return { redacted: text, findings: [], scanned };\n let redacted = text;\n const findings: RedactionFinding[] = [];\n for (const p of patternsFor(opts)) {\n let count = 0;\n redacted = redacted.replace(p.regex, () => {\n count++;\n return redactionMarker(p.label);\n });\n if (count > 0) findings.push({ label: p.label, count, confidence: 'format' });\n }\n // Announced runs LAST, and only on request. Order is not cosmetic: the format\n // pass has already replaced everything it recognises, and this regex refuses a\n // value that is already a marker — so `API key: sk-ant-…` keeps its specific\n // `anthropic-api-key` attribution instead of being flattened to a generic one.\n // The announcing label itself is KEPT in the output; only the value goes, so\n // the redacted text still reads `Adgangskode: [REDACTED:announced-secret]` and\n // a human or model reading it can still tell what was removed.\n if (opts?.announced) {\n let count = 0;\n const redactedAnnounced = redacted.replace(ANNOUNCED_SECRET, (_match, prefix: string) => {\n count++;\n return prefix + redactionMarker(ANNOUNCED_LABEL);\n });\n if (count > 0) {\n redacted = redactedAnnounced;\n findings.push({ label: ANNOUNCED_LABEL, count, confidence: 'announced' });\n }\n }\n return { redacted, findings, scanned };\n}\n\n/**\n * True if `text` announces a credential by label — `Adgangskode: hunter2` —\n * without building a redaction. Cheap enough to run on every inbound message.\n *\n * This exists because for untrusted inbound text heading to a model, the right\n * response is often to REFUSE rather than redact: a false positive costs a\n * slightly worse classification, a false negative costs a leak. That use needs a\n * boolean, not a redactor. (buddy's reasoning, F035.8.)\n */\nexport function hasAnnouncedSecret(text: string): boolean {\n if (!text) return false;\n ANNOUNCED_SECRET.lastIndex = 0;\n return ANNOUNCED_SECRET.test(text);\n}\n\n/**\n * True if `text` contains at least one detectable secret. Honours\n * `opts.announced` — a caller who asks for the announced axis and is told\n * `false` must be able to believe it.\n */\nexport function hasSecret(text: string, opts?: RedactOptions): boolean {\n if (opts?.announced && hasAnnouncedSecret(text)) return true;\n return patternsFor(opts).some((p) => {\n p.regex.lastIndex = 0;\n return p.regex.test(text);\n });\n}\n\nexport interface ClassifyResult {\n /** the matching pattern's stable label (e.g. `openai-api-key`) */\n label: string;\n /** the matching pattern's human description (e.g. `OpenAI API key (sk-… / sk-proj-…)`) */\n description: string;\n}\n\n/**\n * Classify a SINGLE pasted token — the INVERSE of redaction. Returns the first\n * (most-specific) pattern the value matches, or `null`. Backs a \"paste a key →\n * detect its type\" UI (cardmem F214 Secrets Vault) so every consumer shares the\n * same classification, not just the same redaction.\n *\n * First-match-wins over the ordered `SECRET_PATTERNS`, so `sk-ant-…` classifies\n * as `anthropic-api-key`, never the generic `openai-api-key`. Field-anchored\n * context-only patterns (mistral / vimeo / cloudflare-api-token /\n * labeled-hex-secret / deepseek-fallback) only match when the pasted value\n * includes their `NAME=` context; a bare provider token classifies via its\n * prefix pattern, and a prefix-less bare token (e.g. a raw Mistral key) is\n * genuinely unidentifiable → `null`. `opts.extraPatterns` run AFTER the\n * canonical set (canonical attribution wins). Input is trimmed; empty /\n * whitespace-only → `null`.\n */\nexport function classify(value: string, opts?: RedactOptions): ClassifyResult | null {\n if (!value) return null;\n const v = value.trim();\n if (!v) return null;\n for (const p of patternsFor(opts)) {\n p.regex.lastIndex = 0;\n if (p.regex.test(v)) return { label: p.label, description: p.description };\n }\n return null;\n}\n"]}
package/dist/index.d.cts CHANGED
@@ -55,8 +55,33 @@ interface RedactionFinding {
55
55
  interface RedactionResult {
56
56
  /** input with every secret replaced by `[REDACTED:<label>]` */
57
57
  redacted: string;
58
- /** per-pattern counts of what was redacted (empty = clean) */
58
+ /** per-pattern counts of what was redacted (empty = nothing found ON THE AXES IN `scanned`) */
59
59
  findings: RedactionFinding[];
60
+ /**
61
+ * Which axes this call actually EXAMINED — always `['format']`, plus
62
+ * `'announced'` when `opts.announced` was set.
63
+ *
64
+ * It exists because `findings: []` alone cannot tell you which question was
65
+ * asked. `redactSecrets("Adgangskode: hunter2")` and `redactSecrets("hello")`
66
+ * both return an empty `findings`, and until 0.3.0 nothing in the return value
67
+ * distinguished "we found nothing" from "we never looked there".
68
+ *
69
+ * A caller that must be sure can now ASSERT rather than trust the docs:
70
+ *
71
+ * ```ts
72
+ * const r = redactSecrets(body, { announced: true });
73
+ * if (!r.scanned.includes('announced')) throw new Error('announced axis not scanned');
74
+ * ```
75
+ *
76
+ * Note the honest limit: this does not PREVENT the mistake — someone who
77
+ * forgets the flag can equally forget to check this. It makes the mistake
78
+ * *detectable* instead of merely documented, which is the difference between a
79
+ * check and an agreement. Filed by buddy, who had just declined the same
80
+ * "we'll agree to label things" fix from another session on the grounds that
81
+ * an agreement holds only until the first person forgets it, and said it would
82
+ * be cheap to use that argument in one direction and not the other.
83
+ */
84
+ scanned: readonly SecretConfidence[];
60
85
  }
61
86
  interface RedactOptions {
62
87
  /**
package/dist/index.d.ts CHANGED
@@ -55,8 +55,33 @@ interface RedactionFinding {
55
55
  interface RedactionResult {
56
56
  /** input with every secret replaced by `[REDACTED:<label>]` */
57
57
  redacted: string;
58
- /** per-pattern counts of what was redacted (empty = clean) */
58
+ /** per-pattern counts of what was redacted (empty = nothing found ON THE AXES IN `scanned`) */
59
59
  findings: RedactionFinding[];
60
+ /**
61
+ * Which axes this call actually EXAMINED — always `['format']`, plus
62
+ * `'announced'` when `opts.announced` was set.
63
+ *
64
+ * It exists because `findings: []` alone cannot tell you which question was
65
+ * asked. `redactSecrets("Adgangskode: hunter2")` and `redactSecrets("hello")`
66
+ * both return an empty `findings`, and until 0.3.0 nothing in the return value
67
+ * distinguished "we found nothing" from "we never looked there".
68
+ *
69
+ * A caller that must be sure can now ASSERT rather than trust the docs:
70
+ *
71
+ * ```ts
72
+ * const r = redactSecrets(body, { announced: true });
73
+ * if (!r.scanned.includes('announced')) throw new Error('announced axis not scanned');
74
+ * ```
75
+ *
76
+ * Note the honest limit: this does not PREVENT the mistake — someone who
77
+ * forgets the flag can equally forget to check this. It makes the mistake
78
+ * *detectable* instead of merely documented, which is the difference between a
79
+ * check and an agreement. Filed by buddy, who had just declined the same
80
+ * "we'll agree to label things" fix from another session on the grounds that
81
+ * an agreement holds only until the first person forgets it, and said it would
82
+ * be cheap to use that argument in one direction and not the other.
83
+ */
84
+ scanned: readonly SecretConfidence[];
60
85
  }
61
86
  interface RedactOptions {
62
87
  /**
package/dist/index.js CHANGED
@@ -263,7 +263,8 @@ function patternsFor(opts) {
263
263
  return opts?.extraPatterns && opts.extraPatterns.length > 0 ? [...SECRET_PATTERNS, ...opts.extraPatterns] : SECRET_PATTERNS;
264
264
  }
265
265
  function redactSecrets(text, opts) {
266
- if (!text) return { redacted: text, findings: [] };
266
+ const scanned = opts?.announced ? ["format", "announced"] : ["format"];
267
+ if (!text) return { redacted: text, findings: [], scanned };
267
268
  let redacted = text;
268
269
  const findings = [];
269
270
  for (const p of patternsFor(opts)) {
@@ -285,7 +286,7 @@ function redactSecrets(text, opts) {
285
286
  findings.push({ label: ANNOUNCED_LABEL, count, confidence: "announced" });
286
287
  }
287
288
  }
288
- return { redacted, findings };
289
+ return { redacted, findings, scanned };
289
290
  }
290
291
  function hasAnnouncedSecret(text) {
291
292
  if (!text) return false;
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 // LAST on purpose: this is the only unprefixed shape in the list, so every\n // anchored pattern above must get first refusal.\n //\n // Philips Hue v2 application key — 40 chars of [A-Za-z0-9-] with NO prefix,\n // so there is nothing to anchor on. The negative lookahead is load-bearing,\n // not decoration: a bare [A-Za-z0-9-]{40} also matches a GIT COMMIT SHA, and\n // telemetry/error output is full of those. A redactor that eats commit\n // hashes gets switched off within a week, after which it protects nothing.\n // Hue keys are mixed-case; SHAs are lowercase hex — that asymmetry is the\n // whole guard. (Pattern contributed + field-tested by beacon, F035.7.)\n label: 'hue-application-key',\n description: 'Philips Hue v2 application key (40 chars, no prefix)',\n regex: /\\b(?![0-9a-f]{40}\\b)[A-Za-z0-9-]{40}\\b/g,\n },\n];\n\n/**\n * WHY a finding was flagged — the two detection axes this package has.\n *\n * `format` the VALUE carries the signal: `sk-ant-…`, `ghp_…`, `AKIA…`. Shape\n * alone identifies it, so it is safe to run on anything.\n * `announced` the LABEL carries the signal: `Adgangskode: hunter2`. The value is\n * arbitrary human text with no shape to match, so the only evidence\n * is that someone wrote the word \"password\" next to it.\n */\nexport type SecretConfidence = 'format' | 'announced';\n\nexport interface RedactionFinding {\n label: string;\n count: number;\n /** which axis matched — see SecretConfidence. */\n confidence: SecretConfidence;\n}\n\nexport interface RedactionResult {\n /** input with every secret replaced by `[REDACTED:<label>]` */\n redacted: string;\n /** per-pattern counts of what was redacted (empty = 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 * 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 */\nconst ANNOUNCED_SECRET =\n /(\\b(?:adgangskode|kodeord|hemmelighed|password|passwd|api[ -]?key|apinøgle|secret|kode|pwd)\\s*[:=]\\s*)(?!\\[REDACTED:)\\S+/gi;\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 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, 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 };\n}\n\n/**\n * True if `text` announces a credential by label — `Adgangskode: hunter2` —\n * without building a redaction. Cheap enough to run on every inbound message.\n *\n * This exists because for untrusted inbound text heading to a model, the right\n * response is often to REFUSE rather than redact: a false positive costs a\n * slightly worse classification, a false negative costs a leak. That use needs a\n * boolean, not a redactor. (buddy's reasoning, F035.8.)\n */\nexport function hasAnnouncedSecret(text: string): boolean {\n if (!text) return false;\n ANNOUNCED_SECRET.lastIndex = 0;\n return ANNOUNCED_SECRET.test(text);\n}\n\n/**\n * True if `text` contains at least one detectable secret. Honours\n * `opts.announced` — a caller who asks for the announced axis and is told\n * `false` must be able to believe it.\n */\nexport function hasSecret(text: string, opts?: RedactOptions): boolean {\n if (opts?.announced && hasAnnouncedSecret(text)) return true;\n return patternsFor(opts).some((p) => {\n p.regex.lastIndex = 0;\n return p.regex.test(text);\n });\n}\n\nexport interface ClassifyResult {\n /** the matching pattern's stable label (e.g. `openai-api-key`) */\n label: string;\n /** the matching pattern's human description (e.g. `OpenAI API key (sk-… / sk-proj-…)`) */\n description: string;\n}\n\n/**\n * Classify a SINGLE pasted token — the INVERSE of redaction. Returns the first\n * (most-specific) pattern the value matches, or `null`. Backs a \"paste a key →\n * detect its type\" UI (cardmem F214 Secrets Vault) so every consumer shares the\n * same classification, not just the same redaction.\n *\n * First-match-wins over the ordered `SECRET_PATTERNS`, so `sk-ant-…` classifies\n * as `anthropic-api-key`, never the generic `openai-api-key`. Field-anchored\n * context-only patterns (mistral / vimeo / cloudflare-api-token /\n * labeled-hex-secret / deepseek-fallback) only match when the pasted value\n * includes their `NAME=` context; a bare provider token classifies via its\n * prefix pattern, and a prefix-less bare token (e.g. a raw Mistral key) is\n * genuinely unidentifiable → `null`. `opts.extraPatterns` run AFTER the\n * canonical set (canonical attribution wins). Input is trimmed; empty /\n * whitespace-only → `null`.\n */\nexport function classify(value: string, opts?: RedactOptions): ClassifyResult | null {\n if (!value) return null;\n const v = value.trim();\n if (!v) return null;\n for (const p of patternsFor(opts)) {\n p.regex.lastIndex = 0;\n if (p.regex.test(v)) return { label: p.label, description: p.description };\n }\n return null;\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @broberg/secret-scan — fleet secret/credential redaction.\n *\n * `redactSecrets(text)` replaces every matched secret with `[REDACTED:<label>]`\n * and reports what it found. PURE + deterministic (regex/string only, no deps,\n * no I/O) so an engine write-gate, an egress scrub, a CLI, an admin preview UI,\n * and any repo all share the EXACT same detection — and it's trivially testable.\n *\n * Lifted verbatim from broberg/trail F197 (the second-brain safeguard); see\n * docs/features/F035-secret-scan.md. components owns + publishes this; @trail/shared\n * re-exports it.\n *\n * Design choices:\n * - Pattern-based, NOT entropy/generic-randomness — a redacted real fact would\n * corrupt knowledge, so we accept missing an exotic token over false positives.\n * - Order matters: most-specific patterns run first (e.g. `sk-ant-` before the\n * generic OpenAI `sk-`; `sk-or-v1-` before `sk-`), because each match is\n * consumed before the next pattern runs → order = attribution.\n * - Redact, never reject — the surrounding knowledge survives; only the\n * credential substring is neutralised.\n * - NEVER a bare high-entropy/hex pattern (it would hit git shas/hashes).\n * Prefix-less service secrets are caught only via `labeled-hex-secret` (a 40+\n * hex value assigned to a secret/token/password/api-key-named field).\n *\n * Two recommended integration shapes for consumers:\n * (a) write boundary — `redactSecrets(text)` before persist (ingest gate);\n * (b) egress — scrub before a value leaves to a user/LLM (highest-value guard).\n */\n\nexport interface SecretPattern {\n /** stable id shown in the redaction marker + findings */\n label: string;\n /** human description of what this matches */\n description: string;\n /** global regex (used for replace-all + counting) */\n regex: RegExp;\n}\n\n/** Ordered most-specific → least. Every regex carries the `g` flag. */\nexport const SECRET_PATTERNS: SecretPattern[] = [\n {\n label: 'private-key',\n description: 'PEM private key block (RSA/EC/OPENSSH/DSA/PGP)',\n regex:\n /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\\s\\S]*?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g,\n },\n {\n label: 'anthropic-api-key',\n description: 'Anthropic API key (sk-ant-…)',\n regex: /sk-ant-(?:api03-)?[A-Za-z0-9_-]{20,}/g,\n },\n {\n // OpenRouter — distinct from OpenAI; runs BEFORE the generic sk- (which would\n // otherwise also match + mislabel it).\n label: 'openrouter-api-key',\n description: 'OpenRouter API key (sk-or-v1- + 64 hex)',\n regex: /\\bsk-or-v1-[0-9a-f]{64}/g,\n },\n {\n // DeepSeek — shares the sk- prefix with OpenAI, so it MUST run before the\n // generic openai pattern (specific-before-generic = correct attribution).\n // DeepSeek's documented shape is sk- + 32 lowercase hex (GitGuardian confirms\n // an sk- prefix but hides the exact regex); the hex-only body + {32,} length\n // distinguishes it from OpenAI's mixed-case base62 keys, so a real OpenAI key\n // is never mislabelled. The field-anchored fallback below catches any\n // DEEPSEEK_API_KEY value that doesn't fit this canonical shape.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (sk- + 32 lowercase hex)',\n regex: /\\bsk-[0-9a-f]{32,}(?![0-9a-z])/g,\n },\n {\n label: 'openai-api-key',\n description: 'OpenAI API key (sk-… / sk-proj-…)',\n regex: /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/g,\n },\n {\n // ElevenLabs — sk_ with UNDERSCORE (vs OpenAI sk-), 48 hex.\n label: 'elevenlabs-api-key',\n description: 'ElevenLabs API key (sk_ + 48 hex)',\n regex: /\\bsk_[0-9a-f]{48}\\b/g,\n },\n {\n // fal.ai — uuid:hex32 (key_id:key_secret); the colon is the signal.\n label: 'fal-api-key',\n description: 'fal.ai key (uuid:hex32)',\n regex: /\\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:[0-9a-f]{32}\\b/g,\n },\n {\n // Black Forest Labs (FLUX) API key — bfl_ prefix + a long token (sample\n // bfl_Qo1…). The distinctive prefix + {20,} length keeps false positives near\n // zero; image-provider sibling of the fal key above.\n label: 'bfl-api-key',\n description: 'Black Forest Labs / FLUX API key (bfl_ + token)',\n regex: /\\bbfl_[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'google-api-key',\n description: 'Google / Gemini API key (AIza…)',\n regex: /AIza[0-9A-Za-z_-]{35}/g,\n },\n {\n label: 'google-oauth-secret',\n description: 'Google OAuth client secret (GOCSPX-…)',\n regex: /GOCSPX-[A-Za-z0-9_-]{28}/g,\n },\n {\n label: 'aws-access-key-id',\n description: 'AWS access key id (AKIA…)',\n regex: /\\bAKIA[0-9A-Z]{16}\\b/g,\n },\n {\n label: 'github-token',\n description: 'GitHub token (ghp_/gho_/ghs_/ghu_/ghr_…)',\n regex: /\\bgh[posru]_[A-Za-z0-9]{36,}\\b/g,\n },\n {\n // GitHub fine-grained PAT — distinct prefix `github_pat_` (not caught by the\n // classic gh[posru]_ above), then base62 + a `_` separator (~82 chars total).\n // The prefix is so distinctive that {50,} keeps false positives at zero.\n label: 'github-fine-grained-pat',\n description: 'GitHub fine-grained personal access token (github_pat_…)',\n regex: /\\bgithub_pat_[A-Za-z0-9_]{50,}/g,\n },\n {\n label: 'gitlab-token',\n description: 'GitLab personal access token (glpat-…)',\n regex: /\\bglpat-[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'slack-token',\n description: 'Slack token (xox[baprs]-…)',\n regex: /\\bxox[baprs]-[A-Za-z0-9-]{10,}/g,\n },\n {\n label: 'stripe-secret-key',\n description: 'Stripe live secret/restricted key (sk_live_/rk_live_…)',\n regex: /\\b[rs]k_live_[A-Za-z0-9]{20,}/g,\n },\n {\n // Resend (re_…). Lookahead requires a digit in the body so we don't redact\n // long snake_case identifiers like re_compute_the_thing.\n label: 'resend-api-key',\n description: 'Resend API key (re_ + token)',\n regex: /\\bre_(?=[A-Za-z0-9_]*\\d)[A-Za-z0-9_]{24,}\\b/g,\n },\n {\n label: 'supabase-access-token',\n description: 'Supabase personal/management access token (sbp_ + 40 hex)',\n regex: /\\bsbp_[0-9a-f]{40}/g,\n },\n {\n label: 'supabase-secret-key',\n description: 'Supabase secret API key (sb_secret_…)',\n regex: /\\bsb_secret_[A-Za-z0-9_-]{20,}/g,\n },\n {\n // Used by every @broberg/* publish — the highest-value leak from a .env / commit history.\n label: 'npm-token',\n description: 'npm publish/automation token (npm_ + 36 base62)',\n regex: /\\bnpm_[A-Za-z0-9]{36}\\b/g,\n },\n {\n label: 'fly-api-token',\n description: 'Fly.io API token (FlyV1 fm2_… / fo1_…)',\n regex: /(?:FlyV1 fm2_[A-Za-z0-9+/=_-]{20,}|\\bfo1_[A-Za-z0-9_-]{20,})/g,\n },\n {\n // Also covers Turso DB/platform auth tokens AND Supabase anon/service_role\n // keys — both are JWTs (eyJ…), so the single JWT pattern catches them.\n label: 'jwt',\n description: 'JSON Web Token (eyJ…) — incl. Turso + Supabase service_role tokens',\n regex: /\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}/g,\n },\n {\n // genApiKey = randomBytes(24).hex → uk_ + exactly 48 lowercase hex.\n label: 'upmetrics-key',\n description: 'Upmetrics project key (uk_ + 48 hex)',\n regex: /\\buk_[0-9a-f]{48}/g,\n },\n {\n label: 'cardmem-key',\n description: 'Cardmem personal/incident/project key (pa_/pi_/pk_ + 64 hex)',\n regex: /\\bp[aik]_[A-Za-z0-9]{20,}/g,\n },\n {\n // cardmem inbox-webhook key — piw_ isn't matched by p[aik]_ above (3rd char 'w' ≠ '_').\n label: 'cardmem-webhook-key',\n description: 'Cardmem inbox-webhook key (piw_ + 64 hex)',\n regex: /\\bpiw_[0-9a-f]{64}/g,\n },\n {\n label: 'trail-key',\n description: 'Trail personal API key (trail_…)',\n regex: /\\btrail_[A-Za-z0-9]{20,}/g,\n },\n {\n // Cronjobs API key (cronjobs.webhouse.net) — cj_ + randomBytes(32).base64url =\n // exactly 43 base64url chars (46 total). Prefix + fixed length = very low FP.\n // The UI's truncated cj_<8 chars>… preview is shorter than {43} → not matched.\n // Negative lookahead (not \\b) because base64url's `-` breaks a trailing \\b.\n label: 'cronjobs-api-key',\n description: 'Cronjobs API key (cj_ + 43 base64url)',\n regex: /\\bcj_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g,\n },\n {\n // randomBytes(32).hex → wh_ + 64 lowercase hex (67 chars total).\n label: 'cms-access-token',\n description: 'webhouse.app CMS access token (wh_ + 64 hex)',\n regex: /\\bwh_[0-9a-f]{64}/g,\n },\n {\n // Cloudflare API token (R2 / DNS management) — 40 base64url chars, NO prefix.\n // A bare {40} would false-positive broadly, so this is CONTEXT-ONLY: it only\n // fires next to a cf/cloudflare-api-token-named field. Runs before\n // labeled-hex-secret so a hex-valued CF token is attributed correctly.\n label: 'cloudflare-api-token',\n description: 'Cloudflare API token (cf/cloudflare-api-token field + 40 base64url)',\n regex: /\\b(?:cf|cloudflare)_?api_?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{40}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Mistral API key — prefix-less ~32 base62 (Christian-confirmed sample). A bare\n // [A-Za-z0-9]{32} would FP on every ID/hash, so CONTEXT-ONLY: anchored on a\n // mistral-(api-)key/token-named field. Runs before labeled-hex for attribution.\n label: 'mistral-api-key',\n description: 'Mistral API key (mistral-(api-)key/token field + 24+ base62)',\n regex: /\\bmistral(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{24,}(?![A-Za-z0-9])/gi,\n },\n {\n // DeepSeek — field-anchored fallback for any DEEPSEEK_API_KEY/TOKEN value that\n // doesn't fit the canonical sk-+hex shape (mirrors the Mistral context-only\n // approach). The field name is the signal → near-zero false positives. The\n // sk-+hex format pattern above already attributes the canonical shape; this\n // backstops a format change or an opaque token.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (deepseek-(api-)key/token field + 20+ token)',\n regex: /\\bdeepseek(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Vimeo personal access token — ~32 lowercase hex, no prefix (sanne). A bare\n // hex32 would FP massively (MD5/UUID), so CONTEXT-ONLY: anchored on a\n // vimeo-(access-)token-named field.\n label: 'vimeo-access-token',\n description: 'Vimeo access token (vimeo-(access-)token field + 20+ base62)',\n regex: /\\bvimeo(?:[_-]?access)?[_-]?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{20,}(?![A-Za-z0-9])/gi,\n },\n {\n // Context-based catch for prefix-less high-entropy service secrets\n // (CMS_JWT_SECRET, revalidateSecret, fleet openssl-rand-hex secrets): a 40+\n // hex value assigned to a field whose name contains\n // secret/token/password/api-key. The name requirement keeps the\n // false-positive rate near zero (a bare 40/64-hex would hit shas/hashes).\n label: 'labeled-hex-secret',\n description: 'A 40+ hex value assigned to a secret/token/password/api-key-named field',\n regex: /\\b[A-Za-z0-9_-]*(?:secret|token|password|api[_-]?key)\\b\\s*[:=]\\s*[\"'`]?[0-9a-f]{40,}/gi,\n },\n {\n // Discord bot token — three base64url segments. Anchored both sides so it\n // can't partial-match a longer dotted string.\n label: 'discord-bot-token',\n description: 'Discord bot token (3 base64url segments)',\n regex: /(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{24,26}\\.[A-Za-z0-9_-]{6}\\.[A-Za-z0-9_-]{27,40}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'discord-mfa-token',\n description: 'Discord MFA token (mfa. + 84 chars)',\n regex: /\\bmfa\\.[A-Za-z0-9_-]{84}\\b/g,\n },\n {\n // Cloudflare Turnstile PROD secret (sanne, verified 2/2) — 0x4 + 6×A prefix,\n // then 26 base64url (35 total). The 24-char SITE key + 1x/2x/3x TEST keys are\n // intentionally NOT matched (the {26} length gate misses them) so a public\n // key is never redacted.\n label: 'cloudflare-turnstile-secret',\n description: 'Cloudflare Turnstile secret key (0x4AAAAAA + 26 base64url, 35 total)',\n regex: /0x4AAAAAA[A-Za-z0-9_-]{26}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'cloudflare-global-key',\n description: 'Cloudflare global API key (37-hex)',\n regex: /\\b[0-9a-f]{37}\\b/g,\n },\n {\n // LAST on purpose: this is the only unprefixed shape in the list, so every\n // anchored pattern above must get first refusal.\n //\n // Philips Hue v2 application key — 40 chars of [A-Za-z0-9-] with NO prefix,\n // so there is nothing to anchor on. The negative lookahead is load-bearing,\n // not decoration: a bare [A-Za-z0-9-]{40} also matches a GIT COMMIT SHA, and\n // telemetry/error output is full of those. A redactor that eats commit\n // hashes gets switched off within a week, after which it protects nothing.\n // Hue keys are mixed-case; SHAs are lowercase hex — that asymmetry is the\n // whole guard. (Pattern contributed + field-tested by beacon, F035.7.)\n label: 'hue-application-key',\n description: 'Philips Hue v2 application key (40 chars, no prefix)',\n regex: /\\b(?![0-9a-f]{40}\\b)[A-Za-z0-9-]{40}\\b/g,\n },\n];\n\n/**\n * WHY a finding was flagged — the two detection axes this package has.\n *\n * `format` the VALUE carries the signal: `sk-ant-…`, `ghp_…`, `AKIA…`. Shape\n * alone identifies it, so it is safe to run on anything.\n * `announced` the LABEL carries the signal: `Adgangskode: hunter2`. The value is\n * arbitrary human text with no shape to match, so the only evidence\n * is that someone wrote the word \"password\" next to it.\n */\nexport type SecretConfidence = 'format' | 'announced';\n\nexport interface RedactionFinding {\n label: string;\n count: number;\n /** which axis matched — see SecretConfidence. */\n confidence: SecretConfidence;\n}\n\nexport interface RedactionResult {\n /** input with every secret replaced by `[REDACTED:<label>]` */\n redacted: string;\n /** per-pattern counts of what was redacted (empty = nothing found ON THE AXES IN `scanned`) */\n findings: RedactionFinding[];\n /**\n * Which axes this call actually EXAMINED — always `['format']`, plus\n * `'announced'` when `opts.announced` was set.\n *\n * It exists because `findings: []` alone cannot tell you which question was\n * asked. `redactSecrets(\"Adgangskode: hunter2\")` and `redactSecrets(\"hello\")`\n * both return an empty `findings`, and until 0.3.0 nothing in the return value\n * distinguished \"we found nothing\" from \"we never looked there\".\n *\n * A caller that must be sure can now ASSERT rather than trust the docs:\n *\n * ```ts\n * const r = redactSecrets(body, { announced: true });\n * if (!r.scanned.includes('announced')) throw new Error('announced axis not scanned');\n * ```\n *\n * Note the honest limit: this does not PREVENT the mistake — someone who\n * forgets the flag can equally forget to check this. It makes the mistake\n * *detectable* instead of merely documented, which is the difference between a\n * check and an agreement. Filed by buddy, who had just declined the same\n * \"we'll agree to label things\" fix from another session on the grounds that\n * an agreement holds only until the first person forgets it, and said it would\n * be cheap to use that argument in one direction and not the other.\n */\n scanned: readonly SecretConfidence[];\n}\n\nexport interface RedactOptions {\n /**\n * Extra consumer/per-tenant patterns, run AFTER the canonical set (so canonical\n * attribution wins). Backs a future self-service \"paste a key → detector\" UI.\n */\n extraPatterns?: SecretPattern[];\n /**\n * Also detect ANNOUNCED secrets — `Adgangskode: hunter2` — where the label is\n * the only evidence. **Off by default, and it must stay that way.** See\n * ANNOUNCED_LABEL for the measurement that decided it.\n */\n announced?: boolean;\n}\n\n/** Marker label for a secret detected by its announcing label rather than shape. */\nexport const ANNOUNCED_LABEL = 'announced-secret';\n\n/**\n * Label + separator + value. The label list is deliberately short and concrete;\n * this is not a general \"looks like config\" detector.\n *\n * WHY THIS IS OPT-IN, MEASURED RATHER THAN GUESSED. Over this repo on\n * 2026-08-14 — 548 tracked files, 544 readable as text, containing essentially\n * no real secrets — this exact regex matched **97 times**, and every one was\n * noise. Per label: `secret` 61, `api key` 33, `password` 4, and every Danish\n * label 0. So 94 of the 97 are the two words that are also ordinary IDENTIFIERS\n * in source code (`secret: config.secret`, `apiKey: Record<…>`).\n *\n * That is the real finding, and it is sharper than \"the pattern is noisy\": its\n * precision depends entirely on WHAT IS BEING SCANNED. In an inbound mail body\n * — buddy's actual case — `Adgangskode:` is a strong signal. In a TypeScript\n * file it is a variable name. **The package cannot know which corpus it is\n * looking at; only the caller can.** So the caller makes the decision, and the\n * default cannot be on. (This is the opposite of this repo's usual defaults-ON\n * stance — webpush F067.1, lens-engine F065 — and the numbers above are why.)\n *\n * A broader label+separator+value pattern measured 305 on the same corpus, and\n * refining it only reached 202 — no amount of tuning makes a generic version\n * safe. A template/env-reference guard (`${FOO}`, `<your-key>`) was written and\n * then dropped: it changed the count by exactly 0, because the noise here is\n * identifiers, not templates.\n *\n * The value must not already be a redaction marker, so this can run AFTER the\n * format pass without flattening its more specific attribution.\n */\nconst ANNOUNCED_SECRET =\n /(\\b(?:adgangskode|kodeord|hemmelighed|password|passwd|api[ -]?key|apinøgle|secret|kode|pwd)\\s*[:=]\\s*)(?!\\[REDACTED:)\\S+/gi;\n\n/** Replacement marker for a redacted secret. */\nexport const redactionMarker = (label: string): string => `[REDACTED:${label}]`;\n\nfunction patternsFor(opts?: RedactOptions): SecretPattern[] {\n return opts?.extraPatterns && opts.extraPatterns.length > 0\n ? [...SECRET_PATTERNS, ...opts.extraPatterns]\n : SECRET_PATTERNS;\n}\n\n/**\n * Scan `text` and replace every detected secret with its redaction marker.\n * Pure: clean input returns byte-identical (`findings: []`).\n *\n * ⚠️ **This does NOT catch an announced secret unless you pass\n * `{ announced: true }`.** `redactSecrets(\"Adgangskode: hunter2\")` returns the\n * password untouched with `findings: []` — which is indistinguishable from\n * \"this text is clean\", because the announced axis was never examined.\n *\n * The two axes are separate and only one is on by default (see\n * SecretConfidence). If you are gating untrusted inbound text, reach for\n * `hasAnnouncedSecret()` — or pass the flag. Do not assume an empty `findings`\n * means safe.\n *\n * Filed by buddy, who nearly reported this package as behaving wrongly: their\n * probe used the defaults and so could not see the axis they were testing. The\n * behaviour is right; the NAMES are the trap — two functions that sound\n * interchangeable, one of which is only complete with a flag.\n */\nexport function redactSecrets(text: string, opts?: RedactOptions): RedactionResult {\n // Computed from the OPTIONS, not from what was found — so it answers \"which\n // question did this call ask?\" identically on empty, clean and dirty input.\n // The empty-text path returns it too, deliberately: a caller asserting on\n // `scanned` must not get a different shape just because the body was blank.\n const scanned: readonly SecretConfidence[] = opts?.announced\n ? ['format', 'announced']\n : ['format'];\n if (!text) return { redacted: text, findings: [], scanned };\n let redacted = text;\n const findings: RedactionFinding[] = [];\n for (const p of patternsFor(opts)) {\n let count = 0;\n redacted = redacted.replace(p.regex, () => {\n count++;\n return redactionMarker(p.label);\n });\n if (count > 0) findings.push({ label: p.label, count, confidence: 'format' });\n }\n // Announced runs LAST, and only on request. Order is not cosmetic: the format\n // pass has already replaced everything it recognises, and this regex refuses a\n // value that is already a marker — so `API key: sk-ant-…` keeps its specific\n // `anthropic-api-key` attribution instead of being flattened to a generic one.\n // The announcing label itself is KEPT in the output; only the value goes, so\n // the redacted text still reads `Adgangskode: [REDACTED:announced-secret]` and\n // a human or model reading it can still tell what was removed.\n if (opts?.announced) {\n let count = 0;\n const redactedAnnounced = redacted.replace(ANNOUNCED_SECRET, (_match, prefix: string) => {\n count++;\n return prefix + redactionMarker(ANNOUNCED_LABEL);\n });\n if (count > 0) {\n redacted = redactedAnnounced;\n findings.push({ label: ANNOUNCED_LABEL, count, confidence: 'announced' });\n }\n }\n return { redacted, findings, scanned };\n}\n\n/**\n * True if `text` announces a credential by label — `Adgangskode: hunter2` —\n * without building a redaction. Cheap enough to run on every inbound message.\n *\n * This exists because for untrusted inbound text heading to a model, the right\n * response is often to REFUSE rather than redact: a false positive costs a\n * slightly worse classification, a false negative costs a leak. That use needs a\n * boolean, not a redactor. (buddy's reasoning, F035.8.)\n */\nexport function hasAnnouncedSecret(text: string): boolean {\n if (!text) return false;\n ANNOUNCED_SECRET.lastIndex = 0;\n return ANNOUNCED_SECRET.test(text);\n}\n\n/**\n * True if `text` contains at least one detectable secret. Honours\n * `opts.announced` — a caller who asks for the announced axis and is told\n * `false` must be able to believe it.\n */\nexport function hasSecret(text: string, opts?: RedactOptions): boolean {\n if (opts?.announced && hasAnnouncedSecret(text)) return true;\n return patternsFor(opts).some((p) => {\n p.regex.lastIndex = 0;\n return p.regex.test(text);\n });\n}\n\nexport interface ClassifyResult {\n /** the matching pattern's stable label (e.g. `openai-api-key`) */\n label: string;\n /** the matching pattern's human description (e.g. `OpenAI API key (sk-… / sk-proj-…)`) */\n description: string;\n}\n\n/**\n * Classify a SINGLE pasted token — the INVERSE of redaction. Returns the first\n * (most-specific) pattern the value matches, or `null`. Backs a \"paste a key →\n * detect its type\" UI (cardmem F214 Secrets Vault) so every consumer shares the\n * same classification, not just the same redaction.\n *\n * First-match-wins over the ordered `SECRET_PATTERNS`, so `sk-ant-…` classifies\n * as `anthropic-api-key`, never the generic `openai-api-key`. Field-anchored\n * context-only patterns (mistral / vimeo / cloudflare-api-token /\n * labeled-hex-secret / deepseek-fallback) only match when the pasted value\n * includes their `NAME=` context; a bare provider token classifies via its\n * prefix pattern, and a prefix-less bare token (e.g. a raw Mistral key) is\n * genuinely unidentifiable → `null`. `opts.extraPatterns` run AFTER the\n * canonical set (canonical attribution wins). Input is trimmed; empty /\n * whitespace-only → `null`.\n */\nexport function classify(value: string, opts?: RedactOptions): ClassifyResult | null {\n if (!value) return null;\n const v = value.trim();\n if (!v) return null;\n for (const p of patternsFor(opts)) {\n p.regex.lastIndex = 0;\n if (p.regex.test(v)) return { label: p.label, description: p.description };\n }\n return null;\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@broberg/secret-scan",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
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",