@broberg/secret-scan 0.11.0 → 0.12.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 +29 -0
- package/dist/index.cjs +52 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -2
- package/dist/index.d.ts +7 -2
- package/dist/index.js +52 -4
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -117,6 +117,35 @@ on the same corpus, and refining it only reached **202**. A template/env-guard
|
|
|
117
117
|
(`${FOO}`, `<your-key>`) was written and then dropped — it changed the count by
|
|
118
118
|
**exactly 0**, because the noise here is identifiers, not templates.
|
|
119
119
|
|
|
120
|
+
### Scanning source code? `{ announced: 'code' }` (v0.12.0)
|
|
121
|
+
|
|
122
|
+
The rule above reads **prose**, and in source code it is wrong both ways (filed
|
|
123
|
+
by pitch, whose GitGuardian scan flagged a line this package passed):
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
const line = "JSON.stringify({ currentPassword: 'a', newPassword: 'abcdefgh' })";
|
|
127
|
+
redactSecrets(line, { announced: true }).findings; // [] ← compound identifier, digit-free value
|
|
128
|
+
redactSecrets(line, { announced: 'code' }).redacted;
|
|
129
|
+
// "JSON.stringify({ currentPassword: 'a', newPassword: '[REDACTED:announced-secret]' })"
|
|
130
|
+
|
|
131
|
+
redactSecrets("apiKey: nanoid(32)", { announced: true }).findings.length; // 1 ← an expression
|
|
132
|
+
redactSecrets("apiKey: nanoid(32)", { announced: 'code' }).findings; // []
|
|
133
|
+
hasAnnouncedSecret(line, 'code'); // true
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`'code'` flags a **quoted string literal** (4+ chars, no whitespace, no `${`)
|
|
137
|
+
assigned with `:` or `=` to an identifier that **contains** a credential word
|
|
138
|
+
(`newPassword`, `DB_PASSWORD`, `clientSecret`, `apiKey`, `KODEORD`). Unquoted
|
|
139
|
+
values are calls, variables or env references, so they are never flagged.
|
|
140
|
+
|
|
141
|
+
Measured 1/10 2026 over 2,848 TS/JS files in 13 fleet repos: most hits are test
|
|
142
|
+
fixtures (`apiKey: "re_x"`), which is exactly GitGuardian's class. The noise
|
|
143
|
+
shapes are refused by name: `password: "Adgangskode"` (an i18n label),
|
|
144
|
+
`PASSWORD_TOO_SHORT: "password_too_short"`, `secretPath: "…"`, a ternary
|
|
145
|
+
branch, a `type X = '…' | '…'` union. **Not caught:** `.env` files (their values
|
|
146
|
+
are unquoted; use `announced: true` there), comparisons (`password === 'x'`).
|
|
147
|
+
It costs about as much as the format pass: ~2 s per MB, linear.
|
|
148
|
+
|
|
120
149
|
### `hasAnnouncedSecret` — for when the right answer is to refuse
|
|
121
150
|
|
|
122
151
|
```ts
|
package/dist/index.cjs
CHANGED
|
@@ -153,7 +153,17 @@ var PATTERNS = [
|
|
|
153
153
|
// value class: base64 padding never STARTS a value, and excluding it there
|
|
154
154
|
// blocked every `KEY=value` form — measured, `blob=<secret>` went
|
|
155
155
|
// unredacted while the same pair in CSV and Terraform was caught.
|
|
156
|
-
|
|
156
|
+
//
|
|
157
|
+
// THE CHEAP LOOKBEHIND GOES FIRST (0.11.1). Both are zero-width at the same
|
|
158
|
+
// position, so the order does not change what matches — only what it costs.
|
|
159
|
+
// With the 100-char id search first, EVERY character of a long run paid it:
|
|
160
|
+
// 50,000 × 'A' took 3.7 s in this one pattern and a 400 KB blob 30+ s in
|
|
161
|
+
// redactSecrets. `(?<![A-Za-z0-9/+])` rejects every position inside a run
|
|
162
|
+
// in one step, and the lookahead then demands a whole 40-char value AHEAD
|
|
163
|
+
// before anything looks behind — so after a space or a colon (where the
|
|
164
|
+
// first guard passes) it fails in a character or two. The expensive id
|
|
165
|
+
// search only runs where a complete candidate already stands.
|
|
166
|
+
regex: /(?<![A-Za-z0-9/+])(?=[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=]))(?<=(?:AKIA|ASIA)[0-9A-Z]{16}[\s\S]{0,80})[A-Za-z0-9/+=]{40}/g
|
|
157
167
|
},
|
|
158
168
|
{
|
|
159
169
|
// A SEPARATE LABEL FROM AKIA, and the reason is operational rather than
|
|
@@ -581,6 +591,25 @@ var TRAILING_DELIMS = /[)\]},;"'`]+$/;
|
|
|
581
591
|
function plausibleSecretValue(candidate) {
|
|
582
592
|
return /\d/.test(candidate) || candidate.length >= 16;
|
|
583
593
|
}
|
|
594
|
+
var CREDENTIAL_WORD = "(?:password|passwd|pwd|secret|api_?key|adgangskode|kodeord)";
|
|
595
|
+
var CODE_ANNOUNCED_SECRET = new RegExp(
|
|
596
|
+
"(?<![\\w$])(?<!\\?\\s{0,3}[\"'`]?)(?<!\\btype\\s{1,3})(([\"'`]?)(?=([\\w$]+))\\3\\2\\s*[:=]\\s*)([\"'`])([^\"'`\\s]*)\\4(?!\\s*[|&])",
|
|
597
|
+
"g"
|
|
598
|
+
);
|
|
599
|
+
var CODE_DESCRIPTOR_SUFFIX = /^[_$-]*(?:path|name|id|file|url|uri|label|field|header|env|var|ref|type|hint|placeholder|policy|pattern|length|len|min|max|count|mode|provider|prompt|text|title|message|error|status)s?$/i;
|
|
600
|
+
var CREDENTIAL_WORD_ONLY = new RegExp("^" + CREDENTIAL_WORD + "$", "i");
|
|
601
|
+
var CREDENTIAL_WORD_ANY = new RegExp(CREDENTIAL_WORD, "gi");
|
|
602
|
+
var squash = (s) => s.toLowerCase().replace(/[-_\s]/g, "");
|
|
603
|
+
function codeCandidateOk(label, value) {
|
|
604
|
+
if (value.length < 4 || value.includes("${") || value.includes(MARKER_PREFIX)) return false;
|
|
605
|
+
let end = -1;
|
|
606
|
+
for (const m of label.matchAll(CREDENTIAL_WORD_ANY)) end = m.index + m[0].length;
|
|
607
|
+
if (end < 0) return false;
|
|
608
|
+
if (CODE_DESCRIPTOR_SUFFIX.test(label.slice(end))) return false;
|
|
609
|
+
if (CREDENTIAL_WORD_ONLY.test(value.replace(/[\s_-]/g, ""))) return false;
|
|
610
|
+
if (squash(value) === squash(label)) return false;
|
|
611
|
+
return true;
|
|
612
|
+
}
|
|
584
613
|
var redactionMarker = (label) => `[REDACTED:${label}]`;
|
|
585
614
|
var MARKER_PREFIX = redactionMarker("").slice(0, -1);
|
|
586
615
|
function patternsFor(opts) {
|
|
@@ -613,7 +642,18 @@ function redactSecrets(text, opts) {
|
|
|
613
642
|
}
|
|
614
643
|
}
|
|
615
644
|
}
|
|
616
|
-
if (opts?.announced) {
|
|
645
|
+
if (opts?.announced === "code") {
|
|
646
|
+
let count = 0;
|
|
647
|
+
redacted = redacted.replace(
|
|
648
|
+
CODE_ANNOUNCED_SECRET,
|
|
649
|
+
(match, prefix, _q, label, quote, value) => {
|
|
650
|
+
if (!codeCandidateOk(label, value)) return match;
|
|
651
|
+
count++;
|
|
652
|
+
return prefix + quote + redactionMarker(ANNOUNCED_LABEL) + quote;
|
|
653
|
+
}
|
|
654
|
+
);
|
|
655
|
+
if (count > 0) findings.push({ label: ANNOUNCED_LABEL, count, confidence: "announced" });
|
|
656
|
+
} else if (opts?.announced) {
|
|
617
657
|
let count = 0;
|
|
618
658
|
const redactedAnnounced = redacted.replace(
|
|
619
659
|
ANNOUNCED_SECRET,
|
|
@@ -634,8 +674,14 @@ function redactSecrets(text, opts) {
|
|
|
634
674
|
}
|
|
635
675
|
return { redacted, findings, scanned };
|
|
636
676
|
}
|
|
637
|
-
function hasAnnouncedSecret(text) {
|
|
677
|
+
function hasAnnouncedSecret(text, mode = "prose") {
|
|
638
678
|
if (!text) return false;
|
|
679
|
+
if (mode === "code") {
|
|
680
|
+
for (const m of text.matchAll(CODE_ANNOUNCED_SECRET)) {
|
|
681
|
+
if (codeCandidateOk(m[3] ?? "", m[5] ?? "")) return true;
|
|
682
|
+
}
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
639
685
|
ANNOUNCED_SECRET.lastIndex = 0;
|
|
640
686
|
for (let m = ANNOUNCED_SECRET.exec(text); m !== null; m = ANNOUNCED_SECRET.exec(text)) {
|
|
641
687
|
const value = m[2] ?? "";
|
|
@@ -651,7 +697,9 @@ function hasAnnouncedSecret(text) {
|
|
|
651
697
|
return false;
|
|
652
698
|
}
|
|
653
699
|
function hasSecret(text, opts) {
|
|
654
|
-
if (opts?.announced && hasAnnouncedSecret(text))
|
|
700
|
+
if (opts?.announced && hasAnnouncedSecret(text, opts.announced === "code" ? "code" : "prose")) {
|
|
701
|
+
return true;
|
|
702
|
+
}
|
|
655
703
|
return patternsFor(opts).some((p) => {
|
|
656
704
|
p.regex.lastIndex = 0;
|
|
657
705
|
return p.regex.test(text);
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @broberg/secret-scan — fleet secret/credential redaction.\n *\n * `redactSecrets(text)` replaces every matched secret with `[REDACTED:<label>]`\n * and reports what it found. PURE + deterministic (regex/string only, no deps,\n * no I/O) so an engine write-gate, an egress scrub, a CLI, an admin preview UI,\n * and any repo all share the EXACT same detection — and it's trivially testable.\n *\n * Lifted verbatim from broberg/trail F197 (the second-brain safeguard); see\n * docs/features/F035-secret-scan.md. components owns + publishes this; @trail/shared\n * re-exports it.\n *\n * Design choices:\n * - Pattern-based, NOT entropy/generic-randomness — a redacted real fact would\n * corrupt knowledge, so we accept missing an exotic token over false positives.\n * - Order matters: most-specific patterns run first (e.g. `sk-ant-` before the\n * generic OpenAI `sk-`; `sk-or-v1-` before `sk-`), because each match is\n * consumed before the next pattern runs → order = attribution.\n * - Redact, never reject — the surrounding knowledge survives; only the\n * credential substring is neutralised.\n * - NEVER a bare high-entropy/hex pattern (it would hit git shas/hashes).\n * Prefix-less service secrets are caught only via `labeled-hex-secret` (a 40+\n * hex value assigned to a secret/token/password/api-key-named field).\n *\n * Two recommended integration shapes for consumers:\n * (a) write boundary — `redactSecrets(text)` before persist (ingest gate);\n * (b) egress — scrub before a value leaves to a user/LLM (highest-value guard).\n */\n\nexport interface SecretPattern {\n /** stable id shown in the redaction marker + findings */\n label: string;\n /** human description of what this matches */\n description: string;\n /** The matcher. GLOBAL on the internal list, because the redaction pass\n * replaces every occurrence — and NON-GLOBAL on everything this module\n * exports (`SECRET_PATTERNS`, `VALUE_ONLY_PATTERNS`), because a shared `/g`\n * regex carries `lastIndex` between calls and answers differently each time.\n * Through 0.7.0 this comment claimed the opposite, and it is the tooltip a\n * consumer sees: following it, `while ((m = p.regex.exec(text)))` never\n * advances and spins forever. */\n regex: RegExp;\n}\n\n/** Ordered most-specific → least. Every regex carries the `g` flag. */\n// The INTERNAL list. Global (`/g`) because the redaction pass replaces every\n// occurrence. Never exported directly — see SECRET_PATTERNS below for why.\nconst 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 // ── AWS ─────────────────────────────────────────────────────────────────\n //\n // ORDER IS LOAD-BEARING HERE, and it is not the order you would write first.\n // redactSecrets() applies patterns in sequence to the text it has ALREADY\n // redacted, so the id pattern must run LAST: it replaces `AKIA…` with a\n // marker, and the paired rule below anchors on that id. Put the id first and\n // the pair rule silently stops firing — a guard that is present, tested in\n // isolation, and dead in place.\n {\n // THE HALF THAT MATTERS, and it shipped unmatched for months. An access key\n // id alone is useless to an attacker; the SECRET key is the credential. So\n // redacting only the id — and stamping [REDACTED:…] right beside the live\n // secret — is worse than redacting nothing, because the marker tells the\n // reader the text was cleaned. Reported by cardmem the day they took AWS on.\n //\n // A bare 40-char base64 value CANNOT be matched: it is the shape of every\n // git object hash and base64 body in every repo we own. So this is\n // CONTEXT-ONLY, like every other prefix-less secret in this file.\n //\n // `access` is REQUIRED in the field name on purpose. A bare `secret_key`\n // (Terraform's spelling) would drag in far too much; that case is caught by\n // the paired rule below instead, which is the argument for having both.\n //\n // AND THE VALUE CLASS IS \"ANYTHING THAT IS NOT A DELIMITER\", not an\n // alphabet. cardmem measured their Tigris secret's charset after 0.8.1 and\n // it contains `+` — which our class happened to include, but only because\n // we guessed base64 rather than base64url. Their warning is the one worth\n // acting on: that is ONE key. It tells us what CAN occur, never what always\n // occurs, and the next provider's alphabet is another guess we would make\n // the same way. Under a field literally named `aws_secret_access_key`, the\n // NAME is the evidence; the value's alphabet adds nothing and can only be\n // wrong. So the value runs to the first delimiter and no further.\n //\n // ONE EXCLUSION, and it is a false positive our own docs produced the\n // moment the class widened: a value that is a REFERENCE to a secret is not\n // a secret. `secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!` is code\n // showing how to READ the credential, and redacting it would put a\n // [REDACTED:…] marker in a README about wiring up storage. So a value that\n // is ENTIRELY a dotted identifier path, or starts with a shell/template\n // expansion, is skipped. It must be the WHOLE value — a real secret may\n // contain dots, and the exclusion must not fire on one that does.\n //\n // THE LENGTH IS A FLOOR, NOT AWS'S 40 — measured by cardmem in production\n // and it is the finding that matters most here. Their four AWS_*-named\n // variables on Fly are NOT AWS: they are Tigris (Fly's S3-compatible\n // store), with a 54-character `tid_` id and a 75-character secret. Every\n // S3-compatible service — Tigris, R2, MinIO, Backblaze — reuses AWS's\n // variable NAMES with its own key format.\n //\n // Pinning 40 put a shape assumption on top of a name anchor, so the field\n // said AWS_SECRET_ACCESS_KEY, the value did not look like AWS, and the\n // credential stayed in the clear with no marker anywhere near it. The field\n // name is the signal in every context-only pattern in this file; that is\n // the whole design, and requiring a second signal quietly undid it.\n label: 'aws-secret-access-key',\n description: 'AWS/S3-compatible secret access key ((aws-)secret-access-key field + 20+ non-delimiter chars)',\n regex: /\\b(?:aws[_-]?)?secret[_-]?access[_-]?key\\b[\"'`]?\\s*[:=]\\s*[\"'`]?(?!(?:\\$|\\{)|(?:[A-Za-z_$][A-Za-z0-9_$]*(?:\\.[A-Za-z_$][A-Za-z0-9_$]*)+!?)(?=[\\s\"'`,;]|$))[^\\s\"'`,;]{20,}/gi,\n },\n {\n // An STS session token is a live credential for as long as it lasts, and it\n // travels in the same dump as the pair above.\n label: 'aws-session-token',\n description: 'AWS session token ((aws-)session-token field + 100+ base64)',\n regex: /\\b(?:aws[_-]?)?session[_-]?token\\b[\"'`]?\\s*[:=]\\s*[\"'`]?(?!(?:\\$|\\{)|(?:[A-Za-z_$][A-Za-z0-9_$]*(?:\\.[A-Za-z_$][A-Za-z0-9_$]*)+!?)(?=[\\s\"'`,;]|$))[^\\s\"'`,;]{100,}/gi,\n },\n {\n // THE WINDOW IS MEASURED, not chosen. Gap between the end of the id and the\n // start of the secret, in the six formats these actually arrive in:\n //\n // console CSV row 1 terraform provider block 18\n // sts assume-role JSON 21 aws CLI credentials file 25\n // env export pair 30 docker-compose env 30\n //\n // 80 is the largest real case plus room for one intervening line, and both\n // sides of it are pinned by a fixture — a proximity threshold nothing can\n // move is a magic number wearing a measurement's clothes (F035.12).\n //\n // The false-positive cost is near zero BECAUSE the id must be present: a\n // 40-char base64 string is only redacted when an AWS access key id sits\n // within 80 characters of it. That also catches the pair when the field is\n // named something we never anticipated, which the rule above cannot.\n label: 'aws-secret-access-key-paired',\n description: 'A 40-char base64 value within 80 characters of an AWS access key id',\n // `=` is deliberately NOT in the leading lookbehind, though it IS in the\n // value class: base64 padding never STARTS a value, and excluding it there\n // blocked every `KEY=value` form — measured, `blob=<secret>` went\n // unredacted while the same pair in CSV and Terraform was caught.\n regex: /(?<=(?:AKIA|ASIA)[0-9A-Z]{16}[\\s\\S]{0,80})(?<![A-Za-z0-9/+])[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])/g,\n },\n {\n // A SEPARATE LABEL FROM AKIA, and the reason is operational rather than\n // tidy: the two demand different responses. A leaked long-term key must be\n // rotated; a leaked STS key may already have expired on its own. A reader\n // seeing [REDACTED:…] in a log can only make that call if the marker says\n // which one it was. ASIA was unmatched entirely before 0.8.0, so an\n // assumed-role dump read as clean.\n label: 'aws-temporary-access-key-id',\n description: 'AWS temporary (STS) access key id (ASIA…)',\n regex: /\\bASIA[0-9A-Z]{16}\\b/g,\n },\n {\n label: 'aws-access-key-id',\n description: 'AWS long-term access key id (AKIA…)',\n regex: /\\bAKIA[0-9A-Z]{16}\\b/g,\n },\n {\n label: 'github-token',\n description: 'GitHub token (ghp_/gho_/ghs_/ghu_/ghr_…)',\n regex: /\\bgh[posru]_[A-Za-z0-9]{36,}\\b/g,\n },\n {\n // GitHub fine-grained PAT — distinct prefix `github_pat_` (not caught by the\n // classic gh[posru]_ above), then base62 + a `_` separator (~82 chars total).\n // The prefix is so distinctive that {50,} keeps false positives at zero.\n label: 'github-fine-grained-pat',\n description: 'GitHub fine-grained personal access token (github_pat_…)',\n regex: /\\bgithub_pat_[A-Za-z0-9_]{50,}/g,\n },\n {\n label: 'gitlab-token',\n description: 'GitLab personal access token (glpat-…)',\n regex: /\\bglpat-[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'slack-token',\n description: 'Slack token (xox[baprs]-…)',\n regex: /\\bxox[baprs]-[A-Za-z0-9-]{10,}/g,\n },\n {\n label: 'stripe-secret-key',\n description: 'Stripe live secret/restricted key (sk_live_/rk_live_…)',\n regex: /\\b[rs]k_live_[A-Za-z0-9]{20,}/g,\n },\n {\n // Filed by buddy, who found REAL ones sitting in plaintext in their own\n // transcription DB. Their scrub deliberately runs the format axis ONLY, so a\n // prefixed secret we do not match is a secret nobody catches — a precise\n // prefix is the only route that helps them. Zero false-positive risk: the\n // literal `whsec_` does not occur by accident.\n label: 'stripe-webhook-secret',\n description: 'Stripe webhook signing secret (whsec_…)',\n regex: /\\bwhsec_[A-Za-z0-9+/=_-]{20,}/g,\n },\n {\n // Resend (re_…). Lookahead requires a digit in the body so we don't redact\n // long snake_case identifiers like re_compute_the_thing.\n label: 'resend-api-key',\n description: 'Resend API key (re_ + token)',\n regex: /\\bre_(?=[A-Za-z0-9_]*\\d)[A-Za-z0-9_]{24,}\\b/g,\n },\n {\n label: 'supabase-access-token',\n description: 'Supabase personal/management access token (sbp_ + 40 hex)',\n regex: /\\bsbp_[0-9a-f]{40}/g,\n },\n {\n label: 'supabase-secret-key',\n description: 'Supabase secret API key (sb_secret_…)',\n regex: /\\bsb_secret_[A-Za-z0-9_-]{20,}/g,\n },\n {\n // Used by every @broberg/* publish — the highest-value leak from a .env / commit history.\n label: 'npm-token',\n description: 'npm publish/automation token (npm_ + 36 base62)',\n regex: /\\bnpm_[A-Za-z0-9]{36}\\b/g,\n },\n {\n label: 'fly-api-token',\n description: 'Fly.io API token (FlyV1 fm2_… / fo1_…)',\n regex: /(?:FlyV1 fm2_[A-Za-z0-9+/=_-]{20,}|\\bfo1_[A-Za-z0-9_-]{20,})/g,\n },\n {\n // Also covers Turso DB/platform auth tokens AND Supabase anon/service_role\n // keys — both are JWTs (eyJ…), so the single JWT pattern catches them.\n label: 'jwt',\n description: 'JSON Web Token (eyJ…) — incl. Turso + Supabase service_role tokens',\n regex: /\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}/g,\n },\n {\n // genApiKey = randomBytes(24).hex → uk_ + exactly 48 lowercase hex.\n label: 'upmetrics-key',\n description: 'Upmetrics project key (uk_ + 48 hex)',\n regex: /\\buk_[0-9a-f]{48}/g,\n },\n {\n label: 'cardmem-key',\n description: 'Cardmem personal/incident/project key (pa_/pi_/pk_ + 64 hex)',\n regex: /\\bp[aik]_[A-Za-z0-9]{20,}/g,\n },\n {\n // cardmem inbox-webhook key — piw_ isn't matched by p[aik]_ above (3rd char 'w' ≠ '_').\n label: 'cardmem-webhook-key',\n description: 'Cardmem inbox-webhook key (piw_ + 64 hex)',\n regex: /\\bpiw_[0-9a-f]{64}/g,\n },\n {\n // VERIFIED WITH THE OWNER, 2026-08-28: `trail_` + exactly 64 LOWERCASE HEX.\n // trail generated 2000 keys through @broberg/apikey's generateKey('trail')\n // and counted the alphabet: 0-9a-f only, length 64-64, no `-`, no `_`. Source\n // is `${prefix}_${randomBytes(bytes).toString(\"hex\")}`, bytes=32, with a hard\n // floor of 16 — so even a future caller asking for the minimum yields 32 hex\n // chars, still above {20,}.\n //\n // Recorded because the QUESTION is easy to re-ask and the ANSWER is not: this\n // is one of the few patterns here assuming alphanumerics only, and had trail\n // used base64url (the more common one-liner) the `-` and `_` would break the\n // run, {20,} would never be satisfied, and the WHOLE key would pass through\n // unredacted — not partially, entirely. Measured rather than assumed, because\n // two sampled keys cannot tell hex from base64url that has not hit a `-` yet.\n label: 'trail-key',\n description: 'Trail personal API key (trail_ + 64 hex; verified 2026-08-28)',\n regex: /\\btrail_[A-Za-z0-9]{20,}/g,\n },\n {\n // Cronjobs API key (cronjobs.webhouse.net) — cj_ + randomBytes(32).base64url =\n // exactly 43 base64url chars (46 total). Prefix + fixed length = very low FP.\n // The UI's truncated cj_<8 chars>… preview is shorter than {43} → not matched.\n // Negative lookahead (not \\b) because base64url's `-` breaks a trailing \\b.\n label: 'cronjobs-api-key',\n description: 'Cronjobs API key (cj_ + 43 base64url)',\n regex: /\\bcj_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g,\n },\n {\n // HelpDesk API key (helpdesk.broberg.ai) — minted through @broberg/apikey,\n // which is OURS: generateKey(prefix, 32) → `${prefix}_${randomBytes(32).hex}`,\n // so exactly 64 LOWERCASE hex. Verified in packages/apikey/src/core.ts rather\n // than taken from the report. There is no checksum and no internal structure\n // to anchor on; prefix + fixed length + hex is everything there is, and it is\n // enough — `hd_live_` followed by exactly 64 hex does not occur by accident.\n //\n // THE LENGTH IS EXACT ON PURPOSE, and this is the half that needs defending\n // in six months. HelpDesk shows a PREVIEW — `hd_live_f4b4cf`, prefix + 6 hex\n // — deliberately, in their UI and their logs, so a human can see WHICH key\n // was revoked. It is not a secret. Redacting it breaks a value designed to be\n // read, and then the preview stops doing its job.\n //\n // So the tempting loosening — \"let us catch the shortened ones too\" — is the\n // one thing this pattern must never accept. `{64}` excludes the preview, and\n // a NAMED test says so, because by then nobody will remember why.\n //\n // The trailing lookahead covers BOTH cases: 65 hex is not a key, and neither\n // is 64 lowercase followed by an uppercase hex digit.\n //\n // NO PUBLISHABLE VARIANT, measured not assumed: HelpDesk is headless and its\n // console is a client of the same API using a session token. `grep -c \"hd_\"`\n // in the deployed bundle returns 0, so no key ever reaches a browser and the\n // Stripe pk_live_ trap has no counterpart here. Re-check if that changes.\n label: 'helpdesk-api-key',\n description: 'HelpDesk API key (hd_live_ + 64 hex, minted by @broberg/apikey)',\n regex: /\\bhd_live_[0-9a-f]{64}(?![0-9a-fA-F])/g,\n },\n {\n // UpCloud API token — `ucat_` + a ULID: exactly 26 Crockford base32 chars\n // (no I, L, O, U). Measured in three independent places, not inferred from\n // the masked example that filed it: UpCloud's API docs (create response),\n // UpCloud's own Go client fixture, and Kingfisher's rule — all three are in\n // test/f035-16.test.ts. The token's `id` is a separate UUID and is not a\n // secret.\n //\n // NO EXAMPLE VALUE IN THIS COMMENT, on purpose: the bundle keeps comments,\n // so a literal token here ships in dist/ and the scanner flags ITSELF — the\n // pre-commit gate's test caught exactly that on the first 0.10.0 tag.\n //\n // Case-insensitive like Kingfisher: Crockford decodes either case, and a\n // lowercased token is still a live credential. The trailing lookahead keeps\n // 27 chars from matching its first 26 — and leaves UpCloud's own\n // `ucat_[REDACTED]` marker alone.\n label: 'upcloud-api-token',\n description: 'UpCloud API token (ucat_ + 26 Crockford base32)',\n regex: /\\bucat_[0-9A-HJKMNP-TV-Z]{26}(?![0-9A-Za-z])/gi,\n },\n // ── F035.17 — the vault survey of 30 Sep 2026. Every shape below was MEASURED\n // on values already stored in cardmem's vault (the script read them server-side\n // and printed only prefix, length and charset), then checked against a source:\n // Kingfisher's public rule set for vendor tokens, our own minters for fleet keys.\n // No example value appears in these comments: the bundle keeps comments, and a\n // literal here would make the scanner flag its own dist/ (see upcloud above).\n {\n // Cloudflare's prefixed user API token. 3 in the vault, all 48 after the\n // prefix; Kingfisher allows 41-64. Runs before the context-only\n // cloudflare-api-token below so a prefixed one is named by its prefix.\n label: 'cloudflare-user-api-token',\n description: 'Cloudflare user API token (cfut_ + 41-64 base64url)',\n regex: /\\bcfut_[A-Za-z0-9_-]{41,64}(?![A-Za-z0-9_-])/g,\n },\n {\n // Runpod: rpa_ + 40 uppercase/digit + a 6-char mixed-case checksum tail\n // (Kingfisher runpod.1). 2 in the vault, both 46.\n label: 'runpod-api-key',\n description: 'Runpod API key (rpa_ + 46)',\n regex: /\\brpa_[A-Z0-9]{40}[A-Za-z0-9]{6}(?![A-Za-z0-9])/g,\n },\n {\n // Hugging Face user (hf_) and org (api_org_) tokens: 34 letters/digits.\n label: 'huggingface-token',\n description: 'Hugging Face token (hf_ / api_org_ + 34)',\n regex: /\\b(?:hf|api_org)_[A-Za-z0-9]{34}(?![A-Za-z0-9])/g,\n },\n {\n // Tailscale: tskey-<kind>-<id>-<secret>. The body carries its own dash, so\n // the class includes it. Measured 50-51 after the kind; Kingfisher's {20,36}\n // would stop short of those, so the ceiling is ours.\n label: 'tailscale-key',\n description: 'Tailscale key (tskey-<kind>-…)',\n regex: /\\btskey-[a-z]{3,10}-[A-Za-z0-9_-]{20,64}(?![A-Za-z0-9_-])/g,\n },\n {\n // Tigris secret access key: tsec_ + exactly 70 (Kingfisher tigris.2).\n label: 'tigris-secret-key',\n description: 'Tigris secret access key (tsec_ + 70)',\n regex: /\\btsec_[A-Za-z0-9_+-]{70}(?![A-Za-z0-9_+-])/g,\n },\n {\n // Slack APP-level token. slack-token above only knows xox*, so an xapp-\n // token went through untouched. Same label on purpose: the vault already\n // stores it as slack-token, and a second name for one provider helps nobody.\n label: 'slack-token',\n description: 'Slack app-level token (xapp-…)',\n regex: /\\bxapp-\\d{1,3}-[A-Za-z0-9]{8,15}-\\d{8,15}-[A-Za-z0-9]{20,70}(?![A-Za-z0-9])/g,\n },\n {\n // Aiven service password — the credential UpCloud's managed PostgreSQL hands\n // out. AVNS_ + 19. One in the vault; [Likely] fixed length, and a password\n // that silently stops matching is caught by the survey re-run, not by luck.\n label: 'aiven-service-password',\n description: 'Aiven service password (AVNS_ + 19) — UpCloud managed databases',\n regex: /\\bAVNS_[A-Za-z0-9_-]{19}(?![A-Za-z0-9_-])/g,\n },\n {\n // BID app key. OURS: broberg-id mints `bidk_${randomBytes(32).base64url}`,\n // so exactly 43 base64url — a fact about our minter, not a guess.\n label: 'bid-app-key',\n description: 'Broberg ID app key (bidk_ + 43 base64url)',\n regex: /\\bbidk_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g,\n },\n {\n // Fleet hex keys measured in the vault. Hex length is fixed by construction\n // (randomBytes(n).hex), so the survey's lengths are the minter's lengths.\n label: 'beacon-token',\n description: 'Beacon token (bcn_ + 64 hex)',\n regex: /\\bbcn_[0-9a-f]{64}(?![0-9a-fA-F])/g,\n },\n {\n label: 'mailworker-admin-key',\n description: 'mailworker admin key (mw_ + 64 hex)',\n regex: /\\bmw_[0-9a-f]{64}(?![0-9a-fA-F])/g,\n },\n {\n label: 'upmetrics-remediation-token',\n description: 'Upmetrics remediation token (umrt_ + 48 hex)',\n regex: /\\bumrt_[0-9a-f]{48}(?![0-9a-fA-F])/g,\n },\n {\n // A database URL with a password in it. Matches ONLY the password (the\n // lookbehind pins scheme://user: before it, the lookahead pins @ after), so\n // a redacted URL still says which database it points at.\n //\n // The password must contain a digit or be 12+ characters — so the\n // `user:password@` and `user:pass@` placeholders every README carries stay\n // readable. A real generated DB password clears that bar trivially.\n label: 'connection-string',\n description: 'Password inside a database connection URL',\n regex: /(?<=\\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\\+srv)?|rediss?|amqps?):\\/\\/[^\\s:@/]+:)(?=[^\\s@/]*\\d|[^\\s@/]{12})[^\\s@/]+(?=@)/gi,\n },\n {\n // randomBytes(32).hex → wh_ + 64 lowercase hex (67 chars total).\n // NOTE: unlike cj_ and hd_live_ above, this one has no trailing lookahead, so\n // wh_ + 65 hex matches its first 64. Not a leak (the value is still redacted)\n // and not changed here — flagged rather than silently altered in a card about\n // something else.\n label: 'cms-access-token',\n description: 'webhouse.app CMS access token (wh_ + 64 hex)',\n regex: /\\bwh_[0-9a-f]{64}/g,\n },\n {\n // Cloudflare API token (R2 / DNS management) — 40 base64url chars, NO prefix.\n // A bare {40} would false-positive broadly, so this is CONTEXT-ONLY: it only\n // fires next to a cf/cloudflare-api-token-named field. Runs before\n // labeled-hex-secret so a hex-valued CF token is attributed correctly.\n label: 'cloudflare-api-token',\n description: 'Cloudflare API token (cf/cloudflare-api-token field + 40 base64url)',\n regex: /\\b(?:cf|cloudflare)_?api_?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{40}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Mistral API key — prefix-less ~32 base62 (Christian-confirmed sample). A bare\n // [A-Za-z0-9]{32} would FP on every ID/hash, so CONTEXT-ONLY: anchored on a\n // mistral-(api-)key/token-named field. Runs before labeled-hex for attribution.\n label: 'mistral-api-key',\n description: 'Mistral API key (mistral-(api-)key/token field + 24+ base62)',\n regex: /\\bmistral(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{24,}(?![A-Za-z0-9])/gi,\n },\n {\n // DeepSeek — field-anchored fallback for any DEEPSEEK_API_KEY/TOKEN value that\n // doesn't fit the canonical sk-+hex shape (mirrors the Mistral context-only\n // approach). The field name is the signal → near-zero false positives. The\n // sk-+hex format pattern above already attributes the canonical shape; this\n // backstops a format change or an opaque token.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (deepseek-(api-)key/token field + 20+ token)',\n regex: /\\bdeepseek(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Vimeo personal access token — ~32 lowercase hex, no prefix (sanne). A bare\n // hex32 would FP massively (MD5/UUID), so CONTEXT-ONLY: anchored on a\n // vimeo-(access-)token-named field.\n label: 'vimeo-access-token',\n description: 'Vimeo access token (vimeo-(access-)token field + 20+ base62)',\n regex: /\\bvimeo(?:[_-]?access)?[_-]?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{20,}(?![A-Za-z0-9])/gi,\n },\n {\n // Context-based catch for prefix-less high-entropy service secrets\n // (CMS_JWT_SECRET, revalidateSecret, fleet openssl-rand-hex secrets): a 40+\n // hex value assigned to a field whose name contains\n // secret/token/password/api-key. The name requirement keeps the\n // false-positive rate near zero (a bare 40/64-hex would hit shas/hashes).\n label: 'labeled-hex-secret',\n description: 'A 40+ hex value assigned to a secret/token/password/api-key-named field',\n regex: /\\b[A-Za-z0-9_-]*(?:secret|token|password|api[_-]?key)\\b\\s*[:=]\\s*[\"'`]?[0-9a-f]{40,}/gi,\n },\n {\n // Discord bot token — three base64url segments. Anchored both sides so it\n // can't partial-match a longer dotted string.\n label: 'discord-bot-token',\n description: 'Discord bot token (3 base64url segments)',\n regex: /(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{24,26}\\.[A-Za-z0-9_-]{6}\\.[A-Za-z0-9_-]{27,40}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'discord-mfa-token',\n description: 'Discord MFA token (mfa. + 84 chars)',\n regex: /\\bmfa\\.[A-Za-z0-9_-]{84}\\b/g,\n },\n {\n // Cloudflare Turnstile PROD secret (sanne, verified 2/2) — 0x4 + 6×A prefix,\n // then 26 base64url (35 total). The 24-char SITE key + 1x/2x/3x TEST keys are\n // intentionally NOT matched (the {26} length gate misses them) so a public\n // key is never redacted.\n label: 'cloudflare-turnstile-secret',\n description: 'Cloudflare Turnstile secret key (0x4AAAAAA + 26 base64url, 35 total)',\n regex: /0x4AAAAAA[A-Za-z0-9_-]{26}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'cloudflare-global-key',\n description: 'Cloudflare global API key (37-hex)',\n regex: /\\b[0-9a-f]{37}\\b/g,\n },\n {\n // LAST on purpose: this is the only unprefixed shape in the list, so every\n // anchored pattern above must get first refusal.\n //\n // Philips Hue v2 application key — 40 chars of [A-Za-z0-9-] with NO prefix,\n // so there is nothing to anchor on. The negative lookahead is load-bearing,\n // not decoration: a bare [A-Za-z0-9-]{40} also matches a GIT COMMIT SHA, and\n // telemetry/error output is full of those. A redactor that eats commit\n // hashes gets switched off within a week, after which it protects nothing.\n //\n // F035.10 — THAT SENTENCE CAME TRUE ABOUT THIS VERY PATTERN. It was the only\n // prefix-less pattern here matching on ENTROPY ALONE, and base64 is\n // mixed-case alphanumeric, so it fired inside npm integrity digests:\n //\n // resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/…}\n // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ matched\n //\n // 33 hits in components' own pnpm-lock.yaml, 32 of them digests. (`-` is in\n // the class but is not a word character, so \\b anchors happily mid-digest.)\n // Reported by trail, whose gate then could not commit a lockfile change —\n // i.e. no dependency update at all. A gate nobody can satisfy is a gate\n // someone switches off, which costs more than the hole it closed.\n //\n // It is now CONTEXT-ONLY like every other prefix-less secret in this file\n // (cloudflare-api-token, mistral-api-key, vimeo-access-token,\n // labeled-hex-secret): the field name is the signal, not the randomness.\n // Deliberately given up: a bare 40-char Hue key in prose with no field name.\n // See the README's \"Deliberately NOT detected\" — do not remove the anchor to\n // \"fix\" that; a pattern that cannot tell a key from a checksum is worse than\n // no pattern. (Original pattern contributed by beacon, F035.7.)\n label: 'hue-application-key',\n description: 'Philips Hue application key (hue/bridge-named field + 40 chars)',\n // The `[\"'`]?` BEFORE the separator is not decoration: a Hue key most often\n // arrives as JSON — {\"hue_application_key\": \"…\"} — and the four older\n // context-only patterns in this file all omit it, so they miss the quoted\n // form. Noted rather than silently changed there; that is its own card.\n regex: /\\b(?:hue|bridge)[_-]?(?:application[_-]?key|username|user|key)\\b[\"'`]?\\s*[:=]\\s*[\"'`]?[A-Za-z0-9-]{40}(?![A-Za-z0-9-])/gi,\n },\n];\n\n/**\n * Shapes that identify a secret by its VALUE ALONE, with no field name.\n *\n * These are deliberately NOT in `SECRET_PATTERNS`, because a scanner runs over\n * arbitrary text where an unanchored entropy match is a disaster: the Hue shape\n * (40 mixed-case alphanumerics) is also what a 40-character window inside an npm\n * `sha512-…` digest looks like, which is how 0.5.0 blocked every lockfile commit\n * in every repo running the gate (F035.10).\n *\n * `classify()` is a different question, and that is the whole reason this list\n * exists. Its caller has ALREADY asserted the string is a secret — they pasted\n * it into a vault field and asked \"what kind?\" — so there is no checksum to\n * confuse it with and no text to corrupt. Answering \"unknown\" there costs a\n * consumer a working feature (cardmem's Secrets Vault type-detection) for a\n * false-positive risk that only exists when scanning.\n *\n * Same value, two questions: \"is there a secret in this text?\" and \"what kind of\n * secret is this?\" They do not deserve the same evidence bar.\n */\n// ONE source for the value-only rule, two anchorings derived from it. Written\n// twice by hand, the two forms drift the first time anyone tunes one of them.\n//\n// The lookaheads are the discriminator: 40 chars of [A-Za-z0-9-] that contain\n// BOTH a lower- and an upper-case letter. A git SHA (40 lowercase hex) therefore\n// never matches, which is the collision that would otherwise dominate.\nconst HUE_KEY_BODY = String.raw`(?=[A-Za-z0-9-]*[a-z])(?=[A-Za-z0-9-]*[A-Z])[A-Za-z0-9-]{40}`;\n\nconst VALUE_ONLY: ReadonlyArray<SecretPattern> = [\n {\n label: 'hue-application-key',\n description: 'Philips Hue application key (40 chars, no prefix)',\n // Anchored: `classify` is handed ONE value and asks what it is.\n regex: new RegExp(String.raw`^(?=[A-Za-z0-9-]{40}$)${HUE_KEY_BODY}$`),\n },\n];\n\n// Unanchored: `redactSecrets({ valueOnly: true })` runs over free text, where the\n// key sits inside a sentence. Same body, word-bounded.\n//\n// THIS IS THE ONE THAT EATS PROSE, which is why it is opt-in. Measured over two\n// trees with the identical pattern (F035.12):\n//\n// lockfiles everything else\n// components 1 file, 33 hits 15 files, 35 hits class names, hyphenated prose\n// beacon 8 hits 0 prose 12 deliberate fixtures\n//\n// The charset includes the HYPHEN, so a 40-character run of kebab-case slug or\n// hyphenated English matches — `gate-the-submit-button-on-status-not-on-`,\n// `WebStandardStreamableHTTPServerTransport`. No file-level exemption reaches\n// that; it is prose, not lockfiles.\n//\n// So the right default depends on the CALL SITE, not on the quality of the\n// pattern. beacon redacts logs: a false positive costs a masked word, a false\n// negative costs their bridge key. Our commit gate blocks commits: a false\n// positive costs a developer a blocked README. Same pattern, opposite cost —\n// which is what makes it a parameter rather than a fix.\nconst VALUE_ONLY_UNANCHORED: ReadonlyArray<SecretPattern> = [\n {\n label: 'hue-application-key',\n description: 'Philips Hue application key (40 chars, no prefix)',\n regex: new RegExp(String.raw`\\b${HUE_KEY_BODY}\\b`, 'g'),\n },\n];\n\n/**\n * Every pattern this package matches, for callers that want to inspect or audit\n * the roster.\n *\n * THE EXPORTED REGEXES ARE NOT GLOBAL, and that is a deliberate difference from\n * the ones used internally (F035.12). A `/g` regex carries `lastIndex` BETWEEN\n * CALLS, so the obvious way to inspect one lies. Measured on published 0.6.0:\n *\n * p.regex.test(sample) -> true lastIndex now 20\n * p.regex.test(sample) -> false <- same input, different answer\n *\n * Anyone measuring our own patterns — which is exactly what a consumer auditing\n * a redaction does — got alternating answers and no indication why. The copies\n * below are stateless, so testing them is idempotent.\n *\n * VALUE_ONLY_PATTERNS is exported for the same reason it exists: `classify` can\n * return a label that is in NEITHER list if only one of them is published, and a\n * roster that under-describes what the package detects is worse than no roster.\n */\n/** A global copy, for the replace pass. A caller's `extraPatterns` regex may\n * arrive without `/g`, in which case `String.replace` would substitute only the\n * FIRST occurrence and leave the rest in the text. */\nconst asGlobal = (re: RegExp): RegExp =>\n re.flags.includes('g') ? re : new RegExp(re.source, `${re.flags}g`);\n\nconst withoutGlobal = (list: ReadonlyArray<SecretPattern>): ReadonlyArray<SecretPattern> =>\n Object.freeze(\n list.map((p) =>\n Object.freeze({ ...p, regex: new RegExp(p.regex.source, p.regex.flags.replace('g', '')) }),\n ),\n );\n\n/** Every format pattern, ordered most-specific → least, as STATELESS copies —\n * safe to `.test()` repeatedly. See `withoutGlobal` above for what shared\n * `lastIndex` did to anyone auditing our own patterns before 0.7.0. */\nexport const SECRET_PATTERNS: ReadonlyArray<SecretPattern> = withoutGlobal(PATTERNS);\n\n/** The value-only axis — shapes identified from the VALUE ALONE, with no field\n * name beside them. Opt-in at the call site (`{ valueOnly: true }`); see the\n * option's own documentation for why the default is off. */\nexport const VALUE_ONLY_PATTERNS: ReadonlyArray<SecretPattern> = withoutGlobal(VALUE_ONLY);\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 * Also apply the VALUE-ONLY axis — shapes identified from the value alone,\n * with no field name beside them (today: the Philips Hue application key).\n *\n * OFF BY DEFAULT, and the reason is not that the pattern is bad (F035.12).\n *\n * cardmem's rule, which settled the design: **the decision to accept a weak\n * signal belongs to whoever can RENDER the uncertainty. A surface that cannot\n * show \"guess\" must not be given guesses.** Their vault shows a credential's\n * type as a chip beside the name, with nowhere to say \"low confidence\", so a\n * guess they accepted would silently become an assertion the owner acts on.\n * They take the empty answer instead.\n *\n * beacon's calculus is the opposite and equally correct: they redact logs, so\n * a false positive costs a masked word and a false negative costs their bridge\n * key. Their two call paths — masking each string separately, and passing a\n * bridge error message as free text — structurally cannot supply a field name,\n * so the field-anchored rule can never fire for them.\n *\n * MEASURED, same pattern, two corpora, opposite answers:\n *\n * components 2 lockfiles (39 hits) + 9 other files (20 hits) — class names,\n * documentation, `WebStandardStreamableHTTPServerTransport`\n * beacon 8 lockfile hits, 0 prose, 12 deliberate fixtures\n *\n * So there is no single correct default, which is exactly what makes this a\n * parameter rather than a fix. An OPTION rather than a `confidence` field on\n * the result, deliberately: a field is ignorable by destructuring the label,\n * and a caller who did not ask for weak guesses must not be able to receive\n * one by accident. The parameter name is the warning, at the one place it\n * cannot be skipped.\n */\n valueOnly?: boolean;\n}\n\n/** Marker label for a secret detected by its announcing label rather than shape. */\nexport const ANNOUNCED_LABEL = 'announced-secret';\n\n/**\n * Label + separator + value. The label list is deliberately short and concrete;\n * this is not a general \"looks like config\" detector.\n *\n * WHY THIS IS OPT-IN, MEASURED RATHER THAN GUESSED. Over this repo on\n * 2026-08-14 — 548 tracked files, 544 readable as text, containing essentially\n * no real secrets — this exact regex matched **97 times**, and every one was\n * noise. Per label: `secret` 61, `api key` 33, `password` 4, and every Danish\n * label 0. So 94 of the 97 are the two words that are also ordinary IDENTIFIERS\n * in source code (`secret: config.secret`, `apiKey: Record<…>`).\n *\n * That is the real finding, and it is sharper than \"the pattern is noisy\": its\n * precision depends entirely on WHAT IS BEING SCANNED. In an inbound mail body\n * — buddy's actual case — `Adgangskode:` is a strong signal. In a TypeScript\n * file it is a variable name. **The package cannot know which corpus it is\n * looking at; only the caller can.** So the caller makes the decision, and the\n * default cannot be on. (This is the opposite of this repo's usual defaults-ON\n * stance — webpush F067.1, lens-engine F065 — and the numbers above are why.)\n *\n * A broader label+separator+value pattern measured 305 on the same corpus, and\n * refining it only reached 202 — no amount of tuning makes a generic version\n * safe. A template/env-reference guard (`${FOO}`, `<your-key>`) was written and\n * then dropped: it changed the count by exactly 0, because the noise here is\n * identifiers, not templates.\n *\n * The value must not already be a redaction marker, so this can run AFTER the\n * format pass without flattening its more specific attribution.\n *\n * NOT IN THE LIST, AND DELIBERATELY (v0.4.0): bare `kode`. **In Danish, `kode`\n * mostly means SOURCE CODE** — the credential words are `kodeord` and\n * `adgangskode`, both still matched. Until 0.4.0 the bare form was included and\n * fired on ordinary technical prose; measured by buddy over 82,662 lines of real\n * Danish transcription: \"Det er min kode: se linje 40\" · \"Kode: const x = 1\" ·\n * \"Merge-kode: konflikten er løst\" · \"QR-kode: scan den\".\n *\n * And the behaviour was ARBITRARY, which is the part that settled it: `\\b` meant\n * `Landekode:` / `Postkode:` / `Fejlkode:` never matched (no word boundary inside\n * the word) while `QR-kode:` did (a hyphen IS one). Whether a compound was\n * flagged came down to whether someone happened to type a hyphen.\n *\n * THE COST, stated rather than hidden: `Her er min kode: hunter2` is no longer\n * detected, and that is a real Danish way to announce a password. Deliberate — a\n * token that means \"source code\" half the time is noise in every corpus, not\n * just buddy's. If you need it back, file it; do not re-add it locally.\n */\nconst ANNOUNCED_SECRET =\n /(\\b(?:adgangskode|kodeord|hemmelighed|password|passwd|api[ -]?key|apinøgle|secret|pwd)[\"'`\\]]?\\s*[:=]\\s*)(\\S+)/gi;\n\n/**\n * Delimiters that may WRAP a value without being part of it.\n *\n * D1 (F035.12) — `(\\S+)` swallowed these INTO the replaced span, so redacting\n * DELETED them. Measured on published 0.6.0:\n *\n * config(password='hunter2') -> config(password=[REDACTED:announced-secret]\n * Kodeord: hunter2, og derefter -> Kodeord: [REDACTED:announced-secret] og derefter\n * brug `password: hunter2` -> brug `password: [REDACTED:announced-secret]\n *\n * A closing paren, a comma and a backtick, gone. Anyone re-redacting a corpus\n * gets syntactically broken text back — and buddy holds 41k texts to do exactly\n * that. It also inflated every length measurement taken on candidates.\n *\n * THE LIST IS DELIBERATELY NARROW, and what is ABSENT is the load-bearing part:\n * `!` `?` `.` are NOT here. `Sommer2026!` is a real measured password and its\n * final character must go INTO the redaction, not survive it. A trailing quote\n * or bracket is structure; a trailing bang is content. Guessing wrong in the\n * first direction corrupts a corpus; guessing wrong in the second leaks one\n * character of a real secret, so the list only grows on evidence.\n */\nconst LEADING_DELIMS = /^[([{\"'`]+/;\nconst TRAILING_DELIMS = /[)\\]},;\"'`]+$/;\n\n/**\n * Is this candidate plausibly a secret VALUE, or just the next word in a\n * sentence?\n *\n * F035.11 — WITHOUT THIS, THE AXIS EATS PROSE. The pattern above is\n * label + separator + `\\S+`, and in Danish and English «secret:» is ordinary\n * text. Measured on the published 0.5.1:\n *\n * 'Set som secret: gh secret set MYPAT'\n * -> 'Set som secret: [REDACTED:announced-secret] secret set MYPAT'\n * 'jeg siger det aldrig — secret: ALDRIG'\n * -> 'jeg siger det aldrig — secret: [REDACTED:announced-secret]'\n *\n * THE RULE IS DERIVED FROM buddy's NUMBERS, not chosen. Over 40,369 rows of real\n * fleet prose (23,801 intercom messages + 16,568 conversation turns) they found\n * 49 unique candidates after an announcing label. **35 of them were prose, and\n * every one of those 35 was under 16 characters with no digit** — \"kun\", \"jeg\",\n * \"gh\", \"aldrig\", \"ALDRIG\", \"»\". Zero were hex-like. And the axis caught ZERO\n * real secrets that the format rules had not already caught.\n *\n * So: a candidate with no digit, shorter than 16 characters, is prose.\n *\n * THE COST, STATED RATHER THAN HIDDEN, in the house style of the `kode` removal\n * above: `Adgangskode: correcthorse` is no longer detected. That is a real way to\n * write a real password. It is accepted deliberately — an over-broad redaction\n * destroys a corpus as effectively as a narrow one leaks it (buddy's framing),\n * and this axis is the one running over human prose. A value with any digit, or\n * any value of real key length, is unaffected: `hunter2` still goes.\n *\n * The judgement is on the CANDIDATE, never on the label. Narrowing the label list\n * would leave the same greedy `\\S+` behind every label that remained.\n */\nfunction plausibleSecretValue(candidate: string): boolean {\n return /\\d/.test(candidate) || candidate.length >= 16;\n}\n\n\n/** Replacement marker for a redacted secret. */\nexport const redactionMarker = (label: string): string => `[REDACTED:${label}]`;\n\n/** The opening of every marker. Derived from redactionMarker rather than typed\n * again, so the two cannot drift apart — a hand-written '[REDACTED:' here would\n * keep matching after someone changed the marker format. */\nconst MARKER_PREFIX = redactionMarker('').slice(0, -1);\n\nfunction patternsFor(opts?: RedactOptions): SecretPattern[] {\n return opts?.extraPatterns && opts.extraPatterns.length > 0\n ? [...PATTERNS, ...opts.extraPatterns]\n : 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 // VALUE-ONLY runs between format and announced: after the shapes that are safe\n // everywhere, before the label-driven axis, and only when the caller asked.\n if (opts?.valueOnly) {\n for (const p of VALUE_ONLY_UNANCHORED) {\n let count = 0;\n const next = redacted.replace(asGlobal(p.regex), (match: string) => {\n if (match.includes(MARKER_PREFIX)) return match;\n count++;\n return redactionMarker(p.label);\n });\n if (count > 0) {\n redacted = next;\n findings.push({ label: p.label, count, confidence: 'format' });\n }\n }\n }\n\n // Announced runs LAST, and only on request. Order is not cosmetic: the format\n // pass has already replaced everything it recognises, and this regex refuses a\n // value that is already a marker — so `API key: sk-ant-…` keeps its specific\n // `anthropic-api-key` attribution instead of being flattened to a generic one.\n // The announcing label itself is KEPT in the output; only the value goes, so\n // the redacted text still reads `Adgangskode: [REDACTED:announced-secret]` and\n // a human or model reading it can still tell what was removed.\n if (opts?.announced) {\n let count = 0;\n const redactedAnnounced = redacted.replace(\n ANNOUNCED_SECRET,\n (match: string, prefix: string, value: string) => {\n // ALREADY REDACTED -> leave it alone, so the format pass keeps its\n // specific attribution. The old guard was a `(?!\\[REDACTED:)` lookahead\n // in the regex, which only fired when the marker was the FIRST character\n // of the value — so a QUOTED key was flattened (measured on 0.6.0):\n //\n // API key: \"sk-ant-api03-…\" -> API key: [REDACTED:announced-secret]\n // findings: anthropic-api-key, announced-secret\n //\n // The redacted text stopped saying WHICH kind of key it had been. This\n // tests for the marker ANYWHERE in the value rather than listing the\n // delimiters that could precede it — a list would have missed brackets,\n // parentheses and whatever nobody thought of next.\n if (value.includes(MARKER_PREFIX)) return match;\n\n // Split the wrapping delimiters off before judging AND before replacing,\n // so they survive into the output (D1). The judgement is on the CORE:\n // `'hunter2'` and `hunter2` are the same candidate.\n const lead = LEADING_DELIMS.exec(value)?.[0] ?? '';\n const trail = TRAILING_DELIMS.exec(value.slice(lead.length))?.[0] ?? '';\n const core = value.slice(lead.length, value.length - trail.length);\n\n // An implausible candidate is left EXACTLY as it was — byte for byte,\n // including the label. `match` rather than a rebuild on purpose: the\n // pieces are equal today and stop being equal the moment anyone adds a\n // group. Returning what was actually matched cannot drift.\n if (!core || !plausibleSecretValue(core)) return match;\n count++;\n return prefix + lead + redactionMarker(ANNOUNCED_LABEL) + trail;\n },\n );\n if (count > 0) {\n redacted = redactedAnnounced;\n findings.push({ label: ANNOUNCED_LABEL, count, confidence: 'announced' });\n }\n }\n return { redacted, findings, scanned };\n}\n\n/**\n * True if `text` announces a credential by label — `Adgangskode: hunter2` —\n * without building a redaction. Cheap enough to run on every inbound message.\n *\n * This exists because for untrusted inbound text heading to a model, the right\n * response is often to REFUSE rather than redact: a false positive costs a\n * slightly worse classification, a false negative costs a leak. That use needs a\n * boolean, not a redactor. (buddy's reasoning, F035.8.)\n */\nexport function hasAnnouncedSecret(text: string): boolean {\n if (!text) return false;\n // ROUTED THROUGH THE SAME PREDICATE as redactSecrets on purpose. A bare\n // `.test()` here would answer \"yes\" for a string redactSecrets leaves\n // untouched, and the two would disagree about the same input — which is worse\n // than either answer, because a caller can only ever ask one of them.\n //\n // THE INVARIANT IS NARROWER THAN \"THEY AGREE\", and the narrower one is what is\n // true (F035.12). They answer different questions and their FINDINGS can\n // legitimately differ:\n //\n // hasAnnouncedSecret('password: AKIA…') -> true\n // redactSecrets(same).findings -> [aws-access-key-id]\n //\n // Not a bug: the format pass runs FIRST and recognised the value, so it holds\n // the better attribution and the announced pass correctly declines to flatten\n // it. An earlier comment here claimed the two simply agree; that claim was\n // broader than the code, which is the shape this repo keeps naming.\n //\n // What IS guaranteed, and what a caller can rely on:\n //\n // hasAnnouncedSecret(t) === true => redactSecrets(t, { announced: true })\n // changes the text\n //\n // i.e. the boolean never promises a redaction that does not happen. It says\n // nothing about WHICH label does the work. Asserted in the suite over both the\n // agreeing and the disagreeing cases, so the weaker claim cannot silently\n // become the stronger one again.\n ANNOUNCED_SECRET.lastIndex = 0;\n for (let m = ANNOUNCED_SECRET.exec(text); m !== null; m = ANNOUNCED_SECRET.exec(text)) {\n // Same delimiter-stripping as the redactor, for the same reason: the two\n // must agree about what the CANDIDATE is, or they disagree about the input.\n const value = m[2] ?? '';\n if (value.includes(MARKER_PREFIX)) continue;\n const lead = LEADING_DELIMS.exec(value)?.[0] ?? '';\n const trail = TRAILING_DELIMS.exec(value.slice(lead.length))?.[0] ?? '';\n const core = value.slice(lead.length, value.length - trail.length);\n if (core && plausibleSecretValue(core)) {\n ANNOUNCED_SECRET.lastIndex = 0;\n return true;\n }\n }\n return false;\n}\n\n/**\n * True if `text` contains at least one detectable secret. Honours\n * `opts.announced` — a caller who asks for the announced axis and is told\n * `false` must be able to believe it.\n */\nexport function hasSecret(text: string, opts?: RedactOptions): boolean {\n if (opts?.announced && hasAnnouncedSecret(text)) return true;\n return patternsFor(opts).some((p) => {\n p.regex.lastIndex = 0;\n return p.regex.test(text);\n });\n}\n\nexport interface ClassifyResult {\n /** the matching pattern's stable label (e.g. `openai-api-key`) */\n label: string;\n /** the matching pattern's human description (e.g. `OpenAI API key (sk-… / sk-proj-…)`) */\n description: string;\n}\n\n/**\n * Classify a SINGLE pasted token — the INVERSE of redaction. Returns the first\n * (most-specific) pattern the value matches, or `null`. Backs a \"paste a key →\n * detect its type\" UI (cardmem F214 Secrets Vault) so every consumer shares the\n * same classification, not just the same redaction.\n *\n * First-match-wins over the ordered `SECRET_PATTERNS`, so `sk-ant-…` classifies\n * as `anthropic-api-key`, never the generic `openai-api-key`. Field-anchored\n * context-only patterns (mistral / vimeo / cloudflare-api-token /\n * labeled-hex-secret / deepseek-fallback) only match when the pasted value\n * includes their `NAME=` context; a bare provider token classifies via its\n * prefix pattern, and a prefix-less bare token (e.g. a raw Mistral key) is\n * genuinely unidentifiable → `null`. `opts.extraPatterns` run AFTER the\n * canonical set (canonical attribution wins). Input is trimmed; empty /\n * whitespace-only → `null`.\n */\nexport function classify(value: string, opts?: RedactOptions): ClassifyResult | null {\n if (!value) return null;\n const v = value.trim();\n if (!v) return null;\n for (const p of patternsFor(opts)) {\n p.regex.lastIndex = 0;\n if (p.regex.test(v)) return { label: p.label, description: p.description };\n }\n // Only after every anchored pattern has declined, and only when the caller\n // OPTED IN: shapes named from the value alone. Anchored to the WHOLE string\n // (^…$), so this can never fire on a fragment of a longer value.\n //\n // The gate is new in 0.6.1. Before it, `classify` consulted this list\n // unconditionally, so a caller could receive `hue-application-key` for a\n // 40-character id it had never heard of — a guess arriving in the same shape\n // as a certainty, with nothing in the return value marking the difference.\n if (!opts?.valueOnly) return null;\n for (const p of VALUE_ONLY) {\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 /** The matcher. GLOBAL on the internal list, because the redaction pass\n * replaces every occurrence — and NON-GLOBAL on everything this module\n * exports (`SECRET_PATTERNS`, `VALUE_ONLY_PATTERNS`), because a shared `/g`\n * regex carries `lastIndex` between calls and answers differently each time.\n * Through 0.7.0 this comment claimed the opposite, and it is the tooltip a\n * consumer sees: following it, `while ((m = p.regex.exec(text)))` never\n * advances and spins forever. */\n regex: RegExp;\n}\n\n/** Ordered most-specific → least. Every regex carries the `g` flag. */\n// The INTERNAL list. Global (`/g`) because the redaction pass replaces every\n// occurrence. Never exported directly — see SECRET_PATTERNS below for why.\nconst 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 // ── AWS ─────────────────────────────────────────────────────────────────\n //\n // ORDER IS LOAD-BEARING HERE, and it is not the order you would write first.\n // redactSecrets() applies patterns in sequence to the text it has ALREADY\n // redacted, so the id pattern must run LAST: it replaces `AKIA…` with a\n // marker, and the paired rule below anchors on that id. Put the id first and\n // the pair rule silently stops firing — a guard that is present, tested in\n // isolation, and dead in place.\n {\n // THE HALF THAT MATTERS, and it shipped unmatched for months. An access key\n // id alone is useless to an attacker; the SECRET key is the credential. So\n // redacting only the id — and stamping [REDACTED:…] right beside the live\n // secret — is worse than redacting nothing, because the marker tells the\n // reader the text was cleaned. Reported by cardmem the day they took AWS on.\n //\n // A bare 40-char base64 value CANNOT be matched: it is the shape of every\n // git object hash and base64 body in every repo we own. So this is\n // CONTEXT-ONLY, like every other prefix-less secret in this file.\n //\n // `access` is REQUIRED in the field name on purpose. A bare `secret_key`\n // (Terraform's spelling) would drag in far too much; that case is caught by\n // the paired rule below instead, which is the argument for having both.\n //\n // AND THE VALUE CLASS IS \"ANYTHING THAT IS NOT A DELIMITER\", not an\n // alphabet. cardmem measured their Tigris secret's charset after 0.8.1 and\n // it contains `+` — which our class happened to include, but only because\n // we guessed base64 rather than base64url. Their warning is the one worth\n // acting on: that is ONE key. It tells us what CAN occur, never what always\n // occurs, and the next provider's alphabet is another guess we would make\n // the same way. Under a field literally named `aws_secret_access_key`, the\n // NAME is the evidence; the value's alphabet adds nothing and can only be\n // wrong. So the value runs to the first delimiter and no further.\n //\n // ONE EXCLUSION, and it is a false positive our own docs produced the\n // moment the class widened: a value that is a REFERENCE to a secret is not\n // a secret. `secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!` is code\n // showing how to READ the credential, and redacting it would put a\n // [REDACTED:…] marker in a README about wiring up storage. So a value that\n // is ENTIRELY a dotted identifier path, or starts with a shell/template\n // expansion, is skipped. It must be the WHOLE value — a real secret may\n // contain dots, and the exclusion must not fire on one that does.\n //\n // THE LENGTH IS A FLOOR, NOT AWS'S 40 — measured by cardmem in production\n // and it is the finding that matters most here. Their four AWS_*-named\n // variables on Fly are NOT AWS: they are Tigris (Fly's S3-compatible\n // store), with a 54-character `tid_` id and a 75-character secret. Every\n // S3-compatible service — Tigris, R2, MinIO, Backblaze — reuses AWS's\n // variable NAMES with its own key format.\n //\n // Pinning 40 put a shape assumption on top of a name anchor, so the field\n // said AWS_SECRET_ACCESS_KEY, the value did not look like AWS, and the\n // credential stayed in the clear with no marker anywhere near it. The field\n // name is the signal in every context-only pattern in this file; that is\n // the whole design, and requiring a second signal quietly undid it.\n label: 'aws-secret-access-key',\n description: 'AWS/S3-compatible secret access key ((aws-)secret-access-key field + 20+ non-delimiter chars)',\n regex: /\\b(?:aws[_-]?)?secret[_-]?access[_-]?key\\b[\"'`]?\\s*[:=]\\s*[\"'`]?(?!(?:\\$|\\{)|(?:[A-Za-z_$][A-Za-z0-9_$]*(?:\\.[A-Za-z_$][A-Za-z0-9_$]*)+!?)(?=[\\s\"'`,;]|$))[^\\s\"'`,;]{20,}/gi,\n },\n {\n // An STS session token is a live credential for as long as it lasts, and it\n // travels in the same dump as the pair above.\n label: 'aws-session-token',\n description: 'AWS session token ((aws-)session-token field + 100+ base64)',\n regex: /\\b(?:aws[_-]?)?session[_-]?token\\b[\"'`]?\\s*[:=]\\s*[\"'`]?(?!(?:\\$|\\{)|(?:[A-Za-z_$][A-Za-z0-9_$]*(?:\\.[A-Za-z_$][A-Za-z0-9_$]*)+!?)(?=[\\s\"'`,;]|$))[^\\s\"'`,;]{100,}/gi,\n },\n {\n // THE WINDOW IS MEASURED, not chosen. Gap between the end of the id and the\n // start of the secret, in the six formats these actually arrive in:\n //\n // console CSV row 1 terraform provider block 18\n // sts assume-role JSON 21 aws CLI credentials file 25\n // env export pair 30 docker-compose env 30\n //\n // 80 is the largest real case plus room for one intervening line, and both\n // sides of it are pinned by a fixture — a proximity threshold nothing can\n // move is a magic number wearing a measurement's clothes (F035.12).\n //\n // The false-positive cost is near zero BECAUSE the id must be present: a\n // 40-char base64 string is only redacted when an AWS access key id sits\n // within 80 characters of it. That also catches the pair when the field is\n // named something we never anticipated, which the rule above cannot.\n label: 'aws-secret-access-key-paired',\n description: 'A 40-char base64 value within 80 characters of an AWS access key id',\n // `=` is deliberately NOT in the leading lookbehind, though it IS in the\n // value class: base64 padding never STARTS a value, and excluding it there\n // blocked every `KEY=value` form — measured, `blob=<secret>` went\n // unredacted while the same pair in CSV and Terraform was caught.\n //\n // THE CHEAP LOOKBEHIND GOES FIRST (0.11.1). Both are zero-width at the same\n // position, so the order does not change what matches — only what it costs.\n // With the 100-char id search first, EVERY character of a long run paid it:\n // 50,000 × 'A' took 3.7 s in this one pattern and a 400 KB blob 30+ s in\n // redactSecrets. `(?<![A-Za-z0-9/+])` rejects every position inside a run\n // in one step, and the lookahead then demands a whole 40-char value AHEAD\n // before anything looks behind — so after a space or a colon (where the\n // first guard passes) it fails in a character or two. The expensive id\n // search only runs where a complete candidate already stands.\n regex: /(?<![A-Za-z0-9/+])(?=[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=]))(?<=(?:AKIA|ASIA)[0-9A-Z]{16}[\\s\\S]{0,80})[A-Za-z0-9/+=]{40}/g,\n },\n {\n // A SEPARATE LABEL FROM AKIA, and the reason is operational rather than\n // tidy: the two demand different responses. A leaked long-term key must be\n // rotated; a leaked STS key may already have expired on its own. A reader\n // seeing [REDACTED:…] in a log can only make that call if the marker says\n // which one it was. ASIA was unmatched entirely before 0.8.0, so an\n // assumed-role dump read as clean.\n label: 'aws-temporary-access-key-id',\n description: 'AWS temporary (STS) access key id (ASIA…)',\n regex: /\\bASIA[0-9A-Z]{16}\\b/g,\n },\n {\n label: 'aws-access-key-id',\n description: 'AWS long-term access key id (AKIA…)',\n regex: /\\bAKIA[0-9A-Z]{16}\\b/g,\n },\n {\n label: 'github-token',\n description: 'GitHub token (ghp_/gho_/ghs_/ghu_/ghr_…)',\n regex: /\\bgh[posru]_[A-Za-z0-9]{36,}\\b/g,\n },\n {\n // GitHub fine-grained PAT — distinct prefix `github_pat_` (not caught by the\n // classic gh[posru]_ above), then base62 + a `_` separator (~82 chars total).\n // The prefix is so distinctive that {50,} keeps false positives at zero.\n label: 'github-fine-grained-pat',\n description: 'GitHub fine-grained personal access token (github_pat_…)',\n regex: /\\bgithub_pat_[A-Za-z0-9_]{50,}/g,\n },\n {\n label: 'gitlab-token',\n description: 'GitLab personal access token (glpat-…)',\n regex: /\\bglpat-[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'slack-token',\n description: 'Slack token (xox[baprs]-…)',\n regex: /\\bxox[baprs]-[A-Za-z0-9-]{10,}/g,\n },\n {\n label: 'stripe-secret-key',\n description: 'Stripe live secret/restricted key (sk_live_/rk_live_…)',\n regex: /\\b[rs]k_live_[A-Za-z0-9]{20,}/g,\n },\n {\n // Filed by buddy, who found REAL ones sitting in plaintext in their own\n // transcription DB. Their scrub deliberately runs the format axis ONLY, so a\n // prefixed secret we do not match is a secret nobody catches — a precise\n // prefix is the only route that helps them. Zero false-positive risk: the\n // literal `whsec_` does not occur by accident.\n label: 'stripe-webhook-secret',\n description: 'Stripe webhook signing secret (whsec_…)',\n regex: /\\bwhsec_[A-Za-z0-9+/=_-]{20,}/g,\n },\n {\n // Resend (re_…). Lookahead requires a digit in the body so we don't redact\n // long snake_case identifiers like re_compute_the_thing.\n label: 'resend-api-key',\n description: 'Resend API key (re_ + token)',\n regex: /\\bre_(?=[A-Za-z0-9_]*\\d)[A-Za-z0-9_]{24,}\\b/g,\n },\n {\n label: 'supabase-access-token',\n description: 'Supabase personal/management access token (sbp_ + 40 hex)',\n regex: /\\bsbp_[0-9a-f]{40}/g,\n },\n {\n label: 'supabase-secret-key',\n description: 'Supabase secret API key (sb_secret_…)',\n regex: /\\bsb_secret_[A-Za-z0-9_-]{20,}/g,\n },\n {\n // Used by every @broberg/* publish — the highest-value leak from a .env / commit history.\n label: 'npm-token',\n description: 'npm publish/automation token (npm_ + 36 base62)',\n regex: /\\bnpm_[A-Za-z0-9]{36}\\b/g,\n },\n {\n label: 'fly-api-token',\n description: 'Fly.io API token (FlyV1 fm2_… / fo1_…)',\n regex: /(?:FlyV1 fm2_[A-Za-z0-9+/=_-]{20,}|\\bfo1_[A-Za-z0-9_-]{20,})/g,\n },\n {\n // Also covers Turso DB/platform auth tokens AND Supabase anon/service_role\n // keys — both are JWTs (eyJ…), so the single JWT pattern catches them.\n label: 'jwt',\n description: 'JSON Web Token (eyJ…) — incl. Turso + Supabase service_role tokens',\n regex: /\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}/g,\n },\n {\n // genApiKey = randomBytes(24).hex → uk_ + exactly 48 lowercase hex.\n label: 'upmetrics-key',\n description: 'Upmetrics project key (uk_ + 48 hex)',\n regex: /\\buk_[0-9a-f]{48}/g,\n },\n {\n label: 'cardmem-key',\n description: 'Cardmem personal/incident/project key (pa_/pi_/pk_ + 64 hex)',\n regex: /\\bp[aik]_[A-Za-z0-9]{20,}/g,\n },\n {\n // cardmem inbox-webhook key — piw_ isn't matched by p[aik]_ above (3rd char 'w' ≠ '_').\n label: 'cardmem-webhook-key',\n description: 'Cardmem inbox-webhook key (piw_ + 64 hex)',\n regex: /\\bpiw_[0-9a-f]{64}/g,\n },\n {\n // VERIFIED WITH THE OWNER, 2026-08-28: `trail_` + exactly 64 LOWERCASE HEX.\n // trail generated 2000 keys through @broberg/apikey's generateKey('trail')\n // and counted the alphabet: 0-9a-f only, length 64-64, no `-`, no `_`. Source\n // is `${prefix}_${randomBytes(bytes).toString(\"hex\")}`, bytes=32, with a hard\n // floor of 16 — so even a future caller asking for the minimum yields 32 hex\n // chars, still above {20,}.\n //\n // Recorded because the QUESTION is easy to re-ask and the ANSWER is not: this\n // is one of the few patterns here assuming alphanumerics only, and had trail\n // used base64url (the more common one-liner) the `-` and `_` would break the\n // run, {20,} would never be satisfied, and the WHOLE key would pass through\n // unredacted — not partially, entirely. Measured rather than assumed, because\n // two sampled keys cannot tell hex from base64url that has not hit a `-` yet.\n label: 'trail-key',\n description: 'Trail personal API key (trail_ + 64 hex; verified 2026-08-28)',\n regex: /\\btrail_[A-Za-z0-9]{20,}/g,\n },\n {\n // Cronjobs API key (cronjobs.webhouse.net) — cj_ + randomBytes(32).base64url =\n // exactly 43 base64url chars (46 total). Prefix + fixed length = very low FP.\n // The UI's truncated cj_<8 chars>… preview is shorter than {43} → not matched.\n // Negative lookahead (not \\b) because base64url's `-` breaks a trailing \\b.\n label: 'cronjobs-api-key',\n description: 'Cronjobs API key (cj_ + 43 base64url)',\n regex: /\\bcj_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g,\n },\n {\n // HelpDesk API key (helpdesk.broberg.ai) — minted through @broberg/apikey,\n // which is OURS: generateKey(prefix, 32) → `${prefix}_${randomBytes(32).hex}`,\n // so exactly 64 LOWERCASE hex. Verified in packages/apikey/src/core.ts rather\n // than taken from the report. There is no checksum and no internal structure\n // to anchor on; prefix + fixed length + hex is everything there is, and it is\n // enough — `hd_live_` followed by exactly 64 hex does not occur by accident.\n //\n // THE LENGTH IS EXACT ON PURPOSE, and this is the half that needs defending\n // in six months. HelpDesk shows a PREVIEW — `hd_live_f4b4cf`, prefix + 6 hex\n // — deliberately, in their UI and their logs, so a human can see WHICH key\n // was revoked. It is not a secret. Redacting it breaks a value designed to be\n // read, and then the preview stops doing its job.\n //\n // So the tempting loosening — \"let us catch the shortened ones too\" — is the\n // one thing this pattern must never accept. `{64}` excludes the preview, and\n // a NAMED test says so, because by then nobody will remember why.\n //\n // The trailing lookahead covers BOTH cases: 65 hex is not a key, and neither\n // is 64 lowercase followed by an uppercase hex digit.\n //\n // NO PUBLISHABLE VARIANT, measured not assumed: HelpDesk is headless and its\n // console is a client of the same API using a session token. `grep -c \"hd_\"`\n // in the deployed bundle returns 0, so no key ever reaches a browser and the\n // Stripe pk_live_ trap has no counterpart here. Re-check if that changes.\n label: 'helpdesk-api-key',\n description: 'HelpDesk API key (hd_live_ + 64 hex, minted by @broberg/apikey)',\n regex: /\\bhd_live_[0-9a-f]{64}(?![0-9a-fA-F])/g,\n },\n {\n // UpCloud API token — `ucat_` + a ULID: exactly 26 Crockford base32 chars\n // (no I, L, O, U). Measured in three independent places, not inferred from\n // the masked example that filed it: UpCloud's API docs (create response),\n // UpCloud's own Go client fixture, and Kingfisher's rule — all three are in\n // test/f035-16.test.ts. The token's `id` is a separate UUID and is not a\n // secret.\n //\n // NO EXAMPLE VALUE IN THIS COMMENT, on purpose: the bundle keeps comments,\n // so a literal token here ships in dist/ and the scanner flags ITSELF — the\n // pre-commit gate's test caught exactly that on the first 0.10.0 tag.\n //\n // Case-insensitive like Kingfisher: Crockford decodes either case, and a\n // lowercased token is still a live credential. The trailing lookahead keeps\n // 27 chars from matching its first 26 — and leaves UpCloud's own\n // `ucat_[REDACTED]` marker alone.\n label: 'upcloud-api-token',\n description: 'UpCloud API token (ucat_ + 26 Crockford base32)',\n regex: /\\bucat_[0-9A-HJKMNP-TV-Z]{26}(?![0-9A-Za-z])/gi,\n },\n // ── F035.17 — the vault survey of 30 Sep 2026. Every shape below was MEASURED\n // on values already stored in cardmem's vault (the script read them server-side\n // and printed only prefix, length and charset), then checked against a source:\n // Kingfisher's public rule set for vendor tokens, our own minters for fleet keys.\n // No example value appears in these comments: the bundle keeps comments, and a\n // literal here would make the scanner flag its own dist/ (see upcloud above).\n {\n // Cloudflare's prefixed user API token. 3 in the vault, all 48 after the\n // prefix; Kingfisher allows 41-64. Runs before the context-only\n // cloudflare-api-token below so a prefixed one is named by its prefix.\n label: 'cloudflare-user-api-token',\n description: 'Cloudflare user API token (cfut_ + 41-64 base64url)',\n regex: /\\bcfut_[A-Za-z0-9_-]{41,64}(?![A-Za-z0-9_-])/g,\n },\n {\n // Runpod: rpa_ + 40 uppercase/digit + a 6-char mixed-case checksum tail\n // (Kingfisher runpod.1). 2 in the vault, both 46.\n label: 'runpod-api-key',\n description: 'Runpod API key (rpa_ + 46)',\n regex: /\\brpa_[A-Z0-9]{40}[A-Za-z0-9]{6}(?![A-Za-z0-9])/g,\n },\n {\n // Hugging Face user (hf_) and org (api_org_) tokens: 34 letters/digits.\n label: 'huggingface-token',\n description: 'Hugging Face token (hf_ / api_org_ + 34)',\n regex: /\\b(?:hf|api_org)_[A-Za-z0-9]{34}(?![A-Za-z0-9])/g,\n },\n {\n // Tailscale: tskey-<kind>-<id>-<secret>. The body carries its own dash, so\n // the class includes it. Measured 50-51 after the kind; Kingfisher's {20,36}\n // would stop short of those, so the ceiling is ours.\n label: 'tailscale-key',\n description: 'Tailscale key (tskey-<kind>-…)',\n regex: /\\btskey-[a-z]{3,10}-[A-Za-z0-9_-]{20,64}(?![A-Za-z0-9_-])/g,\n },\n {\n // Tigris secret access key: tsec_ + exactly 70 (Kingfisher tigris.2).\n label: 'tigris-secret-key',\n description: 'Tigris secret access key (tsec_ + 70)',\n regex: /\\btsec_[A-Za-z0-9_+-]{70}(?![A-Za-z0-9_+-])/g,\n },\n {\n // Slack APP-level token. slack-token above only knows xox*, so an xapp-\n // token went through untouched. Same label on purpose: the vault already\n // stores it as slack-token, and a second name for one provider helps nobody.\n label: 'slack-token',\n description: 'Slack app-level token (xapp-…)',\n regex: /\\bxapp-\\d{1,3}-[A-Za-z0-9]{8,15}-\\d{8,15}-[A-Za-z0-9]{20,70}(?![A-Za-z0-9])/g,\n },\n {\n // Aiven service password — the credential UpCloud's managed PostgreSQL hands\n // out. AVNS_ + 19. One in the vault; [Likely] fixed length, and a password\n // that silently stops matching is caught by the survey re-run, not by luck.\n label: 'aiven-service-password',\n description: 'Aiven service password (AVNS_ + 19) — UpCloud managed databases',\n regex: /\\bAVNS_[A-Za-z0-9_-]{19}(?![A-Za-z0-9_-])/g,\n },\n {\n // BID app key. OURS: broberg-id mints `bidk_${randomBytes(32).base64url}`,\n // so exactly 43 base64url — a fact about our minter, not a guess.\n label: 'bid-app-key',\n description: 'Broberg ID app key (bidk_ + 43 base64url)',\n regex: /\\bbidk_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g,\n },\n {\n // Fleet hex keys measured in the vault. Hex length is fixed by construction\n // (randomBytes(n).hex), so the survey's lengths are the minter's lengths.\n label: 'beacon-token',\n description: 'Beacon token (bcn_ + 64 hex)',\n regex: /\\bbcn_[0-9a-f]{64}(?![0-9a-fA-F])/g,\n },\n {\n label: 'mailworker-admin-key',\n description: 'mailworker admin key (mw_ + 64 hex)',\n regex: /\\bmw_[0-9a-f]{64}(?![0-9a-fA-F])/g,\n },\n {\n label: 'upmetrics-remediation-token',\n description: 'Upmetrics remediation token (umrt_ + 48 hex)',\n regex: /\\bumrt_[0-9a-f]{48}(?![0-9a-fA-F])/g,\n },\n {\n // A database URL with a password in it. Matches ONLY the password (the\n // lookbehind pins scheme://user: before it, the lookahead pins @ after), so\n // a redacted URL still says which database it points at.\n //\n // The password must contain a digit or be 12+ characters — so the\n // `user:password@` and `user:pass@` placeholders every README carries stay\n // readable. A real generated DB password clears that bar trivially.\n label: 'connection-string',\n description: 'Password inside a database connection URL',\n regex: /(?<=\\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\\+srv)?|rediss?|amqps?):\\/\\/[^\\s:@/]+:)(?=[^\\s@/]*\\d|[^\\s@/]{12})[^\\s@/]+(?=@)/gi,\n },\n {\n // randomBytes(32).hex → wh_ + 64 lowercase hex (67 chars total).\n // NOTE: unlike cj_ and hd_live_ above, this one has no trailing lookahead, so\n // wh_ + 65 hex matches its first 64. Not a leak (the value is still redacted)\n // and not changed here — flagged rather than silently altered in a card about\n // something else.\n label: 'cms-access-token',\n description: 'webhouse.app CMS access token (wh_ + 64 hex)',\n regex: /\\bwh_[0-9a-f]{64}/g,\n },\n {\n // Cloudflare API token (R2 / DNS management) — 40 base64url chars, NO prefix.\n // A bare {40} would false-positive broadly, so this is CONTEXT-ONLY: it only\n // fires next to a cf/cloudflare-api-token-named field. Runs before\n // labeled-hex-secret so a hex-valued CF token is attributed correctly.\n label: 'cloudflare-api-token',\n description: 'Cloudflare API token (cf/cloudflare-api-token field + 40 base64url)',\n regex: /\\b(?:cf|cloudflare)_?api_?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{40}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Mistral API key — prefix-less ~32 base62 (Christian-confirmed sample). A bare\n // [A-Za-z0-9]{32} would FP on every ID/hash, so CONTEXT-ONLY: anchored on a\n // mistral-(api-)key/token-named field. Runs before labeled-hex for attribution.\n label: 'mistral-api-key',\n description: 'Mistral API key (mistral-(api-)key/token field + 24+ base62)',\n regex: /\\bmistral(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{24,}(?![A-Za-z0-9])/gi,\n },\n {\n // DeepSeek — field-anchored fallback for any DEEPSEEK_API_KEY/TOKEN value that\n // doesn't fit the canonical sk-+hex shape (mirrors the Mistral context-only\n // approach). The field name is the signal → near-zero false positives. The\n // sk-+hex format pattern above already attributes the canonical shape; this\n // backstops a format change or an opaque token.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (deepseek-(api-)key/token field + 20+ token)',\n regex: /\\bdeepseek(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Vimeo personal access token — ~32 lowercase hex, no prefix (sanne). A bare\n // hex32 would FP massively (MD5/UUID), so CONTEXT-ONLY: anchored on a\n // vimeo-(access-)token-named field.\n label: 'vimeo-access-token',\n description: 'Vimeo access token (vimeo-(access-)token field + 20+ base62)',\n regex: /\\bvimeo(?:[_-]?access)?[_-]?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{20,}(?![A-Za-z0-9])/gi,\n },\n {\n // Context-based catch for prefix-less high-entropy service secrets\n // (CMS_JWT_SECRET, revalidateSecret, fleet openssl-rand-hex secrets): a 40+\n // hex value assigned to a field whose name contains\n // secret/token/password/api-key. The name requirement keeps the\n // false-positive rate near zero (a bare 40/64-hex would hit shas/hashes).\n label: 'labeled-hex-secret',\n description: 'A 40+ hex value assigned to a secret/token/password/api-key-named field',\n regex: /\\b[A-Za-z0-9_-]*(?:secret|token|password|api[_-]?key)\\b\\s*[:=]\\s*[\"'`]?[0-9a-f]{40,}/gi,\n },\n {\n // Discord bot token — three base64url segments. Anchored both sides so it\n // can't partial-match a longer dotted string.\n label: 'discord-bot-token',\n description: 'Discord bot token (3 base64url segments)',\n regex: /(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{24,26}\\.[A-Za-z0-9_-]{6}\\.[A-Za-z0-9_-]{27,40}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'discord-mfa-token',\n description: 'Discord MFA token (mfa. + 84 chars)',\n regex: /\\bmfa\\.[A-Za-z0-9_-]{84}\\b/g,\n },\n {\n // Cloudflare Turnstile PROD secret (sanne, verified 2/2) — 0x4 + 6×A prefix,\n // then 26 base64url (35 total). The 24-char SITE key + 1x/2x/3x TEST keys are\n // intentionally NOT matched (the {26} length gate misses them) so a public\n // key is never redacted.\n label: 'cloudflare-turnstile-secret',\n description: 'Cloudflare Turnstile secret key (0x4AAAAAA + 26 base64url, 35 total)',\n regex: /0x4AAAAAA[A-Za-z0-9_-]{26}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'cloudflare-global-key',\n description: 'Cloudflare global API key (37-hex)',\n regex: /\\b[0-9a-f]{37}\\b/g,\n },\n {\n // LAST on purpose: this is the only unprefixed shape in the list, so every\n // anchored pattern above must get first refusal.\n //\n // Philips Hue v2 application key — 40 chars of [A-Za-z0-9-] with NO prefix,\n // so there is nothing to anchor on. The negative lookahead is load-bearing,\n // not decoration: a bare [A-Za-z0-9-]{40} also matches a GIT COMMIT SHA, and\n // telemetry/error output is full of those. A redactor that eats commit\n // hashes gets switched off within a week, after which it protects nothing.\n //\n // F035.10 — THAT SENTENCE CAME TRUE ABOUT THIS VERY PATTERN. It was the only\n // prefix-less pattern here matching on ENTROPY ALONE, and base64 is\n // mixed-case alphanumeric, so it fired inside npm integrity digests:\n //\n // resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/…}\n // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ matched\n //\n // 33 hits in components' own pnpm-lock.yaml, 32 of them digests. (`-` is in\n // the class but is not a word character, so \\b anchors happily mid-digest.)\n // Reported by trail, whose gate then could not commit a lockfile change —\n // i.e. no dependency update at all. A gate nobody can satisfy is a gate\n // someone switches off, which costs more than the hole it closed.\n //\n // It is now CONTEXT-ONLY like every other prefix-less secret in this file\n // (cloudflare-api-token, mistral-api-key, vimeo-access-token,\n // labeled-hex-secret): the field name is the signal, not the randomness.\n // Deliberately given up: a bare 40-char Hue key in prose with no field name.\n // See the README's \"Deliberately NOT detected\" — do not remove the anchor to\n // \"fix\" that; a pattern that cannot tell a key from a checksum is worse than\n // no pattern. (Original pattern contributed by beacon, F035.7.)\n label: 'hue-application-key',\n description: 'Philips Hue application key (hue/bridge-named field + 40 chars)',\n // The `[\"'`]?` BEFORE the separator is not decoration: a Hue key most often\n // arrives as JSON — {\"hue_application_key\": \"…\"} — and the four older\n // context-only patterns in this file all omit it, so they miss the quoted\n // form. Noted rather than silently changed there; that is its own card.\n regex: /\\b(?:hue|bridge)[_-]?(?:application[_-]?key|username|user|key)\\b[\"'`]?\\s*[:=]\\s*[\"'`]?[A-Za-z0-9-]{40}(?![A-Za-z0-9-])/gi,\n },\n];\n\n/**\n * Shapes that identify a secret by its VALUE ALONE, with no field name.\n *\n * These are deliberately NOT in `SECRET_PATTERNS`, because a scanner runs over\n * arbitrary text where an unanchored entropy match is a disaster: the Hue shape\n * (40 mixed-case alphanumerics) is also what a 40-character window inside an npm\n * `sha512-…` digest looks like, which is how 0.5.0 blocked every lockfile commit\n * in every repo running the gate (F035.10).\n *\n * `classify()` is a different question, and that is the whole reason this list\n * exists. Its caller has ALREADY asserted the string is a secret — they pasted\n * it into a vault field and asked \"what kind?\" — so there is no checksum to\n * confuse it with and no text to corrupt. Answering \"unknown\" there costs a\n * consumer a working feature (cardmem's Secrets Vault type-detection) for a\n * false-positive risk that only exists when scanning.\n *\n * Same value, two questions: \"is there a secret in this text?\" and \"what kind of\n * secret is this?\" They do not deserve the same evidence bar.\n */\n// ONE source for the value-only rule, two anchorings derived from it. Written\n// twice by hand, the two forms drift the first time anyone tunes one of them.\n//\n// The lookaheads are the discriminator: 40 chars of [A-Za-z0-9-] that contain\n// BOTH a lower- and an upper-case letter. A git SHA (40 lowercase hex) therefore\n// never matches, which is the collision that would otherwise dominate.\nconst HUE_KEY_BODY = String.raw`(?=[A-Za-z0-9-]*[a-z])(?=[A-Za-z0-9-]*[A-Z])[A-Za-z0-9-]{40}`;\n\nconst VALUE_ONLY: ReadonlyArray<SecretPattern> = [\n {\n label: 'hue-application-key',\n description: 'Philips Hue application key (40 chars, no prefix)',\n // Anchored: `classify` is handed ONE value and asks what it is.\n regex: new RegExp(String.raw`^(?=[A-Za-z0-9-]{40}$)${HUE_KEY_BODY}$`),\n },\n];\n\n// Unanchored: `redactSecrets({ valueOnly: true })` runs over free text, where the\n// key sits inside a sentence. Same body, word-bounded.\n//\n// THIS IS THE ONE THAT EATS PROSE, which is why it is opt-in. Measured over two\n// trees with the identical pattern (F035.12):\n//\n// lockfiles everything else\n// components 1 file, 33 hits 15 files, 35 hits class names, hyphenated prose\n// beacon 8 hits 0 prose 12 deliberate fixtures\n//\n// The charset includes the HYPHEN, so a 40-character run of kebab-case slug or\n// hyphenated English matches — `gate-the-submit-button-on-status-not-on-`,\n// `WebStandardStreamableHTTPServerTransport`. No file-level exemption reaches\n// that; it is prose, not lockfiles.\n//\n// So the right default depends on the CALL SITE, not on the quality of the\n// pattern. beacon redacts logs: a false positive costs a masked word, a false\n// negative costs their bridge key. Our commit gate blocks commits: a false\n// positive costs a developer a blocked README. Same pattern, opposite cost —\n// which is what makes it a parameter rather than a fix.\nconst VALUE_ONLY_UNANCHORED: ReadonlyArray<SecretPattern> = [\n {\n label: 'hue-application-key',\n description: 'Philips Hue application key (40 chars, no prefix)',\n regex: new RegExp(String.raw`\\b${HUE_KEY_BODY}\\b`, 'g'),\n },\n];\n\n/**\n * Every pattern this package matches, for callers that want to inspect or audit\n * the roster.\n *\n * THE EXPORTED REGEXES ARE NOT GLOBAL, and that is a deliberate difference from\n * the ones used internally (F035.12). A `/g` regex carries `lastIndex` BETWEEN\n * CALLS, so the obvious way to inspect one lies. Measured on published 0.6.0:\n *\n * p.regex.test(sample) -> true lastIndex now 20\n * p.regex.test(sample) -> false <- same input, different answer\n *\n * Anyone measuring our own patterns — which is exactly what a consumer auditing\n * a redaction does — got alternating answers and no indication why. The copies\n * below are stateless, so testing them is idempotent.\n *\n * VALUE_ONLY_PATTERNS is exported for the same reason it exists: `classify` can\n * return a label that is in NEITHER list if only one of them is published, and a\n * roster that under-describes what the package detects is worse than no roster.\n */\n/** A global copy, for the replace pass. A caller's `extraPatterns` regex may\n * arrive without `/g`, in which case `String.replace` would substitute only the\n * FIRST occurrence and leave the rest in the text. */\nconst asGlobal = (re: RegExp): RegExp =>\n re.flags.includes('g') ? re : new RegExp(re.source, `${re.flags}g`);\n\nconst withoutGlobal = (list: ReadonlyArray<SecretPattern>): ReadonlyArray<SecretPattern> =>\n Object.freeze(\n list.map((p) =>\n Object.freeze({ ...p, regex: new RegExp(p.regex.source, p.regex.flags.replace('g', '')) }),\n ),\n );\n\n/** Every format pattern, ordered most-specific → least, as STATELESS copies —\n * safe to `.test()` repeatedly. See `withoutGlobal` above for what shared\n * `lastIndex` did to anyone auditing our own patterns before 0.7.0. */\nexport const SECRET_PATTERNS: ReadonlyArray<SecretPattern> = withoutGlobal(PATTERNS);\n\n/** The value-only axis — shapes identified from the VALUE ALONE, with no field\n * name beside them. Opt-in at the call site (`{ valueOnly: true }`); see the\n * option's own documentation for why the default is off. */\nexport const VALUE_ONLY_PATTERNS: ReadonlyArray<SecretPattern> = withoutGlobal(VALUE_ONLY);\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 * `true` reads PROSE: `Adgangskode: hunter2`. `'code'` reads SOURCE CODE,\n * where the only hardcoded secret is a quoted literal assigned to a\n * credential-named identifier: `newPassword: 'abcdefgh'`. See\n * CODE_ANNOUNCED_SECRET for why the two cannot share one rule (F035.19).\n */\n announced?: boolean | 'code';\n\n /**\n * Also apply the VALUE-ONLY axis — shapes identified from the value alone,\n * with no field name beside them (today: the Philips Hue application key).\n *\n * OFF BY DEFAULT, and the reason is not that the pattern is bad (F035.12).\n *\n * cardmem's rule, which settled the design: **the decision to accept a weak\n * signal belongs to whoever can RENDER the uncertainty. A surface that cannot\n * show \"guess\" must not be given guesses.** Their vault shows a credential's\n * type as a chip beside the name, with nowhere to say \"low confidence\", so a\n * guess they accepted would silently become an assertion the owner acts on.\n * They take the empty answer instead.\n *\n * beacon's calculus is the opposite and equally correct: they redact logs, so\n * a false positive costs a masked word and a false negative costs their bridge\n * key. Their two call paths — masking each string separately, and passing a\n * bridge error message as free text — structurally cannot supply a field name,\n * so the field-anchored rule can never fire for them.\n *\n * MEASURED, same pattern, two corpora, opposite answers:\n *\n * components 2 lockfiles (39 hits) + 9 other files (20 hits) — class names,\n * documentation, `WebStandardStreamableHTTPServerTransport`\n * beacon 8 lockfile hits, 0 prose, 12 deliberate fixtures\n *\n * So there is no single correct default, which is exactly what makes this a\n * parameter rather than a fix. An OPTION rather than a `confidence` field on\n * the result, deliberately: a field is ignorable by destructuring the label,\n * and a caller who did not ask for weak guesses must not be able to receive\n * one by accident. The parameter name is the warning, at the one place it\n * cannot be skipped.\n */\n valueOnly?: boolean;\n}\n\n/** Marker label for a secret detected by its announcing label rather than shape. */\nexport const ANNOUNCED_LABEL = 'announced-secret';\n\n/**\n * Label + separator + value. The label list is deliberately short and concrete;\n * this is not a general \"looks like config\" detector.\n *\n * WHY THIS IS OPT-IN, MEASURED RATHER THAN GUESSED. Over this repo on\n * 2026-08-14 — 548 tracked files, 544 readable as text, containing essentially\n * no real secrets — this exact regex matched **97 times**, and every one was\n * noise. Per label: `secret` 61, `api key` 33, `password` 4, and every Danish\n * label 0. So 94 of the 97 are the two words that are also ordinary IDENTIFIERS\n * in source code (`secret: config.secret`, `apiKey: Record<…>`).\n *\n * That is the real finding, and it is sharper than \"the pattern is noisy\": its\n * precision depends entirely on WHAT IS BEING SCANNED. In an inbound mail body\n * — buddy's actual case — `Adgangskode:` is a strong signal. In a TypeScript\n * file it is a variable name. **The package cannot know which corpus it is\n * looking at; only the caller can.** So the caller makes the decision, and the\n * default cannot be on. (This is the opposite of this repo's usual defaults-ON\n * stance — webpush F067.1, lens-engine F065 — and the numbers above are why.)\n *\n * A broader label+separator+value pattern measured 305 on the same corpus, and\n * refining it only reached 202 — no amount of tuning makes a generic version\n * safe. A template/env-reference guard (`${FOO}`, `<your-key>`) was written and\n * then dropped: it changed the count by exactly 0, because the noise here is\n * identifiers, not templates.\n *\n * The value must not already be a redaction marker, so this can run AFTER the\n * format pass without flattening its more specific attribution.\n *\n * NOT IN THE LIST, AND DELIBERATELY (v0.4.0): bare `kode`. **In Danish, `kode`\n * mostly means SOURCE CODE** — the credential words are `kodeord` and\n * `adgangskode`, both still matched. Until 0.4.0 the bare form was included and\n * fired on ordinary technical prose; measured by buddy over 82,662 lines of real\n * Danish transcription: \"Det er min kode: se linje 40\" · \"Kode: const x = 1\" ·\n * \"Merge-kode: konflikten er løst\" · \"QR-kode: scan den\".\n *\n * And the behaviour was ARBITRARY, which is the part that settled it: `\\b` meant\n * `Landekode:` / `Postkode:` / `Fejlkode:` never matched (no word boundary inside\n * the word) while `QR-kode:` did (a hyphen IS one). Whether a compound was\n * flagged came down to whether someone happened to type a hyphen.\n *\n * THE COST, stated rather than hidden: `Her er min kode: hunter2` is no longer\n * detected, and that is a real Danish way to announce a password. Deliberate — a\n * token that means \"source code\" half the time is noise in every corpus, not\n * just buddy's. If you need it back, file it; do not re-add it locally.\n */\nconst ANNOUNCED_SECRET =\n /(\\b(?:adgangskode|kodeord|hemmelighed|password|passwd|api[ -]?key|apinøgle|secret|pwd)[\"'`\\]]?\\s*[:=]\\s*)(\\S+)/gi;\n\n/**\n * Delimiters that may WRAP a value without being part of it.\n *\n * D1 (F035.12) — `(\\S+)` swallowed these INTO the replaced span, so redacting\n * DELETED them. Measured on published 0.6.0:\n *\n * config(password='hunter2') -> config(password=[REDACTED:announced-secret]\n * Kodeord: hunter2, og derefter -> Kodeord: [REDACTED:announced-secret] og derefter\n * brug `password: hunter2` -> brug `password: [REDACTED:announced-secret]\n *\n * A closing paren, a comma and a backtick, gone. Anyone re-redacting a corpus\n * gets syntactically broken text back — and buddy holds 41k texts to do exactly\n * that. It also inflated every length measurement taken on candidates.\n *\n * THE LIST IS DELIBERATELY NARROW, and what is ABSENT is the load-bearing part:\n * `!` `?` `.` are NOT here. `Sommer2026!` is a real measured password and its\n * final character must go INTO the redaction, not survive it. A trailing quote\n * or bracket is structure; a trailing bang is content. Guessing wrong in the\n * first direction corrupts a corpus; guessing wrong in the second leaks one\n * character of a real secret, so the list only grows on evidence.\n */\nconst LEADING_DELIMS = /^[([{\"'`]+/;\nconst TRAILING_DELIMS = /[)\\]},;\"'`]+$/;\n\n/**\n * Is this candidate plausibly a secret VALUE, or just the next word in a\n * sentence?\n *\n * F035.11 — WITHOUT THIS, THE AXIS EATS PROSE. The pattern above is\n * label + separator + `\\S+`, and in Danish and English «secret:» is ordinary\n * text. Measured on the published 0.5.1:\n *\n * 'Set som secret: gh secret set MYPAT'\n * -> 'Set som secret: [REDACTED:announced-secret] secret set MYPAT'\n * 'jeg siger det aldrig — secret: ALDRIG'\n * -> 'jeg siger det aldrig — secret: [REDACTED:announced-secret]'\n *\n * THE RULE IS DERIVED FROM buddy's NUMBERS, not chosen. Over 40,369 rows of real\n * fleet prose (23,801 intercom messages + 16,568 conversation turns) they found\n * 49 unique candidates after an announcing label. **35 of them were prose, and\n * every one of those 35 was under 16 characters with no digit** — \"kun\", \"jeg\",\n * \"gh\", \"aldrig\", \"ALDRIG\", \"»\". Zero were hex-like. And the axis caught ZERO\n * real secrets that the format rules had not already caught.\n *\n * So: a candidate with no digit, shorter than 16 characters, is prose.\n *\n * THE COST, STATED RATHER THAN HIDDEN, in the house style of the `kode` removal\n * above: `Adgangskode: correcthorse` is no longer detected. That is a real way to\n * write a real password. It is accepted deliberately — an over-broad redaction\n * destroys a corpus as effectively as a narrow one leaks it (buddy's framing),\n * and this axis is the one running over human prose. A value with any digit, or\n * any value of real key length, is unaffected: `hunter2` still goes.\n *\n * The judgement is on the CANDIDATE, never on the label. Narrowing the label list\n * would leave the same greedy `\\S+` behind every label that remained.\n */\nfunction plausibleSecretValue(candidate: string): boolean {\n return /\\d/.test(candidate) || candidate.length >= 16;\n}\n\n/**\n * The announced axis for SOURCE CODE — `{ announced: 'code' }` (F035.19).\n *\n * Filed by pitch: GitGuardian flagged «Generic Password» on\n * `JSON.stringify({ currentPassword: 'a', newPassword: 'abcdefgh' })`, and the\n * prose rule above returned `findings: []` on that exact line — while flagging\n * `apiKey: nanoid(32)` and `apiKey: process.env.RESEND_API_KEY`. Both halves are\n * the prose rule being right about prose:\n *\n * · `\\bpassword` needs a word boundary, and `newPassword` has none;\n * · a digit-free value under 16 chars is a WORD in prose (buddy: 35 of 35);\n * · in prose, `\\S+` after the label is the value. In code it is an expression.\n *\n * So code gets its own rule, and QUOTED is the whole of it: an unquoted value\n * is a call, a variable or an env reference, never a hardcoded secret. The\n * label may be any identifier CONTAINING a credential word (`DB_PASSWORD`,\n * `clientSecret`), optionally quoted as an object key. `==`, `===` and `=>`\n * need no rule of their own: what follows the first `=` is not a quote.\n *\n * MEASURED 1/10 2026 over 2,848 tracked TS/JS files in 13 fleet repos: 315\n * hits, 280 in test/spec/fixture files — `apiKey: \"re_x\"`, `password:\n * \"hunter2\"`, precisely GitGuardian's class, and correct for a pre-push gate.\n * The noise outside tests had five shapes, each refused by name in\n * codeCandidateOk or by the lookarounds here: a ternary branch\n * (`? \"wrong_password\" : \"enable_failed\"`), a type union (`type X = 'a' | 'b'`),\n * a descriptor-named label (`secretPath: \".lens/x\"`), an i18n label whose value\n * is the WORD (`password: \"Adgangskode\"`), an error code equal to its key\n * (`PASSWORD_TOO_SHORT: \"password_too_short\"`).\n *\n * NOT CAUGHT, deliberately: `.env` files (their literals are unquoted — use the\n * prose rule there), comparisons (`password === 'x'`), and values under 4\n * characters (`currentPassword: 'a'`, pitch's own threshold).\n */\nconst CREDENTIAL_WORD = '(?:password|passwd|pwd|secret|api_?key|adgangskode|kodeord)';\n//\n// LINEAR BY CONSTRUCTION, and the first draft was not: `[\\w$]*password[\\w$]*`\n// backtracks quadratically inside one long identifier — 'password' × 50,000\n// did not finish in two minutes. So the identifier is taken WHOLE, as an atomic\n// group (`(?=(x+))\\3` — JS has no possessive quantifier), and the credential\n// word is checked in codeCandidateOk. The lookbehinds are bounded for the same\n// reason: an unbounded `\\s*` inside a lookbehind rescans every whitespace run.\nconst CODE_ANNOUNCED_SECRET = new RegExp(\n '(?<![\\\\w$])(?<!\\\\?\\\\s{0,3}[\"\\'`]?)(?<!\\\\btype\\\\s{1,3})' +\n '(([\"\\'`]?)(?=([\\\\w$]+))\\\\3\\\\2\\\\s*[:=]\\\\s*)' +\n '([\"\\'`])([^\"\\'`\\\\s]*)\\\\4(?!\\\\s*[|&])',\n 'g',\n);\nconst CODE_DESCRIPTOR_SUFFIX =\n /^[_$-]*(?:path|name|id|file|url|uri|label|field|header|env|var|ref|type|hint|placeholder|policy|pattern|length|len|min|max|count|mode|provider|prompt|text|title|message|error|status)s?$/i;\nconst CREDENTIAL_WORD_ONLY = new RegExp('^' + CREDENTIAL_WORD + '$', 'i');\nconst CREDENTIAL_WORD_ANY = new RegExp(CREDENTIAL_WORD, 'gi');\nconst squash = (s: string): string => s.toLowerCase().replace(/[-_\\s]/g, '');\n\nfunction codeCandidateOk(label: string, value: string): boolean {\n if (value.length < 4 || value.includes('${') || value.includes(MARKER_PREFIX)) return false;\n // The LAST credential word decides what the identifier names: `secretPath`\n // is a path, `pathSecret` is a secret. matchAll, not a `(?!.*word)`\n // lookahead — that rescans the rest of the label from every position.\n let end = -1;\n for (const m of label.matchAll(CREDENTIAL_WORD_ANY)) end = m.index + m[0].length;\n if (end < 0) return false;\n if (CODE_DESCRIPTOR_SUFFIX.test(label.slice(end))) return false;\n if (CREDENTIAL_WORD_ONLY.test(value.replace(/[\\s_-]/g, ''))) return false;\n if (squash(value) === squash(label)) return false;\n return true;\n}\n\n\n/** Replacement marker for a redacted secret. */\nexport const redactionMarker = (label: string): string => `[REDACTED:${label}]`;\n\n/** The opening of every marker. Derived from redactionMarker rather than typed\n * again, so the two cannot drift apart — a hand-written '[REDACTED:' here would\n * keep matching after someone changed the marker format. */\nconst MARKER_PREFIX = redactionMarker('').slice(0, -1);\n\nfunction patternsFor(opts?: RedactOptions): SecretPattern[] {\n return opts?.extraPatterns && opts.extraPatterns.length > 0\n ? [...PATTERNS, ...opts.extraPatterns]\n : 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 // VALUE-ONLY runs between format and announced: after the shapes that are safe\n // everywhere, before the label-driven axis, and only when the caller asked.\n if (opts?.valueOnly) {\n for (const p of VALUE_ONLY_UNANCHORED) {\n let count = 0;\n const next = redacted.replace(asGlobal(p.regex), (match: string) => {\n if (match.includes(MARKER_PREFIX)) return match;\n count++;\n return redactionMarker(p.label);\n });\n if (count > 0) {\n redacted = next;\n findings.push({ label: p.label, count, confidence: 'format' });\n }\n }\n }\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 === 'code') {\n let count = 0;\n redacted = redacted.replace(\n CODE_ANNOUNCED_SECRET,\n (match: string, prefix: string, _q: string, label: string, quote: string, value: string) => {\n if (!codeCandidateOk(label, value)) return match;\n count++;\n return prefix + quote + redactionMarker(ANNOUNCED_LABEL) + quote;\n },\n );\n if (count > 0) findings.push({ label: ANNOUNCED_LABEL, count, confidence: 'announced' });\n } else if (opts?.announced) {\n let count = 0;\n const redactedAnnounced = redacted.replace(\n ANNOUNCED_SECRET,\n (match: string, prefix: string, value: string) => {\n // ALREADY REDACTED -> leave it alone, so the format pass keeps its\n // specific attribution. The old guard was a `(?!\\[REDACTED:)` lookahead\n // in the regex, which only fired when the marker was the FIRST character\n // of the value — so a QUOTED key was flattened (measured on 0.6.0):\n //\n // API key: \"sk-ant-api03-…\" -> API key: [REDACTED:announced-secret]\n // findings: anthropic-api-key, announced-secret\n //\n // The redacted text stopped saying WHICH kind of key it had been. This\n // tests for the marker ANYWHERE in the value rather than listing the\n // delimiters that could precede it — a list would have missed brackets,\n // parentheses and whatever nobody thought of next.\n if (value.includes(MARKER_PREFIX)) return match;\n\n // Split the wrapping delimiters off before judging AND before replacing,\n // so they survive into the output (D1). The judgement is on the CORE:\n // `'hunter2'` and `hunter2` are the same candidate.\n const lead = LEADING_DELIMS.exec(value)?.[0] ?? '';\n const trail = TRAILING_DELIMS.exec(value.slice(lead.length))?.[0] ?? '';\n const core = value.slice(lead.length, value.length - trail.length);\n\n // An implausible candidate is left EXACTLY as it was — byte for byte,\n // including the label. `match` rather than a rebuild on purpose: the\n // pieces are equal today and stop being equal the moment anyone adds a\n // group. Returning what was actually matched cannot drift.\n if (!core || !plausibleSecretValue(core)) return match;\n count++;\n return prefix + lead + redactionMarker(ANNOUNCED_LABEL) + trail;\n },\n );\n if (count > 0) {\n redacted = redactedAnnounced;\n findings.push({ label: ANNOUNCED_LABEL, count, confidence: 'announced' });\n }\n }\n return { redacted, findings, scanned };\n}\n\n/**\n * True if `text` announces a credential by label — `Adgangskode: hunter2` —\n * without building a redaction. Cheap enough to run on every inbound message.\n *\n * This exists because for untrusted inbound text heading to a model, the right\n * response is often to REFUSE rather than redact: a false positive costs a\n * slightly worse classification, a false negative costs a leak. That use needs a\n * boolean, not a redactor. (buddy's reasoning, F035.8.)\n */\nexport function hasAnnouncedSecret(text: string, mode: 'prose' | 'code' = 'prose'): boolean {\n if (!text) return false;\n if (mode === 'code') {\n // Same predicate as the redactor (F035.19), for the invariant below.\n for (const m of text.matchAll(CODE_ANNOUNCED_SECRET)) {\n if (codeCandidateOk(m[3] ?? '', m[5] ?? '')) return true;\n }\n return false;\n }\n // ROUTED THROUGH THE SAME PREDICATE as redactSecrets on purpose. A bare\n // `.test()` here would answer \"yes\" for a string redactSecrets leaves\n // untouched, and the two would disagree about the same input — which is worse\n // than either answer, because a caller can only ever ask one of them.\n //\n // THE INVARIANT IS NARROWER THAN \"THEY AGREE\", and the narrower one is what is\n // true (F035.12). They answer different questions and their FINDINGS can\n // legitimately differ:\n //\n // hasAnnouncedSecret('password: AKIA…') -> true\n // redactSecrets(same).findings -> [aws-access-key-id]\n //\n // Not a bug: the format pass runs FIRST and recognised the value, so it holds\n // the better attribution and the announced pass correctly declines to flatten\n // it. An earlier comment here claimed the two simply agree; that claim was\n // broader than the code, which is the shape this repo keeps naming.\n //\n // What IS guaranteed, and what a caller can rely on:\n //\n // hasAnnouncedSecret(t) === true => redactSecrets(t, { announced: true })\n // changes the text\n //\n // i.e. the boolean never promises a redaction that does not happen. It says\n // nothing about WHICH label does the work. Asserted in the suite over both the\n // agreeing and the disagreeing cases, so the weaker claim cannot silently\n // become the stronger one again.\n ANNOUNCED_SECRET.lastIndex = 0;\n for (let m = ANNOUNCED_SECRET.exec(text); m !== null; m = ANNOUNCED_SECRET.exec(text)) {\n // Same delimiter-stripping as the redactor, for the same reason: the two\n // must agree about what the CANDIDATE is, or they disagree about the input.\n const value = m[2] ?? '';\n if (value.includes(MARKER_PREFIX)) continue;\n const lead = LEADING_DELIMS.exec(value)?.[0] ?? '';\n const trail = TRAILING_DELIMS.exec(value.slice(lead.length))?.[0] ?? '';\n const core = value.slice(lead.length, value.length - trail.length);\n if (core && plausibleSecretValue(core)) {\n ANNOUNCED_SECRET.lastIndex = 0;\n return true;\n }\n }\n return false;\n}\n\n/**\n * True if `text` contains at least one detectable secret. Honours\n * `opts.announced` — a caller who asks for the announced axis and is told\n * `false` must be able to believe it.\n */\nexport function hasSecret(text: string, opts?: RedactOptions): boolean {\n if (opts?.announced && hasAnnouncedSecret(text, opts.announced === 'code' ? 'code' : 'prose')) {\n return true;\n }\n return patternsFor(opts).some((p) => {\n p.regex.lastIndex = 0;\n return p.regex.test(text);\n });\n}\n\nexport interface ClassifyResult {\n /** the matching pattern's stable label (e.g. `openai-api-key`) */\n label: string;\n /** the matching pattern's human description (e.g. `OpenAI API key (sk-… / sk-proj-…)`) */\n description: string;\n}\n\n/**\n * Classify a SINGLE pasted token — the INVERSE of redaction. Returns the first\n * (most-specific) pattern the value matches, or `null`. Backs a \"paste a key →\n * detect its type\" UI (cardmem F214 Secrets Vault) so every consumer shares the\n * same classification, not just the same redaction.\n *\n * First-match-wins over the ordered `SECRET_PATTERNS`, so `sk-ant-…` classifies\n * as `anthropic-api-key`, never the generic `openai-api-key`. Field-anchored\n * context-only patterns (mistral / vimeo / cloudflare-api-token /\n * labeled-hex-secret / deepseek-fallback) only match when the pasted value\n * includes their `NAME=` context; a bare provider token classifies via its\n * prefix pattern, and a prefix-less bare token (e.g. a raw Mistral key) is\n * genuinely unidentifiable → `null`. `opts.extraPatterns` run AFTER the\n * canonical set (canonical attribution wins). Input is trimmed; empty /\n * whitespace-only → `null`.\n */\nexport function classify(value: string, opts?: RedactOptions): ClassifyResult | null {\n if (!value) return null;\n const v = value.trim();\n if (!v) return null;\n for (const p of patternsFor(opts)) {\n p.regex.lastIndex = 0;\n if (p.regex.test(v)) return { label: p.label, description: p.description };\n }\n // Only after every anchored pattern has declined, and only when the caller\n // OPTED IN: shapes named from the value alone. Anchored to the WHOLE string\n // (^…$), so this can never fire on a fragment of a longer value.\n //\n // The gate is new in 0.6.1. Before it, `classify` consulted this list\n // unconditionally, so a caller could receive `hue-application-key` for a\n // 40-character id it had never heard of — a guess arriving in the same shape\n // as a certainty, with nothing in the return value marking the difference.\n if (!opts?.valueOnly) return null;\n for (const p of VALUE_ONLY) {\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
|
@@ -105,8 +105,13 @@ interface RedactOptions {
|
|
|
105
105
|
* Also detect ANNOUNCED secrets — `Adgangskode: hunter2` — where the label is
|
|
106
106
|
* the only evidence. **Off by default, and it must stay that way.** See
|
|
107
107
|
* ANNOUNCED_LABEL for the measurement that decided it.
|
|
108
|
+
*
|
|
109
|
+
* `true` reads PROSE: `Adgangskode: hunter2`. `'code'` reads SOURCE CODE,
|
|
110
|
+
* where the only hardcoded secret is a quoted literal assigned to a
|
|
111
|
+
* credential-named identifier: `newPassword: 'abcdefgh'`. See
|
|
112
|
+
* CODE_ANNOUNCED_SECRET for why the two cannot share one rule (F035.19).
|
|
108
113
|
*/
|
|
109
|
-
announced?: boolean;
|
|
114
|
+
announced?: boolean | 'code';
|
|
110
115
|
/**
|
|
111
116
|
* Also apply the VALUE-ONLY axis — shapes identified from the value alone,
|
|
112
117
|
* with no field name beside them (today: the Philips Hue application key).
|
|
@@ -174,7 +179,7 @@ declare function redactSecrets(text: string, opts?: RedactOptions): RedactionRes
|
|
|
174
179
|
* slightly worse classification, a false negative costs a leak. That use needs a
|
|
175
180
|
* boolean, not a redactor. (buddy's reasoning, F035.8.)
|
|
176
181
|
*/
|
|
177
|
-
declare function hasAnnouncedSecret(text: string): boolean;
|
|
182
|
+
declare function hasAnnouncedSecret(text: string, mode?: 'prose' | 'code'): boolean;
|
|
178
183
|
/**
|
|
179
184
|
* True if `text` contains at least one detectable secret. Honours
|
|
180
185
|
* `opts.announced` — a caller who asks for the announced axis and is told
|
package/dist/index.d.ts
CHANGED
|
@@ -105,8 +105,13 @@ interface RedactOptions {
|
|
|
105
105
|
* Also detect ANNOUNCED secrets — `Adgangskode: hunter2` — where the label is
|
|
106
106
|
* the only evidence. **Off by default, and it must stay that way.** See
|
|
107
107
|
* ANNOUNCED_LABEL for the measurement that decided it.
|
|
108
|
+
*
|
|
109
|
+
* `true` reads PROSE: `Adgangskode: hunter2`. `'code'` reads SOURCE CODE,
|
|
110
|
+
* where the only hardcoded secret is a quoted literal assigned to a
|
|
111
|
+
* credential-named identifier: `newPassword: 'abcdefgh'`. See
|
|
112
|
+
* CODE_ANNOUNCED_SECRET for why the two cannot share one rule (F035.19).
|
|
108
113
|
*/
|
|
109
|
-
announced?: boolean;
|
|
114
|
+
announced?: boolean | 'code';
|
|
110
115
|
/**
|
|
111
116
|
* Also apply the VALUE-ONLY axis — shapes identified from the value alone,
|
|
112
117
|
* with no field name beside them (today: the Philips Hue application key).
|
|
@@ -174,7 +179,7 @@ declare function redactSecrets(text: string, opts?: RedactOptions): RedactionRes
|
|
|
174
179
|
* slightly worse classification, a false negative costs a leak. That use needs a
|
|
175
180
|
* boolean, not a redactor. (buddy's reasoning, F035.8.)
|
|
176
181
|
*/
|
|
177
|
-
declare function hasAnnouncedSecret(text: string): boolean;
|
|
182
|
+
declare function hasAnnouncedSecret(text: string, mode?: 'prose' | 'code'): boolean;
|
|
178
183
|
/**
|
|
179
184
|
* True if `text` contains at least one detectable secret. Honours
|
|
180
185
|
* `opts.announced` — a caller who asks for the announced axis and is told
|
package/dist/index.js
CHANGED
|
@@ -151,7 +151,17 @@ var PATTERNS = [
|
|
|
151
151
|
// value class: base64 padding never STARTS a value, and excluding it there
|
|
152
152
|
// blocked every `KEY=value` form — measured, `blob=<secret>` went
|
|
153
153
|
// unredacted while the same pair in CSV and Terraform was caught.
|
|
154
|
-
|
|
154
|
+
//
|
|
155
|
+
// THE CHEAP LOOKBEHIND GOES FIRST (0.11.1). Both are zero-width at the same
|
|
156
|
+
// position, so the order does not change what matches — only what it costs.
|
|
157
|
+
// With the 100-char id search first, EVERY character of a long run paid it:
|
|
158
|
+
// 50,000 × 'A' took 3.7 s in this one pattern and a 400 KB blob 30+ s in
|
|
159
|
+
// redactSecrets. `(?<![A-Za-z0-9/+])` rejects every position inside a run
|
|
160
|
+
// in one step, and the lookahead then demands a whole 40-char value AHEAD
|
|
161
|
+
// before anything looks behind — so after a space or a colon (where the
|
|
162
|
+
// first guard passes) it fails in a character or two. The expensive id
|
|
163
|
+
// search only runs where a complete candidate already stands.
|
|
164
|
+
regex: /(?<![A-Za-z0-9/+])(?=[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=]))(?<=(?:AKIA|ASIA)[0-9A-Z]{16}[\s\S]{0,80})[A-Za-z0-9/+=]{40}/g
|
|
155
165
|
},
|
|
156
166
|
{
|
|
157
167
|
// A SEPARATE LABEL FROM AKIA, and the reason is operational rather than
|
|
@@ -579,6 +589,25 @@ var TRAILING_DELIMS = /[)\]},;"'`]+$/;
|
|
|
579
589
|
function plausibleSecretValue(candidate) {
|
|
580
590
|
return /\d/.test(candidate) || candidate.length >= 16;
|
|
581
591
|
}
|
|
592
|
+
var CREDENTIAL_WORD = "(?:password|passwd|pwd|secret|api_?key|adgangskode|kodeord)";
|
|
593
|
+
var CODE_ANNOUNCED_SECRET = new RegExp(
|
|
594
|
+
"(?<![\\w$])(?<!\\?\\s{0,3}[\"'`]?)(?<!\\btype\\s{1,3})(([\"'`]?)(?=([\\w$]+))\\3\\2\\s*[:=]\\s*)([\"'`])([^\"'`\\s]*)\\4(?!\\s*[|&])",
|
|
595
|
+
"g"
|
|
596
|
+
);
|
|
597
|
+
var CODE_DESCRIPTOR_SUFFIX = /^[_$-]*(?:path|name|id|file|url|uri|label|field|header|env|var|ref|type|hint|placeholder|policy|pattern|length|len|min|max|count|mode|provider|prompt|text|title|message|error|status)s?$/i;
|
|
598
|
+
var CREDENTIAL_WORD_ONLY = new RegExp("^" + CREDENTIAL_WORD + "$", "i");
|
|
599
|
+
var CREDENTIAL_WORD_ANY = new RegExp(CREDENTIAL_WORD, "gi");
|
|
600
|
+
var squash = (s) => s.toLowerCase().replace(/[-_\s]/g, "");
|
|
601
|
+
function codeCandidateOk(label, value) {
|
|
602
|
+
if (value.length < 4 || value.includes("${") || value.includes(MARKER_PREFIX)) return false;
|
|
603
|
+
let end = -1;
|
|
604
|
+
for (const m of label.matchAll(CREDENTIAL_WORD_ANY)) end = m.index + m[0].length;
|
|
605
|
+
if (end < 0) return false;
|
|
606
|
+
if (CODE_DESCRIPTOR_SUFFIX.test(label.slice(end))) return false;
|
|
607
|
+
if (CREDENTIAL_WORD_ONLY.test(value.replace(/[\s_-]/g, ""))) return false;
|
|
608
|
+
if (squash(value) === squash(label)) return false;
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
582
611
|
var redactionMarker = (label) => `[REDACTED:${label}]`;
|
|
583
612
|
var MARKER_PREFIX = redactionMarker("").slice(0, -1);
|
|
584
613
|
function patternsFor(opts) {
|
|
@@ -611,7 +640,18 @@ function redactSecrets(text, opts) {
|
|
|
611
640
|
}
|
|
612
641
|
}
|
|
613
642
|
}
|
|
614
|
-
if (opts?.announced) {
|
|
643
|
+
if (opts?.announced === "code") {
|
|
644
|
+
let count = 0;
|
|
645
|
+
redacted = redacted.replace(
|
|
646
|
+
CODE_ANNOUNCED_SECRET,
|
|
647
|
+
(match, prefix, _q, label, quote, value) => {
|
|
648
|
+
if (!codeCandidateOk(label, value)) return match;
|
|
649
|
+
count++;
|
|
650
|
+
return prefix + quote + redactionMarker(ANNOUNCED_LABEL) + quote;
|
|
651
|
+
}
|
|
652
|
+
);
|
|
653
|
+
if (count > 0) findings.push({ label: ANNOUNCED_LABEL, count, confidence: "announced" });
|
|
654
|
+
} else if (opts?.announced) {
|
|
615
655
|
let count = 0;
|
|
616
656
|
const redactedAnnounced = redacted.replace(
|
|
617
657
|
ANNOUNCED_SECRET,
|
|
@@ -632,8 +672,14 @@ function redactSecrets(text, opts) {
|
|
|
632
672
|
}
|
|
633
673
|
return { redacted, findings, scanned };
|
|
634
674
|
}
|
|
635
|
-
function hasAnnouncedSecret(text) {
|
|
675
|
+
function hasAnnouncedSecret(text, mode = "prose") {
|
|
636
676
|
if (!text) return false;
|
|
677
|
+
if (mode === "code") {
|
|
678
|
+
for (const m of text.matchAll(CODE_ANNOUNCED_SECRET)) {
|
|
679
|
+
if (codeCandidateOk(m[3] ?? "", m[5] ?? "")) return true;
|
|
680
|
+
}
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
637
683
|
ANNOUNCED_SECRET.lastIndex = 0;
|
|
638
684
|
for (let m = ANNOUNCED_SECRET.exec(text); m !== null; m = ANNOUNCED_SECRET.exec(text)) {
|
|
639
685
|
const value = m[2] ?? "";
|
|
@@ -649,7 +695,9 @@ function hasAnnouncedSecret(text) {
|
|
|
649
695
|
return false;
|
|
650
696
|
}
|
|
651
697
|
function hasSecret(text, opts) {
|
|
652
|
-
if (opts?.announced && hasAnnouncedSecret(text))
|
|
698
|
+
if (opts?.announced && hasAnnouncedSecret(text, opts.announced === "code" ? "code" : "prose")) {
|
|
699
|
+
return true;
|
|
700
|
+
}
|
|
653
701
|
return patternsFor(opts).some((p) => {
|
|
654
702
|
p.regex.lastIndex = 0;
|
|
655
703
|
return p.regex.test(text);
|
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 /** The matcher. GLOBAL on the internal list, because the redaction pass\n * replaces every occurrence — and NON-GLOBAL on everything this module\n * exports (`SECRET_PATTERNS`, `VALUE_ONLY_PATTERNS`), because a shared `/g`\n * regex carries `lastIndex` between calls and answers differently each time.\n * Through 0.7.0 this comment claimed the opposite, and it is the tooltip a\n * consumer sees: following it, `while ((m = p.regex.exec(text)))` never\n * advances and spins forever. */\n regex: RegExp;\n}\n\n/** Ordered most-specific → least. Every regex carries the `g` flag. */\n// The INTERNAL list. Global (`/g`) because the redaction pass replaces every\n// occurrence. Never exported directly — see SECRET_PATTERNS below for why.\nconst 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 // ── AWS ─────────────────────────────────────────────────────────────────\n //\n // ORDER IS LOAD-BEARING HERE, and it is not the order you would write first.\n // redactSecrets() applies patterns in sequence to the text it has ALREADY\n // redacted, so the id pattern must run LAST: it replaces `AKIA…` with a\n // marker, and the paired rule below anchors on that id. Put the id first and\n // the pair rule silently stops firing — a guard that is present, tested in\n // isolation, and dead in place.\n {\n // THE HALF THAT MATTERS, and it shipped unmatched for months. An access key\n // id alone is useless to an attacker; the SECRET key is the credential. So\n // redacting only the id — and stamping [REDACTED:…] right beside the live\n // secret — is worse than redacting nothing, because the marker tells the\n // reader the text was cleaned. Reported by cardmem the day they took AWS on.\n //\n // A bare 40-char base64 value CANNOT be matched: it is the shape of every\n // git object hash and base64 body in every repo we own. So this is\n // CONTEXT-ONLY, like every other prefix-less secret in this file.\n //\n // `access` is REQUIRED in the field name on purpose. A bare `secret_key`\n // (Terraform's spelling) would drag in far too much; that case is caught by\n // the paired rule below instead, which is the argument for having both.\n //\n // AND THE VALUE CLASS IS \"ANYTHING THAT IS NOT A DELIMITER\", not an\n // alphabet. cardmem measured their Tigris secret's charset after 0.8.1 and\n // it contains `+` — which our class happened to include, but only because\n // we guessed base64 rather than base64url. Their warning is the one worth\n // acting on: that is ONE key. It tells us what CAN occur, never what always\n // occurs, and the next provider's alphabet is another guess we would make\n // the same way. Under a field literally named `aws_secret_access_key`, the\n // NAME is the evidence; the value's alphabet adds nothing and can only be\n // wrong. So the value runs to the first delimiter and no further.\n //\n // ONE EXCLUSION, and it is a false positive our own docs produced the\n // moment the class widened: a value that is a REFERENCE to a secret is not\n // a secret. `secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!` is code\n // showing how to READ the credential, and redacting it would put a\n // [REDACTED:…] marker in a README about wiring up storage. So a value that\n // is ENTIRELY a dotted identifier path, or starts with a shell/template\n // expansion, is skipped. It must be the WHOLE value — a real secret may\n // contain dots, and the exclusion must not fire on one that does.\n //\n // THE LENGTH IS A FLOOR, NOT AWS'S 40 — measured by cardmem in production\n // and it is the finding that matters most here. Their four AWS_*-named\n // variables on Fly are NOT AWS: they are Tigris (Fly's S3-compatible\n // store), with a 54-character `tid_` id and a 75-character secret. Every\n // S3-compatible service — Tigris, R2, MinIO, Backblaze — reuses AWS's\n // variable NAMES with its own key format.\n //\n // Pinning 40 put a shape assumption on top of a name anchor, so the field\n // said AWS_SECRET_ACCESS_KEY, the value did not look like AWS, and the\n // credential stayed in the clear with no marker anywhere near it. The field\n // name is the signal in every context-only pattern in this file; that is\n // the whole design, and requiring a second signal quietly undid it.\n label: 'aws-secret-access-key',\n description: 'AWS/S3-compatible secret access key ((aws-)secret-access-key field + 20+ non-delimiter chars)',\n regex: /\\b(?:aws[_-]?)?secret[_-]?access[_-]?key\\b[\"'`]?\\s*[:=]\\s*[\"'`]?(?!(?:\\$|\\{)|(?:[A-Za-z_$][A-Za-z0-9_$]*(?:\\.[A-Za-z_$][A-Za-z0-9_$]*)+!?)(?=[\\s\"'`,;]|$))[^\\s\"'`,;]{20,}/gi,\n },\n {\n // An STS session token is a live credential for as long as it lasts, and it\n // travels in the same dump as the pair above.\n label: 'aws-session-token',\n description: 'AWS session token ((aws-)session-token field + 100+ base64)',\n regex: /\\b(?:aws[_-]?)?session[_-]?token\\b[\"'`]?\\s*[:=]\\s*[\"'`]?(?!(?:\\$|\\{)|(?:[A-Za-z_$][A-Za-z0-9_$]*(?:\\.[A-Za-z_$][A-Za-z0-9_$]*)+!?)(?=[\\s\"'`,;]|$))[^\\s\"'`,;]{100,}/gi,\n },\n {\n // THE WINDOW IS MEASURED, not chosen. Gap between the end of the id and the\n // start of the secret, in the six formats these actually arrive in:\n //\n // console CSV row 1 terraform provider block 18\n // sts assume-role JSON 21 aws CLI credentials file 25\n // env export pair 30 docker-compose env 30\n //\n // 80 is the largest real case plus room for one intervening line, and both\n // sides of it are pinned by a fixture — a proximity threshold nothing can\n // move is a magic number wearing a measurement's clothes (F035.12).\n //\n // The false-positive cost is near zero BECAUSE the id must be present: a\n // 40-char base64 string is only redacted when an AWS access key id sits\n // within 80 characters of it. That also catches the pair when the field is\n // named something we never anticipated, which the rule above cannot.\n label: 'aws-secret-access-key-paired',\n description: 'A 40-char base64 value within 80 characters of an AWS access key id',\n // `=` is deliberately NOT in the leading lookbehind, though it IS in the\n // value class: base64 padding never STARTS a value, and excluding it there\n // blocked every `KEY=value` form — measured, `blob=<secret>` went\n // unredacted while the same pair in CSV and Terraform was caught.\n regex: /(?<=(?:AKIA|ASIA)[0-9A-Z]{16}[\\s\\S]{0,80})(?<![A-Za-z0-9/+])[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])/g,\n },\n {\n // A SEPARATE LABEL FROM AKIA, and the reason is operational rather than\n // tidy: the two demand different responses. A leaked long-term key must be\n // rotated; a leaked STS key may already have expired on its own. A reader\n // seeing [REDACTED:…] in a log can only make that call if the marker says\n // which one it was. ASIA was unmatched entirely before 0.8.0, so an\n // assumed-role dump read as clean.\n label: 'aws-temporary-access-key-id',\n description: 'AWS temporary (STS) access key id (ASIA…)',\n regex: /\\bASIA[0-9A-Z]{16}\\b/g,\n },\n {\n label: 'aws-access-key-id',\n description: 'AWS long-term access key id (AKIA…)',\n regex: /\\bAKIA[0-9A-Z]{16}\\b/g,\n },\n {\n label: 'github-token',\n description: 'GitHub token (ghp_/gho_/ghs_/ghu_/ghr_…)',\n regex: /\\bgh[posru]_[A-Za-z0-9]{36,}\\b/g,\n },\n {\n // GitHub fine-grained PAT — distinct prefix `github_pat_` (not caught by the\n // classic gh[posru]_ above), then base62 + a `_` separator (~82 chars total).\n // The prefix is so distinctive that {50,} keeps false positives at zero.\n label: 'github-fine-grained-pat',\n description: 'GitHub fine-grained personal access token (github_pat_…)',\n regex: /\\bgithub_pat_[A-Za-z0-9_]{50,}/g,\n },\n {\n label: 'gitlab-token',\n description: 'GitLab personal access token (glpat-…)',\n regex: /\\bglpat-[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'slack-token',\n description: 'Slack token (xox[baprs]-…)',\n regex: /\\bxox[baprs]-[A-Za-z0-9-]{10,}/g,\n },\n {\n label: 'stripe-secret-key',\n description: 'Stripe live secret/restricted key (sk_live_/rk_live_…)',\n regex: /\\b[rs]k_live_[A-Za-z0-9]{20,}/g,\n },\n {\n // Filed by buddy, who found REAL ones sitting in plaintext in their own\n // transcription DB. Their scrub deliberately runs the format axis ONLY, so a\n // prefixed secret we do not match is a secret nobody catches — a precise\n // prefix is the only route that helps them. Zero false-positive risk: the\n // literal `whsec_` does not occur by accident.\n label: 'stripe-webhook-secret',\n description: 'Stripe webhook signing secret (whsec_…)',\n regex: /\\bwhsec_[A-Za-z0-9+/=_-]{20,}/g,\n },\n {\n // Resend (re_…). Lookahead requires a digit in the body so we don't redact\n // long snake_case identifiers like re_compute_the_thing.\n label: 'resend-api-key',\n description: 'Resend API key (re_ + token)',\n regex: /\\bre_(?=[A-Za-z0-9_]*\\d)[A-Za-z0-9_]{24,}\\b/g,\n },\n {\n label: 'supabase-access-token',\n description: 'Supabase personal/management access token (sbp_ + 40 hex)',\n regex: /\\bsbp_[0-9a-f]{40}/g,\n },\n {\n label: 'supabase-secret-key',\n description: 'Supabase secret API key (sb_secret_…)',\n regex: /\\bsb_secret_[A-Za-z0-9_-]{20,}/g,\n },\n {\n // Used by every @broberg/* publish — the highest-value leak from a .env / commit history.\n label: 'npm-token',\n description: 'npm publish/automation token (npm_ + 36 base62)',\n regex: /\\bnpm_[A-Za-z0-9]{36}\\b/g,\n },\n {\n label: 'fly-api-token',\n description: 'Fly.io API token (FlyV1 fm2_… / fo1_…)',\n regex: /(?:FlyV1 fm2_[A-Za-z0-9+/=_-]{20,}|\\bfo1_[A-Za-z0-9_-]{20,})/g,\n },\n {\n // Also covers Turso DB/platform auth tokens AND Supabase anon/service_role\n // keys — both are JWTs (eyJ…), so the single JWT pattern catches them.\n label: 'jwt',\n description: 'JSON Web Token (eyJ…) — incl. Turso + Supabase service_role tokens',\n regex: /\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}/g,\n },\n {\n // genApiKey = randomBytes(24).hex → uk_ + exactly 48 lowercase hex.\n label: 'upmetrics-key',\n description: 'Upmetrics project key (uk_ + 48 hex)',\n regex: /\\buk_[0-9a-f]{48}/g,\n },\n {\n label: 'cardmem-key',\n description: 'Cardmem personal/incident/project key (pa_/pi_/pk_ + 64 hex)',\n regex: /\\bp[aik]_[A-Za-z0-9]{20,}/g,\n },\n {\n // cardmem inbox-webhook key — piw_ isn't matched by p[aik]_ above (3rd char 'w' ≠ '_').\n label: 'cardmem-webhook-key',\n description: 'Cardmem inbox-webhook key (piw_ + 64 hex)',\n regex: /\\bpiw_[0-9a-f]{64}/g,\n },\n {\n // VERIFIED WITH THE OWNER, 2026-08-28: `trail_` + exactly 64 LOWERCASE HEX.\n // trail generated 2000 keys through @broberg/apikey's generateKey('trail')\n // and counted the alphabet: 0-9a-f only, length 64-64, no `-`, no `_`. Source\n // is `${prefix}_${randomBytes(bytes).toString(\"hex\")}`, bytes=32, with a hard\n // floor of 16 — so even a future caller asking for the minimum yields 32 hex\n // chars, still above {20,}.\n //\n // Recorded because the QUESTION is easy to re-ask and the ANSWER is not: this\n // is one of the few patterns here assuming alphanumerics only, and had trail\n // used base64url (the more common one-liner) the `-` and `_` would break the\n // run, {20,} would never be satisfied, and the WHOLE key would pass through\n // unredacted — not partially, entirely. Measured rather than assumed, because\n // two sampled keys cannot tell hex from base64url that has not hit a `-` yet.\n label: 'trail-key',\n description: 'Trail personal API key (trail_ + 64 hex; verified 2026-08-28)',\n regex: /\\btrail_[A-Za-z0-9]{20,}/g,\n },\n {\n // Cronjobs API key (cronjobs.webhouse.net) — cj_ + randomBytes(32).base64url =\n // exactly 43 base64url chars (46 total). Prefix + fixed length = very low FP.\n // The UI's truncated cj_<8 chars>… preview is shorter than {43} → not matched.\n // Negative lookahead (not \\b) because base64url's `-` breaks a trailing \\b.\n label: 'cronjobs-api-key',\n description: 'Cronjobs API key (cj_ + 43 base64url)',\n regex: /\\bcj_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g,\n },\n {\n // HelpDesk API key (helpdesk.broberg.ai) — minted through @broberg/apikey,\n // which is OURS: generateKey(prefix, 32) → `${prefix}_${randomBytes(32).hex}`,\n // so exactly 64 LOWERCASE hex. Verified in packages/apikey/src/core.ts rather\n // than taken from the report. There is no checksum and no internal structure\n // to anchor on; prefix + fixed length + hex is everything there is, and it is\n // enough — `hd_live_` followed by exactly 64 hex does not occur by accident.\n //\n // THE LENGTH IS EXACT ON PURPOSE, and this is the half that needs defending\n // in six months. HelpDesk shows a PREVIEW — `hd_live_f4b4cf`, prefix + 6 hex\n // — deliberately, in their UI and their logs, so a human can see WHICH key\n // was revoked. It is not a secret. Redacting it breaks a value designed to be\n // read, and then the preview stops doing its job.\n //\n // So the tempting loosening — \"let us catch the shortened ones too\" — is the\n // one thing this pattern must never accept. `{64}` excludes the preview, and\n // a NAMED test says so, because by then nobody will remember why.\n //\n // The trailing lookahead covers BOTH cases: 65 hex is not a key, and neither\n // is 64 lowercase followed by an uppercase hex digit.\n //\n // NO PUBLISHABLE VARIANT, measured not assumed: HelpDesk is headless and its\n // console is a client of the same API using a session token. `grep -c \"hd_\"`\n // in the deployed bundle returns 0, so no key ever reaches a browser and the\n // Stripe pk_live_ trap has no counterpart here. Re-check if that changes.\n label: 'helpdesk-api-key',\n description: 'HelpDesk API key (hd_live_ + 64 hex, minted by @broberg/apikey)',\n regex: /\\bhd_live_[0-9a-f]{64}(?![0-9a-fA-F])/g,\n },\n {\n // UpCloud API token — `ucat_` + a ULID: exactly 26 Crockford base32 chars\n // (no I, L, O, U). Measured in three independent places, not inferred from\n // the masked example that filed it: UpCloud's API docs (create response),\n // UpCloud's own Go client fixture, and Kingfisher's rule — all three are in\n // test/f035-16.test.ts. The token's `id` is a separate UUID and is not a\n // secret.\n //\n // NO EXAMPLE VALUE IN THIS COMMENT, on purpose: the bundle keeps comments,\n // so a literal token here ships in dist/ and the scanner flags ITSELF — the\n // pre-commit gate's test caught exactly that on the first 0.10.0 tag.\n //\n // Case-insensitive like Kingfisher: Crockford decodes either case, and a\n // lowercased token is still a live credential. The trailing lookahead keeps\n // 27 chars from matching its first 26 — and leaves UpCloud's own\n // `ucat_[REDACTED]` marker alone.\n label: 'upcloud-api-token',\n description: 'UpCloud API token (ucat_ + 26 Crockford base32)',\n regex: /\\bucat_[0-9A-HJKMNP-TV-Z]{26}(?![0-9A-Za-z])/gi,\n },\n // ── F035.17 — the vault survey of 30 Sep 2026. Every shape below was MEASURED\n // on values already stored in cardmem's vault (the script read them server-side\n // and printed only prefix, length and charset), then checked against a source:\n // Kingfisher's public rule set for vendor tokens, our own minters for fleet keys.\n // No example value appears in these comments: the bundle keeps comments, and a\n // literal here would make the scanner flag its own dist/ (see upcloud above).\n {\n // Cloudflare's prefixed user API token. 3 in the vault, all 48 after the\n // prefix; Kingfisher allows 41-64. Runs before the context-only\n // cloudflare-api-token below so a prefixed one is named by its prefix.\n label: 'cloudflare-user-api-token',\n description: 'Cloudflare user API token (cfut_ + 41-64 base64url)',\n regex: /\\bcfut_[A-Za-z0-9_-]{41,64}(?![A-Za-z0-9_-])/g,\n },\n {\n // Runpod: rpa_ + 40 uppercase/digit + a 6-char mixed-case checksum tail\n // (Kingfisher runpod.1). 2 in the vault, both 46.\n label: 'runpod-api-key',\n description: 'Runpod API key (rpa_ + 46)',\n regex: /\\brpa_[A-Z0-9]{40}[A-Za-z0-9]{6}(?![A-Za-z0-9])/g,\n },\n {\n // Hugging Face user (hf_) and org (api_org_) tokens: 34 letters/digits.\n label: 'huggingface-token',\n description: 'Hugging Face token (hf_ / api_org_ + 34)',\n regex: /\\b(?:hf|api_org)_[A-Za-z0-9]{34}(?![A-Za-z0-9])/g,\n },\n {\n // Tailscale: tskey-<kind>-<id>-<secret>. The body carries its own dash, so\n // the class includes it. Measured 50-51 after the kind; Kingfisher's {20,36}\n // would stop short of those, so the ceiling is ours.\n label: 'tailscale-key',\n description: 'Tailscale key (tskey-<kind>-…)',\n regex: /\\btskey-[a-z]{3,10}-[A-Za-z0-9_-]{20,64}(?![A-Za-z0-9_-])/g,\n },\n {\n // Tigris secret access key: tsec_ + exactly 70 (Kingfisher tigris.2).\n label: 'tigris-secret-key',\n description: 'Tigris secret access key (tsec_ + 70)',\n regex: /\\btsec_[A-Za-z0-9_+-]{70}(?![A-Za-z0-9_+-])/g,\n },\n {\n // Slack APP-level token. slack-token above only knows xox*, so an xapp-\n // token went through untouched. Same label on purpose: the vault already\n // stores it as slack-token, and a second name for one provider helps nobody.\n label: 'slack-token',\n description: 'Slack app-level token (xapp-…)',\n regex: /\\bxapp-\\d{1,3}-[A-Za-z0-9]{8,15}-\\d{8,15}-[A-Za-z0-9]{20,70}(?![A-Za-z0-9])/g,\n },\n {\n // Aiven service password — the credential UpCloud's managed PostgreSQL hands\n // out. AVNS_ + 19. One in the vault; [Likely] fixed length, and a password\n // that silently stops matching is caught by the survey re-run, not by luck.\n label: 'aiven-service-password',\n description: 'Aiven service password (AVNS_ + 19) — UpCloud managed databases',\n regex: /\\bAVNS_[A-Za-z0-9_-]{19}(?![A-Za-z0-9_-])/g,\n },\n {\n // BID app key. OURS: broberg-id mints `bidk_${randomBytes(32).base64url}`,\n // so exactly 43 base64url — a fact about our minter, not a guess.\n label: 'bid-app-key',\n description: 'Broberg ID app key (bidk_ + 43 base64url)',\n regex: /\\bbidk_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g,\n },\n {\n // Fleet hex keys measured in the vault. Hex length is fixed by construction\n // (randomBytes(n).hex), so the survey's lengths are the minter's lengths.\n label: 'beacon-token',\n description: 'Beacon token (bcn_ + 64 hex)',\n regex: /\\bbcn_[0-9a-f]{64}(?![0-9a-fA-F])/g,\n },\n {\n label: 'mailworker-admin-key',\n description: 'mailworker admin key (mw_ + 64 hex)',\n regex: /\\bmw_[0-9a-f]{64}(?![0-9a-fA-F])/g,\n },\n {\n label: 'upmetrics-remediation-token',\n description: 'Upmetrics remediation token (umrt_ + 48 hex)',\n regex: /\\bumrt_[0-9a-f]{48}(?![0-9a-fA-F])/g,\n },\n {\n // A database URL with a password in it. Matches ONLY the password (the\n // lookbehind pins scheme://user: before it, the lookahead pins @ after), so\n // a redacted URL still says which database it points at.\n //\n // The password must contain a digit or be 12+ characters — so the\n // `user:password@` and `user:pass@` placeholders every README carries stay\n // readable. A real generated DB password clears that bar trivially.\n label: 'connection-string',\n description: 'Password inside a database connection URL',\n regex: /(?<=\\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\\+srv)?|rediss?|amqps?):\\/\\/[^\\s:@/]+:)(?=[^\\s@/]*\\d|[^\\s@/]{12})[^\\s@/]+(?=@)/gi,\n },\n {\n // randomBytes(32).hex → wh_ + 64 lowercase hex (67 chars total).\n // NOTE: unlike cj_ and hd_live_ above, this one has no trailing lookahead, so\n // wh_ + 65 hex matches its first 64. Not a leak (the value is still redacted)\n // and not changed here — flagged rather than silently altered in a card about\n // something else.\n label: 'cms-access-token',\n description: 'webhouse.app CMS access token (wh_ + 64 hex)',\n regex: /\\bwh_[0-9a-f]{64}/g,\n },\n {\n // Cloudflare API token (R2 / DNS management) — 40 base64url chars, NO prefix.\n // A bare {40} would false-positive broadly, so this is CONTEXT-ONLY: it only\n // fires next to a cf/cloudflare-api-token-named field. Runs before\n // labeled-hex-secret so a hex-valued CF token is attributed correctly.\n label: 'cloudflare-api-token',\n description: 'Cloudflare API token (cf/cloudflare-api-token field + 40 base64url)',\n regex: /\\b(?:cf|cloudflare)_?api_?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{40}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Mistral API key — prefix-less ~32 base62 (Christian-confirmed sample). A bare\n // [A-Za-z0-9]{32} would FP on every ID/hash, so CONTEXT-ONLY: anchored on a\n // mistral-(api-)key/token-named field. Runs before labeled-hex for attribution.\n label: 'mistral-api-key',\n description: 'Mistral API key (mistral-(api-)key/token field + 24+ base62)',\n regex: /\\bmistral(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{24,}(?![A-Za-z0-9])/gi,\n },\n {\n // DeepSeek — field-anchored fallback for any DEEPSEEK_API_KEY/TOKEN value that\n // doesn't fit the canonical sk-+hex shape (mirrors the Mistral context-only\n // approach). The field name is the signal → near-zero false positives. The\n // sk-+hex format pattern above already attributes the canonical shape; this\n // backstops a format change or an opaque token.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (deepseek-(api-)key/token field + 20+ token)',\n regex: /\\bdeepseek(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Vimeo personal access token — ~32 lowercase hex, no prefix (sanne). A bare\n // hex32 would FP massively (MD5/UUID), so CONTEXT-ONLY: anchored on a\n // vimeo-(access-)token-named field.\n label: 'vimeo-access-token',\n description: 'Vimeo access token (vimeo-(access-)token field + 20+ base62)',\n regex: /\\bvimeo(?:[_-]?access)?[_-]?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{20,}(?![A-Za-z0-9])/gi,\n },\n {\n // Context-based catch for prefix-less high-entropy service secrets\n // (CMS_JWT_SECRET, revalidateSecret, fleet openssl-rand-hex secrets): a 40+\n // hex value assigned to a field whose name contains\n // secret/token/password/api-key. The name requirement keeps the\n // false-positive rate near zero (a bare 40/64-hex would hit shas/hashes).\n label: 'labeled-hex-secret',\n description: 'A 40+ hex value assigned to a secret/token/password/api-key-named field',\n regex: /\\b[A-Za-z0-9_-]*(?:secret|token|password|api[_-]?key)\\b\\s*[:=]\\s*[\"'`]?[0-9a-f]{40,}/gi,\n },\n {\n // Discord bot token — three base64url segments. Anchored both sides so it\n // can't partial-match a longer dotted string.\n label: 'discord-bot-token',\n description: 'Discord bot token (3 base64url segments)',\n regex: /(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{24,26}\\.[A-Za-z0-9_-]{6}\\.[A-Za-z0-9_-]{27,40}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'discord-mfa-token',\n description: 'Discord MFA token (mfa. + 84 chars)',\n regex: /\\bmfa\\.[A-Za-z0-9_-]{84}\\b/g,\n },\n {\n // Cloudflare Turnstile PROD secret (sanne, verified 2/2) — 0x4 + 6×A prefix,\n // then 26 base64url (35 total). The 24-char SITE key + 1x/2x/3x TEST keys are\n // intentionally NOT matched (the {26} length gate misses them) so a public\n // key is never redacted.\n label: 'cloudflare-turnstile-secret',\n description: 'Cloudflare Turnstile secret key (0x4AAAAAA + 26 base64url, 35 total)',\n regex: /0x4AAAAAA[A-Za-z0-9_-]{26}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'cloudflare-global-key',\n description: 'Cloudflare global API key (37-hex)',\n regex: /\\b[0-9a-f]{37}\\b/g,\n },\n {\n // LAST on purpose: this is the only unprefixed shape in the list, so every\n // anchored pattern above must get first refusal.\n //\n // Philips Hue v2 application key — 40 chars of [A-Za-z0-9-] with NO prefix,\n // so there is nothing to anchor on. The negative lookahead is load-bearing,\n // not decoration: a bare [A-Za-z0-9-]{40} also matches a GIT COMMIT SHA, and\n // telemetry/error output is full of those. A redactor that eats commit\n // hashes gets switched off within a week, after which it protects nothing.\n //\n // F035.10 — THAT SENTENCE CAME TRUE ABOUT THIS VERY PATTERN. It was the only\n // prefix-less pattern here matching on ENTROPY ALONE, and base64 is\n // mixed-case alphanumeric, so it fired inside npm integrity digests:\n //\n // resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/…}\n // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ matched\n //\n // 33 hits in components' own pnpm-lock.yaml, 32 of them digests. (`-` is in\n // the class but is not a word character, so \\b anchors happily mid-digest.)\n // Reported by trail, whose gate then could not commit a lockfile change —\n // i.e. no dependency update at all. A gate nobody can satisfy is a gate\n // someone switches off, which costs more than the hole it closed.\n //\n // It is now CONTEXT-ONLY like every other prefix-less secret in this file\n // (cloudflare-api-token, mistral-api-key, vimeo-access-token,\n // labeled-hex-secret): the field name is the signal, not the randomness.\n // Deliberately given up: a bare 40-char Hue key in prose with no field name.\n // See the README's \"Deliberately NOT detected\" — do not remove the anchor to\n // \"fix\" that; a pattern that cannot tell a key from a checksum is worse than\n // no pattern. (Original pattern contributed by beacon, F035.7.)\n label: 'hue-application-key',\n description: 'Philips Hue application key (hue/bridge-named field + 40 chars)',\n // The `[\"'`]?` BEFORE the separator is not decoration: a Hue key most often\n // arrives as JSON — {\"hue_application_key\": \"…\"} — and the four older\n // context-only patterns in this file all omit it, so they miss the quoted\n // form. Noted rather than silently changed there; that is its own card.\n regex: /\\b(?:hue|bridge)[_-]?(?:application[_-]?key|username|user|key)\\b[\"'`]?\\s*[:=]\\s*[\"'`]?[A-Za-z0-9-]{40}(?![A-Za-z0-9-])/gi,\n },\n];\n\n/**\n * Shapes that identify a secret by its VALUE ALONE, with no field name.\n *\n * These are deliberately NOT in `SECRET_PATTERNS`, because a scanner runs over\n * arbitrary text where an unanchored entropy match is a disaster: the Hue shape\n * (40 mixed-case alphanumerics) is also what a 40-character window inside an npm\n * `sha512-…` digest looks like, which is how 0.5.0 blocked every lockfile commit\n * in every repo running the gate (F035.10).\n *\n * `classify()` is a different question, and that is the whole reason this list\n * exists. Its caller has ALREADY asserted the string is a secret — they pasted\n * it into a vault field and asked \"what kind?\" — so there is no checksum to\n * confuse it with and no text to corrupt. Answering \"unknown\" there costs a\n * consumer a working feature (cardmem's Secrets Vault type-detection) for a\n * false-positive risk that only exists when scanning.\n *\n * Same value, two questions: \"is there a secret in this text?\" and \"what kind of\n * secret is this?\" They do not deserve the same evidence bar.\n */\n// ONE source for the value-only rule, two anchorings derived from it. Written\n// twice by hand, the two forms drift the first time anyone tunes one of them.\n//\n// The lookaheads are the discriminator: 40 chars of [A-Za-z0-9-] that contain\n// BOTH a lower- and an upper-case letter. A git SHA (40 lowercase hex) therefore\n// never matches, which is the collision that would otherwise dominate.\nconst HUE_KEY_BODY = String.raw`(?=[A-Za-z0-9-]*[a-z])(?=[A-Za-z0-9-]*[A-Z])[A-Za-z0-9-]{40}`;\n\nconst VALUE_ONLY: ReadonlyArray<SecretPattern> = [\n {\n label: 'hue-application-key',\n description: 'Philips Hue application key (40 chars, no prefix)',\n // Anchored: `classify` is handed ONE value and asks what it is.\n regex: new RegExp(String.raw`^(?=[A-Za-z0-9-]{40}$)${HUE_KEY_BODY}$`),\n },\n];\n\n// Unanchored: `redactSecrets({ valueOnly: true })` runs over free text, where the\n// key sits inside a sentence. Same body, word-bounded.\n//\n// THIS IS THE ONE THAT EATS PROSE, which is why it is opt-in. Measured over two\n// trees with the identical pattern (F035.12):\n//\n// lockfiles everything else\n// components 1 file, 33 hits 15 files, 35 hits class names, hyphenated prose\n// beacon 8 hits 0 prose 12 deliberate fixtures\n//\n// The charset includes the HYPHEN, so a 40-character run of kebab-case slug or\n// hyphenated English matches — `gate-the-submit-button-on-status-not-on-`,\n// `WebStandardStreamableHTTPServerTransport`. No file-level exemption reaches\n// that; it is prose, not lockfiles.\n//\n// So the right default depends on the CALL SITE, not on the quality of the\n// pattern. beacon redacts logs: a false positive costs a masked word, a false\n// negative costs their bridge key. Our commit gate blocks commits: a false\n// positive costs a developer a blocked README. Same pattern, opposite cost —\n// which is what makes it a parameter rather than a fix.\nconst VALUE_ONLY_UNANCHORED: ReadonlyArray<SecretPattern> = [\n {\n label: 'hue-application-key',\n description: 'Philips Hue application key (40 chars, no prefix)',\n regex: new RegExp(String.raw`\\b${HUE_KEY_BODY}\\b`, 'g'),\n },\n];\n\n/**\n * Every pattern this package matches, for callers that want to inspect or audit\n * the roster.\n *\n * THE EXPORTED REGEXES ARE NOT GLOBAL, and that is a deliberate difference from\n * the ones used internally (F035.12). A `/g` regex carries `lastIndex` BETWEEN\n * CALLS, so the obvious way to inspect one lies. Measured on published 0.6.0:\n *\n * p.regex.test(sample) -> true lastIndex now 20\n * p.regex.test(sample) -> false <- same input, different answer\n *\n * Anyone measuring our own patterns — which is exactly what a consumer auditing\n * a redaction does — got alternating answers and no indication why. The copies\n * below are stateless, so testing them is idempotent.\n *\n * VALUE_ONLY_PATTERNS is exported for the same reason it exists: `classify` can\n * return a label that is in NEITHER list if only one of them is published, and a\n * roster that under-describes what the package detects is worse than no roster.\n */\n/** A global copy, for the replace pass. A caller's `extraPatterns` regex may\n * arrive without `/g`, in which case `String.replace` would substitute only the\n * FIRST occurrence and leave the rest in the text. */\nconst asGlobal = (re: RegExp): RegExp =>\n re.flags.includes('g') ? re : new RegExp(re.source, `${re.flags}g`);\n\nconst withoutGlobal = (list: ReadonlyArray<SecretPattern>): ReadonlyArray<SecretPattern> =>\n Object.freeze(\n list.map((p) =>\n Object.freeze({ ...p, regex: new RegExp(p.regex.source, p.regex.flags.replace('g', '')) }),\n ),\n );\n\n/** Every format pattern, ordered most-specific → least, as STATELESS copies —\n * safe to `.test()` repeatedly. See `withoutGlobal` above for what shared\n * `lastIndex` did to anyone auditing our own patterns before 0.7.0. */\nexport const SECRET_PATTERNS: ReadonlyArray<SecretPattern> = withoutGlobal(PATTERNS);\n\n/** The value-only axis — shapes identified from the VALUE ALONE, with no field\n * name beside them. Opt-in at the call site (`{ valueOnly: true }`); see the\n * option's own documentation for why the default is off. */\nexport const VALUE_ONLY_PATTERNS: ReadonlyArray<SecretPattern> = withoutGlobal(VALUE_ONLY);\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 * Also apply the VALUE-ONLY axis — shapes identified from the value alone,\n * with no field name beside them (today: the Philips Hue application key).\n *\n * OFF BY DEFAULT, and the reason is not that the pattern is bad (F035.12).\n *\n * cardmem's rule, which settled the design: **the decision to accept a weak\n * signal belongs to whoever can RENDER the uncertainty. A surface that cannot\n * show \"guess\" must not be given guesses.** Their vault shows a credential's\n * type as a chip beside the name, with nowhere to say \"low confidence\", so a\n * guess they accepted would silently become an assertion the owner acts on.\n * They take the empty answer instead.\n *\n * beacon's calculus is the opposite and equally correct: they redact logs, so\n * a false positive costs a masked word and a false negative costs their bridge\n * key. Their two call paths — masking each string separately, and passing a\n * bridge error message as free text — structurally cannot supply a field name,\n * so the field-anchored rule can never fire for them.\n *\n * MEASURED, same pattern, two corpora, opposite answers:\n *\n * components 2 lockfiles (39 hits) + 9 other files (20 hits) — class names,\n * documentation, `WebStandardStreamableHTTPServerTransport`\n * beacon 8 lockfile hits, 0 prose, 12 deliberate fixtures\n *\n * So there is no single correct default, which is exactly what makes this a\n * parameter rather than a fix. An OPTION rather than a `confidence` field on\n * the result, deliberately: a field is ignorable by destructuring the label,\n * and a caller who did not ask for weak guesses must not be able to receive\n * one by accident. The parameter name is the warning, at the one place it\n * cannot be skipped.\n */\n valueOnly?: boolean;\n}\n\n/** Marker label for a secret detected by its announcing label rather than shape. */\nexport const ANNOUNCED_LABEL = 'announced-secret';\n\n/**\n * Label + separator + value. The label list is deliberately short and concrete;\n * this is not a general \"looks like config\" detector.\n *\n * WHY THIS IS OPT-IN, MEASURED RATHER THAN GUESSED. Over this repo on\n * 2026-08-14 — 548 tracked files, 544 readable as text, containing essentially\n * no real secrets — this exact regex matched **97 times**, and every one was\n * noise. Per label: `secret` 61, `api key` 33, `password` 4, and every Danish\n * label 0. So 94 of the 97 are the two words that are also ordinary IDENTIFIERS\n * in source code (`secret: config.secret`, `apiKey: Record<…>`).\n *\n * That is the real finding, and it is sharper than \"the pattern is noisy\": its\n * precision depends entirely on WHAT IS BEING SCANNED. In an inbound mail body\n * — buddy's actual case — `Adgangskode:` is a strong signal. In a TypeScript\n * file it is a variable name. **The package cannot know which corpus it is\n * looking at; only the caller can.** So the caller makes the decision, and the\n * default cannot be on. (This is the opposite of this repo's usual defaults-ON\n * stance — webpush F067.1, lens-engine F065 — and the numbers above are why.)\n *\n * A broader label+separator+value pattern measured 305 on the same corpus, and\n * refining it only reached 202 — no amount of tuning makes a generic version\n * safe. A template/env-reference guard (`${FOO}`, `<your-key>`) was written and\n * then dropped: it changed the count by exactly 0, because the noise here is\n * identifiers, not templates.\n *\n * The value must not already be a redaction marker, so this can run AFTER the\n * format pass without flattening its more specific attribution.\n *\n * NOT IN THE LIST, AND DELIBERATELY (v0.4.0): bare `kode`. **In Danish, `kode`\n * mostly means SOURCE CODE** — the credential words are `kodeord` and\n * `adgangskode`, both still matched. Until 0.4.0 the bare form was included and\n * fired on ordinary technical prose; measured by buddy over 82,662 lines of real\n * Danish transcription: \"Det er min kode: se linje 40\" · \"Kode: const x = 1\" ·\n * \"Merge-kode: konflikten er løst\" · \"QR-kode: scan den\".\n *\n * And the behaviour was ARBITRARY, which is the part that settled it: `\\b` meant\n * `Landekode:` / `Postkode:` / `Fejlkode:` never matched (no word boundary inside\n * the word) while `QR-kode:` did (a hyphen IS one). Whether a compound was\n * flagged came down to whether someone happened to type a hyphen.\n *\n * THE COST, stated rather than hidden: `Her er min kode: hunter2` is no longer\n * detected, and that is a real Danish way to announce a password. Deliberate — a\n * token that means \"source code\" half the time is noise in every corpus, not\n * just buddy's. If you need it back, file it; do not re-add it locally.\n */\nconst ANNOUNCED_SECRET =\n /(\\b(?:adgangskode|kodeord|hemmelighed|password|passwd|api[ -]?key|apinøgle|secret|pwd)[\"'`\\]]?\\s*[:=]\\s*)(\\S+)/gi;\n\n/**\n * Delimiters that may WRAP a value without being part of it.\n *\n * D1 (F035.12) — `(\\S+)` swallowed these INTO the replaced span, so redacting\n * DELETED them. Measured on published 0.6.0:\n *\n * config(password='hunter2') -> config(password=[REDACTED:announced-secret]\n * Kodeord: hunter2, og derefter -> Kodeord: [REDACTED:announced-secret] og derefter\n * brug `password: hunter2` -> brug `password: [REDACTED:announced-secret]\n *\n * A closing paren, a comma and a backtick, gone. Anyone re-redacting a corpus\n * gets syntactically broken text back — and buddy holds 41k texts to do exactly\n * that. It also inflated every length measurement taken on candidates.\n *\n * THE LIST IS DELIBERATELY NARROW, and what is ABSENT is the load-bearing part:\n * `!` `?` `.` are NOT here. `Sommer2026!` is a real measured password and its\n * final character must go INTO the redaction, not survive it. A trailing quote\n * or bracket is structure; a trailing bang is content. Guessing wrong in the\n * first direction corrupts a corpus; guessing wrong in the second leaks one\n * character of a real secret, so the list only grows on evidence.\n */\nconst LEADING_DELIMS = /^[([{\"'`]+/;\nconst TRAILING_DELIMS = /[)\\]},;\"'`]+$/;\n\n/**\n * Is this candidate plausibly a secret VALUE, or just the next word in a\n * sentence?\n *\n * F035.11 — WITHOUT THIS, THE AXIS EATS PROSE. The pattern above is\n * label + separator + `\\S+`, and in Danish and English «secret:» is ordinary\n * text. Measured on the published 0.5.1:\n *\n * 'Set som secret: gh secret set MYPAT'\n * -> 'Set som secret: [REDACTED:announced-secret] secret set MYPAT'\n * 'jeg siger det aldrig — secret: ALDRIG'\n * -> 'jeg siger det aldrig — secret: [REDACTED:announced-secret]'\n *\n * THE RULE IS DERIVED FROM buddy's NUMBERS, not chosen. Over 40,369 rows of real\n * fleet prose (23,801 intercom messages + 16,568 conversation turns) they found\n * 49 unique candidates after an announcing label. **35 of them were prose, and\n * every one of those 35 was under 16 characters with no digit** — \"kun\", \"jeg\",\n * \"gh\", \"aldrig\", \"ALDRIG\", \"»\". Zero were hex-like. And the axis caught ZERO\n * real secrets that the format rules had not already caught.\n *\n * So: a candidate with no digit, shorter than 16 characters, is prose.\n *\n * THE COST, STATED RATHER THAN HIDDEN, in the house style of the `kode` removal\n * above: `Adgangskode: correcthorse` is no longer detected. That is a real way to\n * write a real password. It is accepted deliberately — an over-broad redaction\n * destroys a corpus as effectively as a narrow one leaks it (buddy's framing),\n * and this axis is the one running over human prose. A value with any digit, or\n * any value of real key length, is unaffected: `hunter2` still goes.\n *\n * The judgement is on the CANDIDATE, never on the label. Narrowing the label list\n * would leave the same greedy `\\S+` behind every label that remained.\n */\nfunction plausibleSecretValue(candidate: string): boolean {\n return /\\d/.test(candidate) || candidate.length >= 16;\n}\n\n\n/** Replacement marker for a redacted secret. */\nexport const redactionMarker = (label: string): string => `[REDACTED:${label}]`;\n\n/** The opening of every marker. Derived from redactionMarker rather than typed\n * again, so the two cannot drift apart — a hand-written '[REDACTED:' here would\n * keep matching after someone changed the marker format. */\nconst MARKER_PREFIX = redactionMarker('').slice(0, -1);\n\nfunction patternsFor(opts?: RedactOptions): SecretPattern[] {\n return opts?.extraPatterns && opts.extraPatterns.length > 0\n ? [...PATTERNS, ...opts.extraPatterns]\n : 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 // VALUE-ONLY runs between format and announced: after the shapes that are safe\n // everywhere, before the label-driven axis, and only when the caller asked.\n if (opts?.valueOnly) {\n for (const p of VALUE_ONLY_UNANCHORED) {\n let count = 0;\n const next = redacted.replace(asGlobal(p.regex), (match: string) => {\n if (match.includes(MARKER_PREFIX)) return match;\n count++;\n return redactionMarker(p.label);\n });\n if (count > 0) {\n redacted = next;\n findings.push({ label: p.label, count, confidence: 'format' });\n }\n }\n }\n\n // Announced runs LAST, and only on request. Order is not cosmetic: the format\n // pass has already replaced everything it recognises, and this regex refuses a\n // value that is already a marker — so `API key: sk-ant-…` keeps its specific\n // `anthropic-api-key` attribution instead of being flattened to a generic one.\n // The announcing label itself is KEPT in the output; only the value goes, so\n // the redacted text still reads `Adgangskode: [REDACTED:announced-secret]` and\n // a human or model reading it can still tell what was removed.\n if (opts?.announced) {\n let count = 0;\n const redactedAnnounced = redacted.replace(\n ANNOUNCED_SECRET,\n (match: string, prefix: string, value: string) => {\n // ALREADY REDACTED -> leave it alone, so the format pass keeps its\n // specific attribution. The old guard was a `(?!\\[REDACTED:)` lookahead\n // in the regex, which only fired when the marker was the FIRST character\n // of the value — so a QUOTED key was flattened (measured on 0.6.0):\n //\n // API key: \"sk-ant-api03-…\" -> API key: [REDACTED:announced-secret]\n // findings: anthropic-api-key, announced-secret\n //\n // The redacted text stopped saying WHICH kind of key it had been. This\n // tests for the marker ANYWHERE in the value rather than listing the\n // delimiters that could precede it — a list would have missed brackets,\n // parentheses and whatever nobody thought of next.\n if (value.includes(MARKER_PREFIX)) return match;\n\n // Split the wrapping delimiters off before judging AND before replacing,\n // so they survive into the output (D1). The judgement is on the CORE:\n // `'hunter2'` and `hunter2` are the same candidate.\n const lead = LEADING_DELIMS.exec(value)?.[0] ?? '';\n const trail = TRAILING_DELIMS.exec(value.slice(lead.length))?.[0] ?? '';\n const core = value.slice(lead.length, value.length - trail.length);\n\n // An implausible candidate is left EXACTLY as it was — byte for byte,\n // including the label. `match` rather than a rebuild on purpose: the\n // pieces are equal today and stop being equal the moment anyone adds a\n // group. Returning what was actually matched cannot drift.\n if (!core || !plausibleSecretValue(core)) return match;\n count++;\n return prefix + lead + redactionMarker(ANNOUNCED_LABEL) + trail;\n },\n );\n if (count > 0) {\n redacted = redactedAnnounced;\n findings.push({ label: ANNOUNCED_LABEL, count, confidence: 'announced' });\n }\n }\n return { redacted, findings, scanned };\n}\n\n/**\n * True if `text` announces a credential by label — `Adgangskode: hunter2` —\n * without building a redaction. Cheap enough to run on every inbound message.\n *\n * This exists because for untrusted inbound text heading to a model, the right\n * response is often to REFUSE rather than redact: a false positive costs a\n * slightly worse classification, a false negative costs a leak. That use needs a\n * boolean, not a redactor. (buddy's reasoning, F035.8.)\n */\nexport function hasAnnouncedSecret(text: string): boolean {\n if (!text) return false;\n // ROUTED THROUGH THE SAME PREDICATE as redactSecrets on purpose. A bare\n // `.test()` here would answer \"yes\" for a string redactSecrets leaves\n // untouched, and the two would disagree about the same input — which is worse\n // than either answer, because a caller can only ever ask one of them.\n //\n // THE INVARIANT IS NARROWER THAN \"THEY AGREE\", and the narrower one is what is\n // true (F035.12). They answer different questions and their FINDINGS can\n // legitimately differ:\n //\n // hasAnnouncedSecret('password: AKIA…') -> true\n // redactSecrets(same).findings -> [aws-access-key-id]\n //\n // Not a bug: the format pass runs FIRST and recognised the value, so it holds\n // the better attribution and the announced pass correctly declines to flatten\n // it. An earlier comment here claimed the two simply agree; that claim was\n // broader than the code, which is the shape this repo keeps naming.\n //\n // What IS guaranteed, and what a caller can rely on:\n //\n // hasAnnouncedSecret(t) === true => redactSecrets(t, { announced: true })\n // changes the text\n //\n // i.e. the boolean never promises a redaction that does not happen. It says\n // nothing about WHICH label does the work. Asserted in the suite over both the\n // agreeing and the disagreeing cases, so the weaker claim cannot silently\n // become the stronger one again.\n ANNOUNCED_SECRET.lastIndex = 0;\n for (let m = ANNOUNCED_SECRET.exec(text); m !== null; m = ANNOUNCED_SECRET.exec(text)) {\n // Same delimiter-stripping as the redactor, for the same reason: the two\n // must agree about what the CANDIDATE is, or they disagree about the input.\n const value = m[2] ?? '';\n if (value.includes(MARKER_PREFIX)) continue;\n const lead = LEADING_DELIMS.exec(value)?.[0] ?? '';\n const trail = TRAILING_DELIMS.exec(value.slice(lead.length))?.[0] ?? '';\n const core = value.slice(lead.length, value.length - trail.length);\n if (core && plausibleSecretValue(core)) {\n ANNOUNCED_SECRET.lastIndex = 0;\n return true;\n }\n }\n return false;\n}\n\n/**\n * True if `text` contains at least one detectable secret. Honours\n * `opts.announced` — a caller who asks for the announced axis and is told\n * `false` must be able to believe it.\n */\nexport function hasSecret(text: string, opts?: RedactOptions): boolean {\n if (opts?.announced && hasAnnouncedSecret(text)) return true;\n return patternsFor(opts).some((p) => {\n p.regex.lastIndex = 0;\n return p.regex.test(text);\n });\n}\n\nexport interface ClassifyResult {\n /** the matching pattern's stable label (e.g. `openai-api-key`) */\n label: string;\n /** the matching pattern's human description (e.g. `OpenAI API key (sk-… / sk-proj-…)`) */\n description: string;\n}\n\n/**\n * Classify a SINGLE pasted token — the INVERSE of redaction. Returns the first\n * (most-specific) pattern the value matches, or `null`. Backs a \"paste a key →\n * detect its type\" UI (cardmem F214 Secrets Vault) so every consumer shares the\n * same classification, not just the same redaction.\n *\n * First-match-wins over the ordered `SECRET_PATTERNS`, so `sk-ant-…` classifies\n * as `anthropic-api-key`, never the generic `openai-api-key`. Field-anchored\n * context-only patterns (mistral / vimeo / cloudflare-api-token /\n * labeled-hex-secret / deepseek-fallback) only match when the pasted value\n * includes their `NAME=` context; a bare provider token classifies via its\n * prefix pattern, and a prefix-less bare token (e.g. a raw Mistral key) is\n * genuinely unidentifiable → `null`. `opts.extraPatterns` run AFTER the\n * canonical set (canonical attribution wins). Input is trimmed; empty /\n * whitespace-only → `null`.\n */\nexport function classify(value: string, opts?: RedactOptions): ClassifyResult | null {\n if (!value) return null;\n const v = value.trim();\n if (!v) return null;\n for (const p of patternsFor(opts)) {\n p.regex.lastIndex = 0;\n if (p.regex.test(v)) return { label: p.label, description: p.description };\n }\n // Only after every anchored pattern has declined, and only when the caller\n // OPTED IN: shapes named from the value alone. Anchored to the WHOLE string\n // (^…$), so this can never fire on a fragment of a longer value.\n //\n // The gate is new in 0.6.1. Before it, `classify` consulted this list\n // unconditionally, so a caller could receive `hue-application-key` for a\n // 40-character id it had never heard of — a guess arriving in the same shape\n // as a certainty, with nothing in the return value marking the difference.\n if (!opts?.valueOnly) return null;\n for (const p of VALUE_ONLY) {\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 /** The matcher. GLOBAL on the internal list, because the redaction pass\n * replaces every occurrence — and NON-GLOBAL on everything this module\n * exports (`SECRET_PATTERNS`, `VALUE_ONLY_PATTERNS`), because a shared `/g`\n * regex carries `lastIndex` between calls and answers differently each time.\n * Through 0.7.0 this comment claimed the opposite, and it is the tooltip a\n * consumer sees: following it, `while ((m = p.regex.exec(text)))` never\n * advances and spins forever. */\n regex: RegExp;\n}\n\n/** Ordered most-specific → least. Every regex carries the `g` flag. */\n// The INTERNAL list. Global (`/g`) because the redaction pass replaces every\n// occurrence. Never exported directly — see SECRET_PATTERNS below for why.\nconst 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 // ── AWS ─────────────────────────────────────────────────────────────────\n //\n // ORDER IS LOAD-BEARING HERE, and it is not the order you would write first.\n // redactSecrets() applies patterns in sequence to the text it has ALREADY\n // redacted, so the id pattern must run LAST: it replaces `AKIA…` with a\n // marker, and the paired rule below anchors on that id. Put the id first and\n // the pair rule silently stops firing — a guard that is present, tested in\n // isolation, and dead in place.\n {\n // THE HALF THAT MATTERS, and it shipped unmatched for months. An access key\n // id alone is useless to an attacker; the SECRET key is the credential. So\n // redacting only the id — and stamping [REDACTED:…] right beside the live\n // secret — is worse than redacting nothing, because the marker tells the\n // reader the text was cleaned. Reported by cardmem the day they took AWS on.\n //\n // A bare 40-char base64 value CANNOT be matched: it is the shape of every\n // git object hash and base64 body in every repo we own. So this is\n // CONTEXT-ONLY, like every other prefix-less secret in this file.\n //\n // `access` is REQUIRED in the field name on purpose. A bare `secret_key`\n // (Terraform's spelling) would drag in far too much; that case is caught by\n // the paired rule below instead, which is the argument for having both.\n //\n // AND THE VALUE CLASS IS \"ANYTHING THAT IS NOT A DELIMITER\", not an\n // alphabet. cardmem measured their Tigris secret's charset after 0.8.1 and\n // it contains `+` — which our class happened to include, but only because\n // we guessed base64 rather than base64url. Their warning is the one worth\n // acting on: that is ONE key. It tells us what CAN occur, never what always\n // occurs, and the next provider's alphabet is another guess we would make\n // the same way. Under a field literally named `aws_secret_access_key`, the\n // NAME is the evidence; the value's alphabet adds nothing and can only be\n // wrong. So the value runs to the first delimiter and no further.\n //\n // ONE EXCLUSION, and it is a false positive our own docs produced the\n // moment the class widened: a value that is a REFERENCE to a secret is not\n // a secret. `secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!` is code\n // showing how to READ the credential, and redacting it would put a\n // [REDACTED:…] marker in a README about wiring up storage. So a value that\n // is ENTIRELY a dotted identifier path, or starts with a shell/template\n // expansion, is skipped. It must be the WHOLE value — a real secret may\n // contain dots, and the exclusion must not fire on one that does.\n //\n // THE LENGTH IS A FLOOR, NOT AWS'S 40 — measured by cardmem in production\n // and it is the finding that matters most here. Their four AWS_*-named\n // variables on Fly are NOT AWS: they are Tigris (Fly's S3-compatible\n // store), with a 54-character `tid_` id and a 75-character secret. Every\n // S3-compatible service — Tigris, R2, MinIO, Backblaze — reuses AWS's\n // variable NAMES with its own key format.\n //\n // Pinning 40 put a shape assumption on top of a name anchor, so the field\n // said AWS_SECRET_ACCESS_KEY, the value did not look like AWS, and the\n // credential stayed in the clear with no marker anywhere near it. The field\n // name is the signal in every context-only pattern in this file; that is\n // the whole design, and requiring a second signal quietly undid it.\n label: 'aws-secret-access-key',\n description: 'AWS/S3-compatible secret access key ((aws-)secret-access-key field + 20+ non-delimiter chars)',\n regex: /\\b(?:aws[_-]?)?secret[_-]?access[_-]?key\\b[\"'`]?\\s*[:=]\\s*[\"'`]?(?!(?:\\$|\\{)|(?:[A-Za-z_$][A-Za-z0-9_$]*(?:\\.[A-Za-z_$][A-Za-z0-9_$]*)+!?)(?=[\\s\"'`,;]|$))[^\\s\"'`,;]{20,}/gi,\n },\n {\n // An STS session token is a live credential for as long as it lasts, and it\n // travels in the same dump as the pair above.\n label: 'aws-session-token',\n description: 'AWS session token ((aws-)session-token field + 100+ base64)',\n regex: /\\b(?:aws[_-]?)?session[_-]?token\\b[\"'`]?\\s*[:=]\\s*[\"'`]?(?!(?:\\$|\\{)|(?:[A-Za-z_$][A-Za-z0-9_$]*(?:\\.[A-Za-z_$][A-Za-z0-9_$]*)+!?)(?=[\\s\"'`,;]|$))[^\\s\"'`,;]{100,}/gi,\n },\n {\n // THE WINDOW IS MEASURED, not chosen. Gap between the end of the id and the\n // start of the secret, in the six formats these actually arrive in:\n //\n // console CSV row 1 terraform provider block 18\n // sts assume-role JSON 21 aws CLI credentials file 25\n // env export pair 30 docker-compose env 30\n //\n // 80 is the largest real case plus room for one intervening line, and both\n // sides of it are pinned by a fixture — a proximity threshold nothing can\n // move is a magic number wearing a measurement's clothes (F035.12).\n //\n // The false-positive cost is near zero BECAUSE the id must be present: a\n // 40-char base64 string is only redacted when an AWS access key id sits\n // within 80 characters of it. That also catches the pair when the field is\n // named something we never anticipated, which the rule above cannot.\n label: 'aws-secret-access-key-paired',\n description: 'A 40-char base64 value within 80 characters of an AWS access key id',\n // `=` is deliberately NOT in the leading lookbehind, though it IS in the\n // value class: base64 padding never STARTS a value, and excluding it there\n // blocked every `KEY=value` form — measured, `blob=<secret>` went\n // unredacted while the same pair in CSV and Terraform was caught.\n //\n // THE CHEAP LOOKBEHIND GOES FIRST (0.11.1). Both are zero-width at the same\n // position, so the order does not change what matches — only what it costs.\n // With the 100-char id search first, EVERY character of a long run paid it:\n // 50,000 × 'A' took 3.7 s in this one pattern and a 400 KB blob 30+ s in\n // redactSecrets. `(?<![A-Za-z0-9/+])` rejects every position inside a run\n // in one step, and the lookahead then demands a whole 40-char value AHEAD\n // before anything looks behind — so after a space or a colon (where the\n // first guard passes) it fails in a character or two. The expensive id\n // search only runs where a complete candidate already stands.\n regex: /(?<![A-Za-z0-9/+])(?=[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=]))(?<=(?:AKIA|ASIA)[0-9A-Z]{16}[\\s\\S]{0,80})[A-Za-z0-9/+=]{40}/g,\n },\n {\n // A SEPARATE LABEL FROM AKIA, and the reason is operational rather than\n // tidy: the two demand different responses. A leaked long-term key must be\n // rotated; a leaked STS key may already have expired on its own. A reader\n // seeing [REDACTED:…] in a log can only make that call if the marker says\n // which one it was. ASIA was unmatched entirely before 0.8.0, so an\n // assumed-role dump read as clean.\n label: 'aws-temporary-access-key-id',\n description: 'AWS temporary (STS) access key id (ASIA…)',\n regex: /\\bASIA[0-9A-Z]{16}\\b/g,\n },\n {\n label: 'aws-access-key-id',\n description: 'AWS long-term access key id (AKIA…)',\n regex: /\\bAKIA[0-9A-Z]{16}\\b/g,\n },\n {\n label: 'github-token',\n description: 'GitHub token (ghp_/gho_/ghs_/ghu_/ghr_…)',\n regex: /\\bgh[posru]_[A-Za-z0-9]{36,}\\b/g,\n },\n {\n // GitHub fine-grained PAT — distinct prefix `github_pat_` (not caught by the\n // classic gh[posru]_ above), then base62 + a `_` separator (~82 chars total).\n // The prefix is so distinctive that {50,} keeps false positives at zero.\n label: 'github-fine-grained-pat',\n description: 'GitHub fine-grained personal access token (github_pat_…)',\n regex: /\\bgithub_pat_[A-Za-z0-9_]{50,}/g,\n },\n {\n label: 'gitlab-token',\n description: 'GitLab personal access token (glpat-…)',\n regex: /\\bglpat-[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'slack-token',\n description: 'Slack token (xox[baprs]-…)',\n regex: /\\bxox[baprs]-[A-Za-z0-9-]{10,}/g,\n },\n {\n label: 'stripe-secret-key',\n description: 'Stripe live secret/restricted key (sk_live_/rk_live_…)',\n regex: /\\b[rs]k_live_[A-Za-z0-9]{20,}/g,\n },\n {\n // Filed by buddy, who found REAL ones sitting in plaintext in their own\n // transcription DB. Their scrub deliberately runs the format axis ONLY, so a\n // prefixed secret we do not match is a secret nobody catches — a precise\n // prefix is the only route that helps them. Zero false-positive risk: the\n // literal `whsec_` does not occur by accident.\n label: 'stripe-webhook-secret',\n description: 'Stripe webhook signing secret (whsec_…)',\n regex: /\\bwhsec_[A-Za-z0-9+/=_-]{20,}/g,\n },\n {\n // Resend (re_…). Lookahead requires a digit in the body so we don't redact\n // long snake_case identifiers like re_compute_the_thing.\n label: 'resend-api-key',\n description: 'Resend API key (re_ + token)',\n regex: /\\bre_(?=[A-Za-z0-9_]*\\d)[A-Za-z0-9_]{24,}\\b/g,\n },\n {\n label: 'supabase-access-token',\n description: 'Supabase personal/management access token (sbp_ + 40 hex)',\n regex: /\\bsbp_[0-9a-f]{40}/g,\n },\n {\n label: 'supabase-secret-key',\n description: 'Supabase secret API key (sb_secret_…)',\n regex: /\\bsb_secret_[A-Za-z0-9_-]{20,}/g,\n },\n {\n // Used by every @broberg/* publish — the highest-value leak from a .env / commit history.\n label: 'npm-token',\n description: 'npm publish/automation token (npm_ + 36 base62)',\n regex: /\\bnpm_[A-Za-z0-9]{36}\\b/g,\n },\n {\n label: 'fly-api-token',\n description: 'Fly.io API token (FlyV1 fm2_… / fo1_…)',\n regex: /(?:FlyV1 fm2_[A-Za-z0-9+/=_-]{20,}|\\bfo1_[A-Za-z0-9_-]{20,})/g,\n },\n {\n // Also covers Turso DB/platform auth tokens AND Supabase anon/service_role\n // keys — both are JWTs (eyJ…), so the single JWT pattern catches them.\n label: 'jwt',\n description: 'JSON Web Token (eyJ…) — incl. Turso + Supabase service_role tokens',\n regex: /\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}/g,\n },\n {\n // genApiKey = randomBytes(24).hex → uk_ + exactly 48 lowercase hex.\n label: 'upmetrics-key',\n description: 'Upmetrics project key (uk_ + 48 hex)',\n regex: /\\buk_[0-9a-f]{48}/g,\n },\n {\n label: 'cardmem-key',\n description: 'Cardmem personal/incident/project key (pa_/pi_/pk_ + 64 hex)',\n regex: /\\bp[aik]_[A-Za-z0-9]{20,}/g,\n },\n {\n // cardmem inbox-webhook key — piw_ isn't matched by p[aik]_ above (3rd char 'w' ≠ '_').\n label: 'cardmem-webhook-key',\n description: 'Cardmem inbox-webhook key (piw_ + 64 hex)',\n regex: /\\bpiw_[0-9a-f]{64}/g,\n },\n {\n // VERIFIED WITH THE OWNER, 2026-08-28: `trail_` + exactly 64 LOWERCASE HEX.\n // trail generated 2000 keys through @broberg/apikey's generateKey('trail')\n // and counted the alphabet: 0-9a-f only, length 64-64, no `-`, no `_`. Source\n // is `${prefix}_${randomBytes(bytes).toString(\"hex\")}`, bytes=32, with a hard\n // floor of 16 — so even a future caller asking for the minimum yields 32 hex\n // chars, still above {20,}.\n //\n // Recorded because the QUESTION is easy to re-ask and the ANSWER is not: this\n // is one of the few patterns here assuming alphanumerics only, and had trail\n // used base64url (the more common one-liner) the `-` and `_` would break the\n // run, {20,} would never be satisfied, and the WHOLE key would pass through\n // unredacted — not partially, entirely. Measured rather than assumed, because\n // two sampled keys cannot tell hex from base64url that has not hit a `-` yet.\n label: 'trail-key',\n description: 'Trail personal API key (trail_ + 64 hex; verified 2026-08-28)',\n regex: /\\btrail_[A-Za-z0-9]{20,}/g,\n },\n {\n // Cronjobs API key (cronjobs.webhouse.net) — cj_ + randomBytes(32).base64url =\n // exactly 43 base64url chars (46 total). Prefix + fixed length = very low FP.\n // The UI's truncated cj_<8 chars>… preview is shorter than {43} → not matched.\n // Negative lookahead (not \\b) because base64url's `-` breaks a trailing \\b.\n label: 'cronjobs-api-key',\n description: 'Cronjobs API key (cj_ + 43 base64url)',\n regex: /\\bcj_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g,\n },\n {\n // HelpDesk API key (helpdesk.broberg.ai) — minted through @broberg/apikey,\n // which is OURS: generateKey(prefix, 32) → `${prefix}_${randomBytes(32).hex}`,\n // so exactly 64 LOWERCASE hex. Verified in packages/apikey/src/core.ts rather\n // than taken from the report. There is no checksum and no internal structure\n // to anchor on; prefix + fixed length + hex is everything there is, and it is\n // enough — `hd_live_` followed by exactly 64 hex does not occur by accident.\n //\n // THE LENGTH IS EXACT ON PURPOSE, and this is the half that needs defending\n // in six months. HelpDesk shows a PREVIEW — `hd_live_f4b4cf`, prefix + 6 hex\n // — deliberately, in their UI and their logs, so a human can see WHICH key\n // was revoked. It is not a secret. Redacting it breaks a value designed to be\n // read, and then the preview stops doing its job.\n //\n // So the tempting loosening — \"let us catch the shortened ones too\" — is the\n // one thing this pattern must never accept. `{64}` excludes the preview, and\n // a NAMED test says so, because by then nobody will remember why.\n //\n // The trailing lookahead covers BOTH cases: 65 hex is not a key, and neither\n // is 64 lowercase followed by an uppercase hex digit.\n //\n // NO PUBLISHABLE VARIANT, measured not assumed: HelpDesk is headless and its\n // console is a client of the same API using a session token. `grep -c \"hd_\"`\n // in the deployed bundle returns 0, so no key ever reaches a browser and the\n // Stripe pk_live_ trap has no counterpart here. Re-check if that changes.\n label: 'helpdesk-api-key',\n description: 'HelpDesk API key (hd_live_ + 64 hex, minted by @broberg/apikey)',\n regex: /\\bhd_live_[0-9a-f]{64}(?![0-9a-fA-F])/g,\n },\n {\n // UpCloud API token — `ucat_` + a ULID: exactly 26 Crockford base32 chars\n // (no I, L, O, U). Measured in three independent places, not inferred from\n // the masked example that filed it: UpCloud's API docs (create response),\n // UpCloud's own Go client fixture, and Kingfisher's rule — all three are in\n // test/f035-16.test.ts. The token's `id` is a separate UUID and is not a\n // secret.\n //\n // NO EXAMPLE VALUE IN THIS COMMENT, on purpose: the bundle keeps comments,\n // so a literal token here ships in dist/ and the scanner flags ITSELF — the\n // pre-commit gate's test caught exactly that on the first 0.10.0 tag.\n //\n // Case-insensitive like Kingfisher: Crockford decodes either case, and a\n // lowercased token is still a live credential. The trailing lookahead keeps\n // 27 chars from matching its first 26 — and leaves UpCloud's own\n // `ucat_[REDACTED]` marker alone.\n label: 'upcloud-api-token',\n description: 'UpCloud API token (ucat_ + 26 Crockford base32)',\n regex: /\\bucat_[0-9A-HJKMNP-TV-Z]{26}(?![0-9A-Za-z])/gi,\n },\n // ── F035.17 — the vault survey of 30 Sep 2026. Every shape below was MEASURED\n // on values already stored in cardmem's vault (the script read them server-side\n // and printed only prefix, length and charset), then checked against a source:\n // Kingfisher's public rule set for vendor tokens, our own minters for fleet keys.\n // No example value appears in these comments: the bundle keeps comments, and a\n // literal here would make the scanner flag its own dist/ (see upcloud above).\n {\n // Cloudflare's prefixed user API token. 3 in the vault, all 48 after the\n // prefix; Kingfisher allows 41-64. Runs before the context-only\n // cloudflare-api-token below so a prefixed one is named by its prefix.\n label: 'cloudflare-user-api-token',\n description: 'Cloudflare user API token (cfut_ + 41-64 base64url)',\n regex: /\\bcfut_[A-Za-z0-9_-]{41,64}(?![A-Za-z0-9_-])/g,\n },\n {\n // Runpod: rpa_ + 40 uppercase/digit + a 6-char mixed-case checksum tail\n // (Kingfisher runpod.1). 2 in the vault, both 46.\n label: 'runpod-api-key',\n description: 'Runpod API key (rpa_ + 46)',\n regex: /\\brpa_[A-Z0-9]{40}[A-Za-z0-9]{6}(?![A-Za-z0-9])/g,\n },\n {\n // Hugging Face user (hf_) and org (api_org_) tokens: 34 letters/digits.\n label: 'huggingface-token',\n description: 'Hugging Face token (hf_ / api_org_ + 34)',\n regex: /\\b(?:hf|api_org)_[A-Za-z0-9]{34}(?![A-Za-z0-9])/g,\n },\n {\n // Tailscale: tskey-<kind>-<id>-<secret>. The body carries its own dash, so\n // the class includes it. Measured 50-51 after the kind; Kingfisher's {20,36}\n // would stop short of those, so the ceiling is ours.\n label: 'tailscale-key',\n description: 'Tailscale key (tskey-<kind>-…)',\n regex: /\\btskey-[a-z]{3,10}-[A-Za-z0-9_-]{20,64}(?![A-Za-z0-9_-])/g,\n },\n {\n // Tigris secret access key: tsec_ + exactly 70 (Kingfisher tigris.2).\n label: 'tigris-secret-key',\n description: 'Tigris secret access key (tsec_ + 70)',\n regex: /\\btsec_[A-Za-z0-9_+-]{70}(?![A-Za-z0-9_+-])/g,\n },\n {\n // Slack APP-level token. slack-token above only knows xox*, so an xapp-\n // token went through untouched. Same label on purpose: the vault already\n // stores it as slack-token, and a second name for one provider helps nobody.\n label: 'slack-token',\n description: 'Slack app-level token (xapp-…)',\n regex: /\\bxapp-\\d{1,3}-[A-Za-z0-9]{8,15}-\\d{8,15}-[A-Za-z0-9]{20,70}(?![A-Za-z0-9])/g,\n },\n {\n // Aiven service password — the credential UpCloud's managed PostgreSQL hands\n // out. AVNS_ + 19. One in the vault; [Likely] fixed length, and a password\n // that silently stops matching is caught by the survey re-run, not by luck.\n label: 'aiven-service-password',\n description: 'Aiven service password (AVNS_ + 19) — UpCloud managed databases',\n regex: /\\bAVNS_[A-Za-z0-9_-]{19}(?![A-Za-z0-9_-])/g,\n },\n {\n // BID app key. OURS: broberg-id mints `bidk_${randomBytes(32).base64url}`,\n // so exactly 43 base64url — a fact about our minter, not a guess.\n label: 'bid-app-key',\n description: 'Broberg ID app key (bidk_ + 43 base64url)',\n regex: /\\bbidk_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g,\n },\n {\n // Fleet hex keys measured in the vault. Hex length is fixed by construction\n // (randomBytes(n).hex), so the survey's lengths are the minter's lengths.\n label: 'beacon-token',\n description: 'Beacon token (bcn_ + 64 hex)',\n regex: /\\bbcn_[0-9a-f]{64}(?![0-9a-fA-F])/g,\n },\n {\n label: 'mailworker-admin-key',\n description: 'mailworker admin key (mw_ + 64 hex)',\n regex: /\\bmw_[0-9a-f]{64}(?![0-9a-fA-F])/g,\n },\n {\n label: 'upmetrics-remediation-token',\n description: 'Upmetrics remediation token (umrt_ + 48 hex)',\n regex: /\\bumrt_[0-9a-f]{48}(?![0-9a-fA-F])/g,\n },\n {\n // A database URL with a password in it. Matches ONLY the password (the\n // lookbehind pins scheme://user: before it, the lookahead pins @ after), so\n // a redacted URL still says which database it points at.\n //\n // The password must contain a digit or be 12+ characters — so the\n // `user:password@` and `user:pass@` placeholders every README carries stay\n // readable. A real generated DB password clears that bar trivially.\n label: 'connection-string',\n description: 'Password inside a database connection URL',\n regex: /(?<=\\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\\+srv)?|rediss?|amqps?):\\/\\/[^\\s:@/]+:)(?=[^\\s@/]*\\d|[^\\s@/]{12})[^\\s@/]+(?=@)/gi,\n },\n {\n // randomBytes(32).hex → wh_ + 64 lowercase hex (67 chars total).\n // NOTE: unlike cj_ and hd_live_ above, this one has no trailing lookahead, so\n // wh_ + 65 hex matches its first 64. Not a leak (the value is still redacted)\n // and not changed here — flagged rather than silently altered in a card about\n // something else.\n label: 'cms-access-token',\n description: 'webhouse.app CMS access token (wh_ + 64 hex)',\n regex: /\\bwh_[0-9a-f]{64}/g,\n },\n {\n // Cloudflare API token (R2 / DNS management) — 40 base64url chars, NO prefix.\n // A bare {40} would false-positive broadly, so this is CONTEXT-ONLY: it only\n // fires next to a cf/cloudflare-api-token-named field. Runs before\n // labeled-hex-secret so a hex-valued CF token is attributed correctly.\n label: 'cloudflare-api-token',\n description: 'Cloudflare API token (cf/cloudflare-api-token field + 40 base64url)',\n regex: /\\b(?:cf|cloudflare)_?api_?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{40}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Mistral API key — prefix-less ~32 base62 (Christian-confirmed sample). A bare\n // [A-Za-z0-9]{32} would FP on every ID/hash, so CONTEXT-ONLY: anchored on a\n // mistral-(api-)key/token-named field. Runs before labeled-hex for attribution.\n label: 'mistral-api-key',\n description: 'Mistral API key (mistral-(api-)key/token field + 24+ base62)',\n regex: /\\bmistral(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{24,}(?![A-Za-z0-9])/gi,\n },\n {\n // DeepSeek — field-anchored fallback for any DEEPSEEK_API_KEY/TOKEN value that\n // doesn't fit the canonical sk-+hex shape (mirrors the Mistral context-only\n // approach). The field name is the signal → near-zero false positives. The\n // sk-+hex format pattern above already attributes the canonical shape; this\n // backstops a format change or an opaque token.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (deepseek-(api-)key/token field + 20+ token)',\n regex: /\\bdeepseek(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Vimeo personal access token — ~32 lowercase hex, no prefix (sanne). A bare\n // hex32 would FP massively (MD5/UUID), so CONTEXT-ONLY: anchored on a\n // vimeo-(access-)token-named field.\n label: 'vimeo-access-token',\n description: 'Vimeo access token (vimeo-(access-)token field + 20+ base62)',\n regex: /\\bvimeo(?:[_-]?access)?[_-]?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{20,}(?![A-Za-z0-9])/gi,\n },\n {\n // Context-based catch for prefix-less high-entropy service secrets\n // (CMS_JWT_SECRET, revalidateSecret, fleet openssl-rand-hex secrets): a 40+\n // hex value assigned to a field whose name contains\n // secret/token/password/api-key. The name requirement keeps the\n // false-positive rate near zero (a bare 40/64-hex would hit shas/hashes).\n label: 'labeled-hex-secret',\n description: 'A 40+ hex value assigned to a secret/token/password/api-key-named field',\n regex: /\\b[A-Za-z0-9_-]*(?:secret|token|password|api[_-]?key)\\b\\s*[:=]\\s*[\"'`]?[0-9a-f]{40,}/gi,\n },\n {\n // Discord bot token — three base64url segments. Anchored both sides so it\n // can't partial-match a longer dotted string.\n label: 'discord-bot-token',\n description: 'Discord bot token (3 base64url segments)',\n regex: /(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{24,26}\\.[A-Za-z0-9_-]{6}\\.[A-Za-z0-9_-]{27,40}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'discord-mfa-token',\n description: 'Discord MFA token (mfa. + 84 chars)',\n regex: /\\bmfa\\.[A-Za-z0-9_-]{84}\\b/g,\n },\n {\n // Cloudflare Turnstile PROD secret (sanne, verified 2/2) — 0x4 + 6×A prefix,\n // then 26 base64url (35 total). The 24-char SITE key + 1x/2x/3x TEST keys are\n // intentionally NOT matched (the {26} length gate misses them) so a public\n // key is never redacted.\n label: 'cloudflare-turnstile-secret',\n description: 'Cloudflare Turnstile secret key (0x4AAAAAA + 26 base64url, 35 total)',\n regex: /0x4AAAAAA[A-Za-z0-9_-]{26}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'cloudflare-global-key',\n description: 'Cloudflare global API key (37-hex)',\n regex: /\\b[0-9a-f]{37}\\b/g,\n },\n {\n // LAST on purpose: this is the only unprefixed shape in the list, so every\n // anchored pattern above must get first refusal.\n //\n // Philips Hue v2 application key — 40 chars of [A-Za-z0-9-] with NO prefix,\n // so there is nothing to anchor on. The negative lookahead is load-bearing,\n // not decoration: a bare [A-Za-z0-9-]{40} also matches a GIT COMMIT SHA, and\n // telemetry/error output is full of those. A redactor that eats commit\n // hashes gets switched off within a week, after which it protects nothing.\n //\n // F035.10 — THAT SENTENCE CAME TRUE ABOUT THIS VERY PATTERN. It was the only\n // prefix-less pattern here matching on ENTROPY ALONE, and base64 is\n // mixed-case alphanumeric, so it fired inside npm integrity digests:\n //\n // resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/…}\n // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ matched\n //\n // 33 hits in components' own pnpm-lock.yaml, 32 of them digests. (`-` is in\n // the class but is not a word character, so \\b anchors happily mid-digest.)\n // Reported by trail, whose gate then could not commit a lockfile change —\n // i.e. no dependency update at all. A gate nobody can satisfy is a gate\n // someone switches off, which costs more than the hole it closed.\n //\n // It is now CONTEXT-ONLY like every other prefix-less secret in this file\n // (cloudflare-api-token, mistral-api-key, vimeo-access-token,\n // labeled-hex-secret): the field name is the signal, not the randomness.\n // Deliberately given up: a bare 40-char Hue key in prose with no field name.\n // See the README's \"Deliberately NOT detected\" — do not remove the anchor to\n // \"fix\" that; a pattern that cannot tell a key from a checksum is worse than\n // no pattern. (Original pattern contributed by beacon, F035.7.)\n label: 'hue-application-key',\n description: 'Philips Hue application key (hue/bridge-named field + 40 chars)',\n // The `[\"'`]?` BEFORE the separator is not decoration: a Hue key most often\n // arrives as JSON — {\"hue_application_key\": \"…\"} — and the four older\n // context-only patterns in this file all omit it, so they miss the quoted\n // form. Noted rather than silently changed there; that is its own card.\n regex: /\\b(?:hue|bridge)[_-]?(?:application[_-]?key|username|user|key)\\b[\"'`]?\\s*[:=]\\s*[\"'`]?[A-Za-z0-9-]{40}(?![A-Za-z0-9-])/gi,\n },\n];\n\n/**\n * Shapes that identify a secret by its VALUE ALONE, with no field name.\n *\n * These are deliberately NOT in `SECRET_PATTERNS`, because a scanner runs over\n * arbitrary text where an unanchored entropy match is a disaster: the Hue shape\n * (40 mixed-case alphanumerics) is also what a 40-character window inside an npm\n * `sha512-…` digest looks like, which is how 0.5.0 blocked every lockfile commit\n * in every repo running the gate (F035.10).\n *\n * `classify()` is a different question, and that is the whole reason this list\n * exists. Its caller has ALREADY asserted the string is a secret — they pasted\n * it into a vault field and asked \"what kind?\" — so there is no checksum to\n * confuse it with and no text to corrupt. Answering \"unknown\" there costs a\n * consumer a working feature (cardmem's Secrets Vault type-detection) for a\n * false-positive risk that only exists when scanning.\n *\n * Same value, two questions: \"is there a secret in this text?\" and \"what kind of\n * secret is this?\" They do not deserve the same evidence bar.\n */\n// ONE source for the value-only rule, two anchorings derived from it. Written\n// twice by hand, the two forms drift the first time anyone tunes one of them.\n//\n// The lookaheads are the discriminator: 40 chars of [A-Za-z0-9-] that contain\n// BOTH a lower- and an upper-case letter. A git SHA (40 lowercase hex) therefore\n// never matches, which is the collision that would otherwise dominate.\nconst HUE_KEY_BODY = String.raw`(?=[A-Za-z0-9-]*[a-z])(?=[A-Za-z0-9-]*[A-Z])[A-Za-z0-9-]{40}`;\n\nconst VALUE_ONLY: ReadonlyArray<SecretPattern> = [\n {\n label: 'hue-application-key',\n description: 'Philips Hue application key (40 chars, no prefix)',\n // Anchored: `classify` is handed ONE value and asks what it is.\n regex: new RegExp(String.raw`^(?=[A-Za-z0-9-]{40}$)${HUE_KEY_BODY}$`),\n },\n];\n\n// Unanchored: `redactSecrets({ valueOnly: true })` runs over free text, where the\n// key sits inside a sentence. Same body, word-bounded.\n//\n// THIS IS THE ONE THAT EATS PROSE, which is why it is opt-in. Measured over two\n// trees with the identical pattern (F035.12):\n//\n// lockfiles everything else\n// components 1 file, 33 hits 15 files, 35 hits class names, hyphenated prose\n// beacon 8 hits 0 prose 12 deliberate fixtures\n//\n// The charset includes the HYPHEN, so a 40-character run of kebab-case slug or\n// hyphenated English matches — `gate-the-submit-button-on-status-not-on-`,\n// `WebStandardStreamableHTTPServerTransport`. No file-level exemption reaches\n// that; it is prose, not lockfiles.\n//\n// So the right default depends on the CALL SITE, not on the quality of the\n// pattern. beacon redacts logs: a false positive costs a masked word, a false\n// negative costs their bridge key. Our commit gate blocks commits: a false\n// positive costs a developer a blocked README. Same pattern, opposite cost —\n// which is what makes it a parameter rather than a fix.\nconst VALUE_ONLY_UNANCHORED: ReadonlyArray<SecretPattern> = [\n {\n label: 'hue-application-key',\n description: 'Philips Hue application key (40 chars, no prefix)',\n regex: new RegExp(String.raw`\\b${HUE_KEY_BODY}\\b`, 'g'),\n },\n];\n\n/**\n * Every pattern this package matches, for callers that want to inspect or audit\n * the roster.\n *\n * THE EXPORTED REGEXES ARE NOT GLOBAL, and that is a deliberate difference from\n * the ones used internally (F035.12). A `/g` regex carries `lastIndex` BETWEEN\n * CALLS, so the obvious way to inspect one lies. Measured on published 0.6.0:\n *\n * p.regex.test(sample) -> true lastIndex now 20\n * p.regex.test(sample) -> false <- same input, different answer\n *\n * Anyone measuring our own patterns — which is exactly what a consumer auditing\n * a redaction does — got alternating answers and no indication why. The copies\n * below are stateless, so testing them is idempotent.\n *\n * VALUE_ONLY_PATTERNS is exported for the same reason it exists: `classify` can\n * return a label that is in NEITHER list if only one of them is published, and a\n * roster that under-describes what the package detects is worse than no roster.\n */\n/** A global copy, for the replace pass. A caller's `extraPatterns` regex may\n * arrive without `/g`, in which case `String.replace` would substitute only the\n * FIRST occurrence and leave the rest in the text. */\nconst asGlobal = (re: RegExp): RegExp =>\n re.flags.includes('g') ? re : new RegExp(re.source, `${re.flags}g`);\n\nconst withoutGlobal = (list: ReadonlyArray<SecretPattern>): ReadonlyArray<SecretPattern> =>\n Object.freeze(\n list.map((p) =>\n Object.freeze({ ...p, regex: new RegExp(p.regex.source, p.regex.flags.replace('g', '')) }),\n ),\n );\n\n/** Every format pattern, ordered most-specific → least, as STATELESS copies —\n * safe to `.test()` repeatedly. See `withoutGlobal` above for what shared\n * `lastIndex` did to anyone auditing our own patterns before 0.7.0. */\nexport const SECRET_PATTERNS: ReadonlyArray<SecretPattern> = withoutGlobal(PATTERNS);\n\n/** The value-only axis — shapes identified from the VALUE ALONE, with no field\n * name beside them. Opt-in at the call site (`{ valueOnly: true }`); see the\n * option's own documentation for why the default is off. */\nexport const VALUE_ONLY_PATTERNS: ReadonlyArray<SecretPattern> = withoutGlobal(VALUE_ONLY);\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 * `true` reads PROSE: `Adgangskode: hunter2`. `'code'` reads SOURCE CODE,\n * where the only hardcoded secret is a quoted literal assigned to a\n * credential-named identifier: `newPassword: 'abcdefgh'`. See\n * CODE_ANNOUNCED_SECRET for why the two cannot share one rule (F035.19).\n */\n announced?: boolean | 'code';\n\n /**\n * Also apply the VALUE-ONLY axis — shapes identified from the value alone,\n * with no field name beside them (today: the Philips Hue application key).\n *\n * OFF BY DEFAULT, and the reason is not that the pattern is bad (F035.12).\n *\n * cardmem's rule, which settled the design: **the decision to accept a weak\n * signal belongs to whoever can RENDER the uncertainty. A surface that cannot\n * show \"guess\" must not be given guesses.** Their vault shows a credential's\n * type as a chip beside the name, with nowhere to say \"low confidence\", so a\n * guess they accepted would silently become an assertion the owner acts on.\n * They take the empty answer instead.\n *\n * beacon's calculus is the opposite and equally correct: they redact logs, so\n * a false positive costs a masked word and a false negative costs their bridge\n * key. Their two call paths — masking each string separately, and passing a\n * bridge error message as free text — structurally cannot supply a field name,\n * so the field-anchored rule can never fire for them.\n *\n * MEASURED, same pattern, two corpora, opposite answers:\n *\n * components 2 lockfiles (39 hits) + 9 other files (20 hits) — class names,\n * documentation, `WebStandardStreamableHTTPServerTransport`\n * beacon 8 lockfile hits, 0 prose, 12 deliberate fixtures\n *\n * So there is no single correct default, which is exactly what makes this a\n * parameter rather than a fix. An OPTION rather than a `confidence` field on\n * the result, deliberately: a field is ignorable by destructuring the label,\n * and a caller who did not ask for weak guesses must not be able to receive\n * one by accident. The parameter name is the warning, at the one place it\n * cannot be skipped.\n */\n valueOnly?: boolean;\n}\n\n/** Marker label for a secret detected by its announcing label rather than shape. */\nexport const ANNOUNCED_LABEL = 'announced-secret';\n\n/**\n * Label + separator + value. The label list is deliberately short and concrete;\n * this is not a general \"looks like config\" detector.\n *\n * WHY THIS IS OPT-IN, MEASURED RATHER THAN GUESSED. Over this repo on\n * 2026-08-14 — 548 tracked files, 544 readable as text, containing essentially\n * no real secrets — this exact regex matched **97 times**, and every one was\n * noise. Per label: `secret` 61, `api key` 33, `password` 4, and every Danish\n * label 0. So 94 of the 97 are the two words that are also ordinary IDENTIFIERS\n * in source code (`secret: config.secret`, `apiKey: Record<…>`).\n *\n * That is the real finding, and it is sharper than \"the pattern is noisy\": its\n * precision depends entirely on WHAT IS BEING SCANNED. In an inbound mail body\n * — buddy's actual case — `Adgangskode:` is a strong signal. In a TypeScript\n * file it is a variable name. **The package cannot know which corpus it is\n * looking at; only the caller can.** So the caller makes the decision, and the\n * default cannot be on. (This is the opposite of this repo's usual defaults-ON\n * stance — webpush F067.1, lens-engine F065 — and the numbers above are why.)\n *\n * A broader label+separator+value pattern measured 305 on the same corpus, and\n * refining it only reached 202 — no amount of tuning makes a generic version\n * safe. A template/env-reference guard (`${FOO}`, `<your-key>`) was written and\n * then dropped: it changed the count by exactly 0, because the noise here is\n * identifiers, not templates.\n *\n * The value must not already be a redaction marker, so this can run AFTER the\n * format pass without flattening its more specific attribution.\n *\n * NOT IN THE LIST, AND DELIBERATELY (v0.4.0): bare `kode`. **In Danish, `kode`\n * mostly means SOURCE CODE** — the credential words are `kodeord` and\n * `adgangskode`, both still matched. Until 0.4.0 the bare form was included and\n * fired on ordinary technical prose; measured by buddy over 82,662 lines of real\n * Danish transcription: \"Det er min kode: se linje 40\" · \"Kode: const x = 1\" ·\n * \"Merge-kode: konflikten er løst\" · \"QR-kode: scan den\".\n *\n * And the behaviour was ARBITRARY, which is the part that settled it: `\\b` meant\n * `Landekode:` / `Postkode:` / `Fejlkode:` never matched (no word boundary inside\n * the word) while `QR-kode:` did (a hyphen IS one). Whether a compound was\n * flagged came down to whether someone happened to type a hyphen.\n *\n * THE COST, stated rather than hidden: `Her er min kode: hunter2` is no longer\n * detected, and that is a real Danish way to announce a password. Deliberate — a\n * token that means \"source code\" half the time is noise in every corpus, not\n * just buddy's. If you need it back, file it; do not re-add it locally.\n */\nconst ANNOUNCED_SECRET =\n /(\\b(?:adgangskode|kodeord|hemmelighed|password|passwd|api[ -]?key|apinøgle|secret|pwd)[\"'`\\]]?\\s*[:=]\\s*)(\\S+)/gi;\n\n/**\n * Delimiters that may WRAP a value without being part of it.\n *\n * D1 (F035.12) — `(\\S+)` swallowed these INTO the replaced span, so redacting\n * DELETED them. Measured on published 0.6.0:\n *\n * config(password='hunter2') -> config(password=[REDACTED:announced-secret]\n * Kodeord: hunter2, og derefter -> Kodeord: [REDACTED:announced-secret] og derefter\n * brug `password: hunter2` -> brug `password: [REDACTED:announced-secret]\n *\n * A closing paren, a comma and a backtick, gone. Anyone re-redacting a corpus\n * gets syntactically broken text back — and buddy holds 41k texts to do exactly\n * that. It also inflated every length measurement taken on candidates.\n *\n * THE LIST IS DELIBERATELY NARROW, and what is ABSENT is the load-bearing part:\n * `!` `?` `.` are NOT here. `Sommer2026!` is a real measured password and its\n * final character must go INTO the redaction, not survive it. A trailing quote\n * or bracket is structure; a trailing bang is content. Guessing wrong in the\n * first direction corrupts a corpus; guessing wrong in the second leaks one\n * character of a real secret, so the list only grows on evidence.\n */\nconst LEADING_DELIMS = /^[([{\"'`]+/;\nconst TRAILING_DELIMS = /[)\\]},;\"'`]+$/;\n\n/**\n * Is this candidate plausibly a secret VALUE, or just the next word in a\n * sentence?\n *\n * F035.11 — WITHOUT THIS, THE AXIS EATS PROSE. The pattern above is\n * label + separator + `\\S+`, and in Danish and English «secret:» is ordinary\n * text. Measured on the published 0.5.1:\n *\n * 'Set som secret: gh secret set MYPAT'\n * -> 'Set som secret: [REDACTED:announced-secret] secret set MYPAT'\n * 'jeg siger det aldrig — secret: ALDRIG'\n * -> 'jeg siger det aldrig — secret: [REDACTED:announced-secret]'\n *\n * THE RULE IS DERIVED FROM buddy's NUMBERS, not chosen. Over 40,369 rows of real\n * fleet prose (23,801 intercom messages + 16,568 conversation turns) they found\n * 49 unique candidates after an announcing label. **35 of them were prose, and\n * every one of those 35 was under 16 characters with no digit** — \"kun\", \"jeg\",\n * \"gh\", \"aldrig\", \"ALDRIG\", \"»\". Zero were hex-like. And the axis caught ZERO\n * real secrets that the format rules had not already caught.\n *\n * So: a candidate with no digit, shorter than 16 characters, is prose.\n *\n * THE COST, STATED RATHER THAN HIDDEN, in the house style of the `kode` removal\n * above: `Adgangskode: correcthorse` is no longer detected. That is a real way to\n * write a real password. It is accepted deliberately — an over-broad redaction\n * destroys a corpus as effectively as a narrow one leaks it (buddy's framing),\n * and this axis is the one running over human prose. A value with any digit, or\n * any value of real key length, is unaffected: `hunter2` still goes.\n *\n * The judgement is on the CANDIDATE, never on the label. Narrowing the label list\n * would leave the same greedy `\\S+` behind every label that remained.\n */\nfunction plausibleSecretValue(candidate: string): boolean {\n return /\\d/.test(candidate) || candidate.length >= 16;\n}\n\n/**\n * The announced axis for SOURCE CODE — `{ announced: 'code' }` (F035.19).\n *\n * Filed by pitch: GitGuardian flagged «Generic Password» on\n * `JSON.stringify({ currentPassword: 'a', newPassword: 'abcdefgh' })`, and the\n * prose rule above returned `findings: []` on that exact line — while flagging\n * `apiKey: nanoid(32)` and `apiKey: process.env.RESEND_API_KEY`. Both halves are\n * the prose rule being right about prose:\n *\n * · `\\bpassword` needs a word boundary, and `newPassword` has none;\n * · a digit-free value under 16 chars is a WORD in prose (buddy: 35 of 35);\n * · in prose, `\\S+` after the label is the value. In code it is an expression.\n *\n * So code gets its own rule, and QUOTED is the whole of it: an unquoted value\n * is a call, a variable or an env reference, never a hardcoded secret. The\n * label may be any identifier CONTAINING a credential word (`DB_PASSWORD`,\n * `clientSecret`), optionally quoted as an object key. `==`, `===` and `=>`\n * need no rule of their own: what follows the first `=` is not a quote.\n *\n * MEASURED 1/10 2026 over 2,848 tracked TS/JS files in 13 fleet repos: 315\n * hits, 280 in test/spec/fixture files — `apiKey: \"re_x\"`, `password:\n * \"hunter2\"`, precisely GitGuardian's class, and correct for a pre-push gate.\n * The noise outside tests had five shapes, each refused by name in\n * codeCandidateOk or by the lookarounds here: a ternary branch\n * (`? \"wrong_password\" : \"enable_failed\"`), a type union (`type X = 'a' | 'b'`),\n * a descriptor-named label (`secretPath: \".lens/x\"`), an i18n label whose value\n * is the WORD (`password: \"Adgangskode\"`), an error code equal to its key\n * (`PASSWORD_TOO_SHORT: \"password_too_short\"`).\n *\n * NOT CAUGHT, deliberately: `.env` files (their literals are unquoted — use the\n * prose rule there), comparisons (`password === 'x'`), and values under 4\n * characters (`currentPassword: 'a'`, pitch's own threshold).\n */\nconst CREDENTIAL_WORD = '(?:password|passwd|pwd|secret|api_?key|adgangskode|kodeord)';\n//\n// LINEAR BY CONSTRUCTION, and the first draft was not: `[\\w$]*password[\\w$]*`\n// backtracks quadratically inside one long identifier — 'password' × 50,000\n// did not finish in two minutes. So the identifier is taken WHOLE, as an atomic\n// group (`(?=(x+))\\3` — JS has no possessive quantifier), and the credential\n// word is checked in codeCandidateOk. The lookbehinds are bounded for the same\n// reason: an unbounded `\\s*` inside a lookbehind rescans every whitespace run.\nconst CODE_ANNOUNCED_SECRET = new RegExp(\n '(?<![\\\\w$])(?<!\\\\?\\\\s{0,3}[\"\\'`]?)(?<!\\\\btype\\\\s{1,3})' +\n '(([\"\\'`]?)(?=([\\\\w$]+))\\\\3\\\\2\\\\s*[:=]\\\\s*)' +\n '([\"\\'`])([^\"\\'`\\\\s]*)\\\\4(?!\\\\s*[|&])',\n 'g',\n);\nconst CODE_DESCRIPTOR_SUFFIX =\n /^[_$-]*(?:path|name|id|file|url|uri|label|field|header|env|var|ref|type|hint|placeholder|policy|pattern|length|len|min|max|count|mode|provider|prompt|text|title|message|error|status)s?$/i;\nconst CREDENTIAL_WORD_ONLY = new RegExp('^' + CREDENTIAL_WORD + '$', 'i');\nconst CREDENTIAL_WORD_ANY = new RegExp(CREDENTIAL_WORD, 'gi');\nconst squash = (s: string): string => s.toLowerCase().replace(/[-_\\s]/g, '');\n\nfunction codeCandidateOk(label: string, value: string): boolean {\n if (value.length < 4 || value.includes('${') || value.includes(MARKER_PREFIX)) return false;\n // The LAST credential word decides what the identifier names: `secretPath`\n // is a path, `pathSecret` is a secret. matchAll, not a `(?!.*word)`\n // lookahead — that rescans the rest of the label from every position.\n let end = -1;\n for (const m of label.matchAll(CREDENTIAL_WORD_ANY)) end = m.index + m[0].length;\n if (end < 0) return false;\n if (CODE_DESCRIPTOR_SUFFIX.test(label.slice(end))) return false;\n if (CREDENTIAL_WORD_ONLY.test(value.replace(/[\\s_-]/g, ''))) return false;\n if (squash(value) === squash(label)) return false;\n return true;\n}\n\n\n/** Replacement marker for a redacted secret. */\nexport const redactionMarker = (label: string): string => `[REDACTED:${label}]`;\n\n/** The opening of every marker. Derived from redactionMarker rather than typed\n * again, so the two cannot drift apart — a hand-written '[REDACTED:' here would\n * keep matching after someone changed the marker format. */\nconst MARKER_PREFIX = redactionMarker('').slice(0, -1);\n\nfunction patternsFor(opts?: RedactOptions): SecretPattern[] {\n return opts?.extraPatterns && opts.extraPatterns.length > 0\n ? [...PATTERNS, ...opts.extraPatterns]\n : 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 // VALUE-ONLY runs between format and announced: after the shapes that are safe\n // everywhere, before the label-driven axis, and only when the caller asked.\n if (opts?.valueOnly) {\n for (const p of VALUE_ONLY_UNANCHORED) {\n let count = 0;\n const next = redacted.replace(asGlobal(p.regex), (match: string) => {\n if (match.includes(MARKER_PREFIX)) return match;\n count++;\n return redactionMarker(p.label);\n });\n if (count > 0) {\n redacted = next;\n findings.push({ label: p.label, count, confidence: 'format' });\n }\n }\n }\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 === 'code') {\n let count = 0;\n redacted = redacted.replace(\n CODE_ANNOUNCED_SECRET,\n (match: string, prefix: string, _q: string, label: string, quote: string, value: string) => {\n if (!codeCandidateOk(label, value)) return match;\n count++;\n return prefix + quote + redactionMarker(ANNOUNCED_LABEL) + quote;\n },\n );\n if (count > 0) findings.push({ label: ANNOUNCED_LABEL, count, confidence: 'announced' });\n } else if (opts?.announced) {\n let count = 0;\n const redactedAnnounced = redacted.replace(\n ANNOUNCED_SECRET,\n (match: string, prefix: string, value: string) => {\n // ALREADY REDACTED -> leave it alone, so the format pass keeps its\n // specific attribution. The old guard was a `(?!\\[REDACTED:)` lookahead\n // in the regex, which only fired when the marker was the FIRST character\n // of the value — so a QUOTED key was flattened (measured on 0.6.0):\n //\n // API key: \"sk-ant-api03-…\" -> API key: [REDACTED:announced-secret]\n // findings: anthropic-api-key, announced-secret\n //\n // The redacted text stopped saying WHICH kind of key it had been. This\n // tests for the marker ANYWHERE in the value rather than listing the\n // delimiters that could precede it — a list would have missed brackets,\n // parentheses and whatever nobody thought of next.\n if (value.includes(MARKER_PREFIX)) return match;\n\n // Split the wrapping delimiters off before judging AND before replacing,\n // so they survive into the output (D1). The judgement is on the CORE:\n // `'hunter2'` and `hunter2` are the same candidate.\n const lead = LEADING_DELIMS.exec(value)?.[0] ?? '';\n const trail = TRAILING_DELIMS.exec(value.slice(lead.length))?.[0] ?? '';\n const core = value.slice(lead.length, value.length - trail.length);\n\n // An implausible candidate is left EXACTLY as it was — byte for byte,\n // including the label. `match` rather than a rebuild on purpose: the\n // pieces are equal today and stop being equal the moment anyone adds a\n // group. Returning what was actually matched cannot drift.\n if (!core || !plausibleSecretValue(core)) return match;\n count++;\n return prefix + lead + redactionMarker(ANNOUNCED_LABEL) + trail;\n },\n );\n if (count > 0) {\n redacted = redactedAnnounced;\n findings.push({ label: ANNOUNCED_LABEL, count, confidence: 'announced' });\n }\n }\n return { redacted, findings, scanned };\n}\n\n/**\n * True if `text` announces a credential by label — `Adgangskode: hunter2` —\n * without building a redaction. Cheap enough to run on every inbound message.\n *\n * This exists because for untrusted inbound text heading to a model, the right\n * response is often to REFUSE rather than redact: a false positive costs a\n * slightly worse classification, a false negative costs a leak. That use needs a\n * boolean, not a redactor. (buddy's reasoning, F035.8.)\n */\nexport function hasAnnouncedSecret(text: string, mode: 'prose' | 'code' = 'prose'): boolean {\n if (!text) return false;\n if (mode === 'code') {\n // Same predicate as the redactor (F035.19), for the invariant below.\n for (const m of text.matchAll(CODE_ANNOUNCED_SECRET)) {\n if (codeCandidateOk(m[3] ?? '', m[5] ?? '')) return true;\n }\n return false;\n }\n // ROUTED THROUGH THE SAME PREDICATE as redactSecrets on purpose. A bare\n // `.test()` here would answer \"yes\" for a string redactSecrets leaves\n // untouched, and the two would disagree about the same input — which is worse\n // than either answer, because a caller can only ever ask one of them.\n //\n // THE INVARIANT IS NARROWER THAN \"THEY AGREE\", and the narrower one is what is\n // true (F035.12). They answer different questions and their FINDINGS can\n // legitimately differ:\n //\n // hasAnnouncedSecret('password: AKIA…') -> true\n // redactSecrets(same).findings -> [aws-access-key-id]\n //\n // Not a bug: the format pass runs FIRST and recognised the value, so it holds\n // the better attribution and the announced pass correctly declines to flatten\n // it. An earlier comment here claimed the two simply agree; that claim was\n // broader than the code, which is the shape this repo keeps naming.\n //\n // What IS guaranteed, and what a caller can rely on:\n //\n // hasAnnouncedSecret(t) === true => redactSecrets(t, { announced: true })\n // changes the text\n //\n // i.e. the boolean never promises a redaction that does not happen. It says\n // nothing about WHICH label does the work. Asserted in the suite over both the\n // agreeing and the disagreeing cases, so the weaker claim cannot silently\n // become the stronger one again.\n ANNOUNCED_SECRET.lastIndex = 0;\n for (let m = ANNOUNCED_SECRET.exec(text); m !== null; m = ANNOUNCED_SECRET.exec(text)) {\n // Same delimiter-stripping as the redactor, for the same reason: the two\n // must agree about what the CANDIDATE is, or they disagree about the input.\n const value = m[2] ?? '';\n if (value.includes(MARKER_PREFIX)) continue;\n const lead = LEADING_DELIMS.exec(value)?.[0] ?? '';\n const trail = TRAILING_DELIMS.exec(value.slice(lead.length))?.[0] ?? '';\n const core = value.slice(lead.length, value.length - trail.length);\n if (core && plausibleSecretValue(core)) {\n ANNOUNCED_SECRET.lastIndex = 0;\n return true;\n }\n }\n return false;\n}\n\n/**\n * True if `text` contains at least one detectable secret. Honours\n * `opts.announced` — a caller who asks for the announced axis and is told\n * `false` must be able to believe it.\n */\nexport function hasSecret(text: string, opts?: RedactOptions): boolean {\n if (opts?.announced && hasAnnouncedSecret(text, opts.announced === 'code' ? 'code' : 'prose')) {\n return true;\n }\n return patternsFor(opts).some((p) => {\n p.regex.lastIndex = 0;\n return p.regex.test(text);\n });\n}\n\nexport interface ClassifyResult {\n /** the matching pattern's stable label (e.g. `openai-api-key`) */\n label: string;\n /** the matching pattern's human description (e.g. `OpenAI API key (sk-… / sk-proj-…)`) */\n description: string;\n}\n\n/**\n * Classify a SINGLE pasted token — the INVERSE of redaction. Returns the first\n * (most-specific) pattern the value matches, or `null`. Backs a \"paste a key →\n * detect its type\" UI (cardmem F214 Secrets Vault) so every consumer shares the\n * same classification, not just the same redaction.\n *\n * First-match-wins over the ordered `SECRET_PATTERNS`, so `sk-ant-…` classifies\n * as `anthropic-api-key`, never the generic `openai-api-key`. Field-anchored\n * context-only patterns (mistral / vimeo / cloudflare-api-token /\n * labeled-hex-secret / deepseek-fallback) only match when the pasted value\n * includes their `NAME=` context; a bare provider token classifies via its\n * prefix pattern, and a prefix-less bare token (e.g. a raw Mistral key) is\n * genuinely unidentifiable → `null`. `opts.extraPatterns` run AFTER the\n * canonical set (canonical attribution wins). Input is trimmed; empty /\n * whitespace-only → `null`.\n */\nexport function classify(value: string, opts?: RedactOptions): ClassifyResult | null {\n if (!value) return null;\n const v = value.trim();\n if (!v) return null;\n for (const p of patternsFor(opts)) {\n p.regex.lastIndex = 0;\n if (p.regex.test(v)) return { label: p.label, description: p.description };\n }\n // Only after every anchored pattern has declined, and only when the caller\n // OPTED IN: shapes named from the value alone. Anchored to the WHOLE string\n // (^…$), so this can never fire on a fragment of a longer value.\n //\n // The gate is new in 0.6.1. Before it, `classify` consulted this list\n // unconditionally, so a caller could receive `hue-application-key` for a\n // 40-character id it had never heard of — a guess arriving in the same shape\n // as a certainty, with nothing in the return value marking the difference.\n if (!opts?.valueOnly) return null;\n for (const p of VALUE_ONLY) {\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.
|
|
3
|
+
"version": "0.12.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",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"scripts": {
|
|
24
24
|
"build": "tsup",
|
|
25
|
-
"test": "vitest run && node ../../scripts/mutations-if-changed.mjs test/mutations.mjs",
|
|
25
|
+
"test": "vitest run --reporter=default --reporter=json --outputFile.json=.vitest-report/results.json && node ../../scripts/mutations-if-changed.mjs test/mutations.mjs",
|
|
26
26
|
"typecheck": "tsc --noEmit"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|