@broberg/secret-scan 0.1.7 → 0.2.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 +79 -4
- package/dist/index.cjs +37 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +36 -2
- package/dist/index.d.ts +36 -2
- package/dist/index.js +36 -2
- package/dist/index.js.map +1 -1
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ import { redactSecrets, hasSecret } from "@broberg/secret-scan";
|
|
|
20
20
|
|
|
21
21
|
const { redacted, findings } = redactSecrets("the key is sk-ant-api03-… use it");
|
|
22
22
|
// redacted → "the key is [REDACTED:anthropic-api-key] use it"
|
|
23
|
-
// findings → [{ label: "anthropic-api-key", count: 1 }]
|
|
23
|
+
// findings → [{ label: "anthropic-api-key", count: 1, confidence: "format" }]
|
|
24
24
|
|
|
25
25
|
hasSecret("nothing here"); // false
|
|
26
26
|
```
|
|
@@ -29,6 +29,70 @@ hasSecret("nothing here"); // false
|
|
|
29
29
|
with `findings: []`. It replaces every detected secret with `[REDACTED:<label>]`
|
|
30
30
|
and never blocks the write — the surrounding knowledge survives.
|
|
31
31
|
|
|
32
|
+
## Announced secrets — when the label is the only evidence (v0.2.0, opt-in)
|
|
33
|
+
|
|
34
|
+
`Adgangskode: hunter2` has **no format to match.** Everything above recognises a
|
|
35
|
+
key by its *shape* (`sk-ant-…`, `ghp_…`, `AKIA…`); here the value is arbitrary
|
|
36
|
+
human text and the only signal is that someone wrote the word "password" next to
|
|
37
|
+
it. cardmem found exactly this as the **first line of an ingested mail**, and
|
|
38
|
+
`redactSecrets()` passed it through unchanged.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
redactSecrets("Adgangskode: hunter2"); // ← UNCHANGED. Off by default.
|
|
42
|
+
redactSecrets("Adgangskode: hunter2", { announced: true });
|
|
43
|
+
// redacted → "Adgangskode: [REDACTED:announced-secret]" ← the label is kept on purpose
|
|
44
|
+
// findings → [{ label: "announced-secret", count: 1, confidence: "announced" }]
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Findings now carry `confidence`, so you can tell the two axes apart: `"format"`
|
|
48
|
+
(the value identifies itself — safe anywhere) vs `"announced"` (a label claims
|
|
49
|
+
the next word is a credential).
|
|
50
|
+
|
|
51
|
+
### Why it is opt-in — the measurement, not a hunch
|
|
52
|
+
|
|
53
|
+
> Measured **2026-08-14** against this repo: **548 tracked files, 544 readable as
|
|
54
|
+
> text**, containing essentially no real secrets. The shipped pattern matched
|
|
55
|
+
> **97 times, all of them noise.** Per label: `secret` **61**, `api key` **33**,
|
|
56
|
+
> `password` **4**, every Danish label **0**.
|
|
57
|
+
|
|
58
|
+
So 94 of the 97 come from the two words that are also ordinary **identifiers in
|
|
59
|
+
source code** — `secret: config.secret`, `apiKey: Record<…>`. That is the real
|
|
60
|
+
finding, and it is sharper than "the pattern is noisy": **its precision depends
|
|
61
|
+
entirely on what you are scanning.** In an inbound mail body `Adgangskode:` is a
|
|
62
|
+
strong signal; in a TypeScript file it is a variable name. The package cannot
|
|
63
|
+
know which corpus it is looking at — **only you can** — so you make the call, and
|
|
64
|
+
the default cannot be on.
|
|
65
|
+
|
|
66
|
+
That is the opposite of this repo's usual defaults-ON stance (webpush F067.1,
|
|
67
|
+
lens-engine F065). The numbers above are the reason, and they are why tuning is
|
|
68
|
+
not the answer either: a broader label+separator+value pattern measured **305**
|
|
69
|
+
on the same corpus, and refining it only reached **202**. A template/env-guard
|
|
70
|
+
(`${FOO}`, `<your-key>`) was written and then dropped — it changed the count by
|
|
71
|
+
**exactly 0**, because the noise here is identifiers, not templates.
|
|
72
|
+
|
|
73
|
+
### `hasAnnouncedSecret` — for when the right answer is to refuse
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { hasAnnouncedSecret } from "@broberg/secret-scan";
|
|
77
|
+
|
|
78
|
+
if (hasAnnouncedSecret(mailBody)) return; // don't send this to a model at all
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
A boolean, with no redaction built. For **untrusted inbound text heading to an
|
|
82
|
+
LLM**, refusing beats redacting: a false positive costs a slightly worse
|
|
83
|
+
classification, a false negative costs a leak. (buddy's reasoning, F035.8 — and
|
|
84
|
+
it is the better half of this feature.)
|
|
85
|
+
|
|
86
|
+
It fires on `Password: hunter2` and **not** on `"I forgot my password"` — a
|
|
87
|
+
label with no separator and value is prose, not a credential. Beware the
|
|
88
|
+
tempting-but-wrong version: a bare "3–32 alphanumerics on the first line" regex
|
|
89
|
+
matches `Hej`, `Tak` and `FYI`. Harmless where a non-match costs nothing,
|
|
90
|
+
dangerous in anything that redacts.
|
|
91
|
+
|
|
92
|
+
**Detection order is load-bearing.** The announced pass runs *last* and refuses a
|
|
93
|
+
value that is already a redaction marker, so `API key: sk-ant-…` still redacts as
|
|
94
|
+
`anthropic-api-key` rather than flattening to a generic `announced-secret`.
|
|
95
|
+
|
|
32
96
|
## Classify a single token — `classify`
|
|
33
97
|
|
|
34
98
|
The inverse of redaction: given a **single pasted token**, tell the caller what
|
|
@@ -103,19 +167,30 @@ regexes — most-specific first so attribution is correct:
|
|
|
103
167
|
secrets are caught only via the `labeled-hex-secret` name-context rule.
|
|
104
168
|
- **Order is API** — specific patterns run before generic ones (`sk-ant-` before
|
|
105
169
|
`sk-`); a test asserts it.
|
|
170
|
+
- **`hue-application-key` is the one unprefixed shape, and it runs LAST.** A Hue
|
|
171
|
+
v2 key is 40 chars of `[A-Za-z0-9-]` with nothing to anchor on, so the regex
|
|
172
|
+
carries a negative lookahead — `\b(?![0-9a-f]{40}\b)[A-Za-z0-9-]{40}\b` — that
|
|
173
|
+
excludes **git commit SHAs**. This is not an optimisation: telemetry and error
|
|
174
|
+
output are full of SHAs, and a redactor that mangles commit hashes gets turned
|
|
175
|
+
off within a week, after which it protects nothing. Hue keys are mixed-case,
|
|
176
|
+
SHAs are lowercase hex. Do not "simplify" the lookahead away; `test/hue-key.test.ts`
|
|
177
|
+
asserts real SHAs stay untouched, standalone and in prose.
|
|
106
178
|
|
|
107
179
|
## API
|
|
108
180
|
|
|
109
181
|
```ts
|
|
110
182
|
interface SecretPattern { label: string; description: string; regex: RegExp; }
|
|
111
|
-
|
|
183
|
+
type SecretConfidence = "format" | "announced";
|
|
184
|
+
interface RedactionFinding { label: string; count: number; confidence: SecretConfidence; }
|
|
112
185
|
interface RedactionResult { redacted: string; findings: RedactionFinding[]; }
|
|
113
|
-
interface RedactOptions { extraPatterns?: SecretPattern[]; }
|
|
186
|
+
interface RedactOptions { extraPatterns?: SecretPattern[]; announced?: boolean; }
|
|
114
187
|
interface ClassifyResult { label: string; description: string; }
|
|
115
188
|
|
|
116
189
|
const SECRET_PATTERNS: SecretPattern[];
|
|
190
|
+
const ANNOUNCED_LABEL: string; // "announced-secret"
|
|
117
191
|
function redactSecrets(text: string, opts?: RedactOptions): RedactionResult;
|
|
118
|
-
function hasSecret(text: string, opts?: RedactOptions): boolean;
|
|
192
|
+
function hasSecret(text: string, opts?: RedactOptions): boolean; // honours opts.announced
|
|
193
|
+
function hasAnnouncedSecret(text: string): boolean; // the refuse-path
|
|
119
194
|
function classify(value: string, opts?: RedactOptions): ClassifyResult | null; // single-token type detection
|
|
120
195
|
function redactionMarker(label: string): string; // `[REDACTED:${label}]`
|
|
121
196
|
```
|
package/dist/index.cjs
CHANGED
|
@@ -241,8 +241,25 @@ var SECRET_PATTERNS = [
|
|
|
241
241
|
label: "cloudflare-global-key",
|
|
242
242
|
description: "Cloudflare global API key (37-hex)",
|
|
243
243
|
regex: /\b[0-9a-f]{37}\b/g
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
// LAST on purpose: this is the only unprefixed shape in the list, so every
|
|
247
|
+
// anchored pattern above must get first refusal.
|
|
248
|
+
//
|
|
249
|
+
// Philips Hue v2 application key — 40 chars of [A-Za-z0-9-] with NO prefix,
|
|
250
|
+
// so there is nothing to anchor on. The negative lookahead is load-bearing,
|
|
251
|
+
// not decoration: a bare [A-Za-z0-9-]{40} also matches a GIT COMMIT SHA, and
|
|
252
|
+
// telemetry/error output is full of those. A redactor that eats commit
|
|
253
|
+
// hashes gets switched off within a week, after which it protects nothing.
|
|
254
|
+
// Hue keys are mixed-case; SHAs are lowercase hex — that asymmetry is the
|
|
255
|
+
// whole guard. (Pattern contributed + field-tested by beacon, F035.7.)
|
|
256
|
+
label: "hue-application-key",
|
|
257
|
+
description: "Philips Hue v2 application key (40 chars, no prefix)",
|
|
258
|
+
regex: /\b(?![0-9a-f]{40}\b)[A-Za-z0-9-]{40}\b/g
|
|
244
259
|
}
|
|
245
260
|
];
|
|
261
|
+
var ANNOUNCED_LABEL = "announced-secret";
|
|
262
|
+
var ANNOUNCED_SECRET = /(\b(?:adgangskode|kodeord|hemmelighed|password|passwd|api[ -]?key|apinøgle|secret|kode|pwd)\s*[:=]\s*)(?!\[REDACTED:)\S+/gi;
|
|
246
263
|
var redactionMarker = (label) => `[REDACTED:${label}]`;
|
|
247
264
|
function patternsFor(opts) {
|
|
248
265
|
return opts?.extraPatterns && opts.extraPatterns.length > 0 ? [...SECRET_PATTERNS, ...opts.extraPatterns] : SECRET_PATTERNS;
|
|
@@ -257,11 +274,28 @@ function redactSecrets(text, opts) {
|
|
|
257
274
|
count++;
|
|
258
275
|
return redactionMarker(p.label);
|
|
259
276
|
});
|
|
260
|
-
if (count > 0) findings.push({ label: p.label, count });
|
|
277
|
+
if (count > 0) findings.push({ label: p.label, count, confidence: "format" });
|
|
278
|
+
}
|
|
279
|
+
if (opts?.announced) {
|
|
280
|
+
let count = 0;
|
|
281
|
+
const redactedAnnounced = redacted.replace(ANNOUNCED_SECRET, (_match, prefix) => {
|
|
282
|
+
count++;
|
|
283
|
+
return prefix + redactionMarker(ANNOUNCED_LABEL);
|
|
284
|
+
});
|
|
285
|
+
if (count > 0) {
|
|
286
|
+
redacted = redactedAnnounced;
|
|
287
|
+
findings.push({ label: ANNOUNCED_LABEL, count, confidence: "announced" });
|
|
288
|
+
}
|
|
261
289
|
}
|
|
262
290
|
return { redacted, findings };
|
|
263
291
|
}
|
|
292
|
+
function hasAnnouncedSecret(text) {
|
|
293
|
+
if (!text) return false;
|
|
294
|
+
ANNOUNCED_SECRET.lastIndex = 0;
|
|
295
|
+
return ANNOUNCED_SECRET.test(text);
|
|
296
|
+
}
|
|
264
297
|
function hasSecret(text, opts) {
|
|
298
|
+
if (opts?.announced && hasAnnouncedSecret(text)) return true;
|
|
265
299
|
return patternsFor(opts).some((p) => {
|
|
266
300
|
p.regex.lastIndex = 0;
|
|
267
301
|
return p.regex.test(text);
|
|
@@ -278,8 +312,10 @@ function classify(value, opts) {
|
|
|
278
312
|
return null;
|
|
279
313
|
}
|
|
280
314
|
|
|
315
|
+
exports.ANNOUNCED_LABEL = ANNOUNCED_LABEL;
|
|
281
316
|
exports.SECRET_PATTERNS = SECRET_PATTERNS;
|
|
282
317
|
exports.classify = classify;
|
|
318
|
+
exports.hasAnnouncedSecret = hasAnnouncedSecret;
|
|
283
319
|
exports.hasSecret = hasSecret;
|
|
284
320
|
exports.redactSecrets = redactSecrets;
|
|
285
321
|
exports.redactionMarker = redactionMarker;
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @broberg/secret-scan — fleet secret/credential redaction.\n *\n * `redactSecrets(text)` replaces every matched secret with `[REDACTED:<label>]`\n * and reports what it found. PURE + deterministic (regex/string only, no deps,\n * no I/O) so an engine write-gate, an egress scrub, a CLI, an admin preview UI,\n * and any repo all share the EXACT same detection — and it's trivially testable.\n *\n * Lifted verbatim from broberg/trail F197 (the second-brain safeguard); see\n * docs/features/F035-secret-scan.md. components owns + publishes this; @trail/shared\n * re-exports it.\n *\n * Design choices:\n * - Pattern-based, NOT entropy/generic-randomness — a redacted real fact would\n * corrupt knowledge, so we accept missing an exotic token over false positives.\n * - Order matters: most-specific patterns run first (e.g. `sk-ant-` before the\n * generic OpenAI `sk-`; `sk-or-v1-` before `sk-`), because each match is\n * consumed before the next pattern runs → order = attribution.\n * - Redact, never reject — the surrounding knowledge survives; only the\n * credential substring is neutralised.\n * - NEVER a bare high-entropy/hex pattern (it would hit git shas/hashes).\n * Prefix-less service secrets are caught only via `labeled-hex-secret` (a 40+\n * hex value assigned to a secret/token/password/api-key-named field).\n *\n * Two recommended integration shapes for consumers:\n * (a) write boundary — `redactSecrets(text)` before persist (ingest gate);\n * (b) egress — scrub before a value leaves to a user/LLM (highest-value guard).\n */\n\nexport interface SecretPattern {\n /** stable id shown in the redaction marker + findings */\n label: string;\n /** human description of what this matches */\n description: string;\n /** global regex (used for replace-all + counting) */\n regex: RegExp;\n}\n\n/** Ordered most-specific → least. Every regex carries the `g` flag. */\nexport const SECRET_PATTERNS: SecretPattern[] = [\n {\n label: 'private-key',\n description: 'PEM private key block (RSA/EC/OPENSSH/DSA/PGP)',\n regex:\n /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\\s\\S]*?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g,\n },\n {\n label: 'anthropic-api-key',\n description: 'Anthropic API key (sk-ant-…)',\n regex: /sk-ant-(?:api03-)?[A-Za-z0-9_-]{20,}/g,\n },\n {\n // OpenRouter — distinct from OpenAI; runs BEFORE the generic sk- (which would\n // otherwise also match + mislabel it).\n label: 'openrouter-api-key',\n description: 'OpenRouter API key (sk-or-v1- + 64 hex)',\n regex: /\\bsk-or-v1-[0-9a-f]{64}/g,\n },\n {\n // DeepSeek — shares the sk- prefix with OpenAI, so it MUST run before the\n // generic openai pattern (specific-before-generic = correct attribution).\n // DeepSeek's documented shape is sk- + 32 lowercase hex (GitGuardian confirms\n // an sk- prefix but hides the exact regex); the hex-only body + {32,} length\n // distinguishes it from OpenAI's mixed-case base62 keys, so a real OpenAI key\n // is never mislabelled. The field-anchored fallback below catches any\n // DEEPSEEK_API_KEY value that doesn't fit this canonical shape.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (sk- + 32 lowercase hex)',\n regex: /\\bsk-[0-9a-f]{32,}(?![0-9a-z])/g,\n },\n {\n label: 'openai-api-key',\n description: 'OpenAI API key (sk-… / sk-proj-…)',\n regex: /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/g,\n },\n {\n // ElevenLabs — sk_ with UNDERSCORE (vs OpenAI sk-), 48 hex.\n label: 'elevenlabs-api-key',\n description: 'ElevenLabs API key (sk_ + 48 hex)',\n regex: /\\bsk_[0-9a-f]{48}\\b/g,\n },\n {\n // fal.ai — uuid:hex32 (key_id:key_secret); the colon is the signal.\n label: 'fal-api-key',\n description: 'fal.ai key (uuid:hex32)',\n regex: /\\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:[0-9a-f]{32}\\b/g,\n },\n {\n // Black Forest Labs (FLUX) API key — bfl_ prefix + a long token (sample\n // bfl_Qo1…). The distinctive prefix + {20,} length keeps false positives near\n // zero; image-provider sibling of the fal key above.\n label: 'bfl-api-key',\n description: 'Black Forest Labs / FLUX API key (bfl_ + token)',\n regex: /\\bbfl_[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'google-api-key',\n description: 'Google / Gemini API key (AIza…)',\n regex: /AIza[0-9A-Za-z_-]{35}/g,\n },\n {\n label: 'google-oauth-secret',\n description: 'Google OAuth client secret (GOCSPX-…)',\n regex: /GOCSPX-[A-Za-z0-9_-]{28}/g,\n },\n {\n label: 'aws-access-key-id',\n description: 'AWS access key id (AKIA…)',\n regex: /\\bAKIA[0-9A-Z]{16}\\b/g,\n },\n {\n label: 'github-token',\n description: 'GitHub token (ghp_/gho_/ghs_/ghu_/ghr_…)',\n regex: /\\bgh[posru]_[A-Za-z0-9]{36,}\\b/g,\n },\n {\n // GitHub fine-grained PAT — distinct prefix `github_pat_` (not caught by the\n // classic gh[posru]_ above), then base62 + a `_` separator (~82 chars total).\n // The prefix is so distinctive that {50,} keeps false positives at zero.\n label: 'github-fine-grained-pat',\n description: 'GitHub fine-grained personal access token (github_pat_…)',\n regex: /\\bgithub_pat_[A-Za-z0-9_]{50,}/g,\n },\n {\n label: 'gitlab-token',\n description: 'GitLab personal access token (glpat-…)',\n regex: /\\bglpat-[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'slack-token',\n description: 'Slack token (xox[baprs]-…)',\n regex: /\\bxox[baprs]-[A-Za-z0-9-]{10,}/g,\n },\n {\n label: 'stripe-secret-key',\n description: 'Stripe live secret/restricted key (sk_live_/rk_live_…)',\n regex: /\\b[rs]k_live_[A-Za-z0-9]{20,}/g,\n },\n {\n // Resend (re_…). Lookahead requires a digit in the body so we don't redact\n // long snake_case identifiers like re_compute_the_thing.\n label: 'resend-api-key',\n description: 'Resend API key (re_ + token)',\n regex: /\\bre_(?=[A-Za-z0-9_]*\\d)[A-Za-z0-9_]{24,}\\b/g,\n },\n {\n label: 'supabase-access-token',\n description: 'Supabase personal/management access token (sbp_ + 40 hex)',\n regex: /\\bsbp_[0-9a-f]{40}/g,\n },\n {\n label: 'supabase-secret-key',\n description: 'Supabase secret API key (sb_secret_…)',\n regex: /\\bsb_secret_[A-Za-z0-9_-]{20,}/g,\n },\n {\n // Used by every @broberg/* publish — the highest-value leak from a .env / commit history.\n label: 'npm-token',\n description: 'npm publish/automation token (npm_ + 36 base62)',\n regex: /\\bnpm_[A-Za-z0-9]{36}\\b/g,\n },\n {\n label: 'fly-api-token',\n description: 'Fly.io API token (FlyV1 fm2_… / fo1_…)',\n regex: /(?:FlyV1 fm2_[A-Za-z0-9+/=_-]{20,}|\\bfo1_[A-Za-z0-9_-]{20,})/g,\n },\n {\n // Also covers Turso DB/platform auth tokens AND Supabase anon/service_role\n // keys — both are JWTs (eyJ…), so the single JWT pattern catches them.\n label: 'jwt',\n description: 'JSON Web Token (eyJ…) — incl. Turso + Supabase service_role tokens',\n regex: /\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}/g,\n },\n {\n // genApiKey = randomBytes(24).hex → uk_ + exactly 48 lowercase hex.\n label: 'upmetrics-key',\n description: 'Upmetrics project key (uk_ + 48 hex)',\n regex: /\\buk_[0-9a-f]{48}/g,\n },\n {\n label: 'cardmem-key',\n description: 'Cardmem personal/incident/project key (pa_/pi_/pk_ + 64 hex)',\n regex: /\\bp[aik]_[A-Za-z0-9]{20,}/g,\n },\n {\n // cardmem inbox-webhook key — piw_ isn't matched by p[aik]_ above (3rd char 'w' ≠ '_').\n label: 'cardmem-webhook-key',\n description: 'Cardmem inbox-webhook key (piw_ + 64 hex)',\n regex: /\\bpiw_[0-9a-f]{64}/g,\n },\n {\n label: 'trail-key',\n description: 'Trail personal API key (trail_…)',\n regex: /\\btrail_[A-Za-z0-9]{20,}/g,\n },\n {\n // Cronjobs API key (cronjobs.webhouse.net) — cj_ + randomBytes(32).base64url =\n // exactly 43 base64url chars (46 total). Prefix + fixed length = very low FP.\n // The UI's truncated cj_<8 chars>… preview is shorter than {43} → not matched.\n // Negative lookahead (not \\b) because base64url's `-` breaks a trailing \\b.\n label: 'cronjobs-api-key',\n description: 'Cronjobs API key (cj_ + 43 base64url)',\n regex: /\\bcj_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g,\n },\n {\n // randomBytes(32).hex → wh_ + 64 lowercase hex (67 chars total).\n label: 'cms-access-token',\n description: 'webhouse.app CMS access token (wh_ + 64 hex)',\n regex: /\\bwh_[0-9a-f]{64}/g,\n },\n {\n // Cloudflare API token (R2 / DNS management) — 40 base64url chars, NO prefix.\n // A bare {40} would false-positive broadly, so this is CONTEXT-ONLY: it only\n // fires next to a cf/cloudflare-api-token-named field. Runs before\n // labeled-hex-secret so a hex-valued CF token is attributed correctly.\n label: 'cloudflare-api-token',\n description: 'Cloudflare API token (cf/cloudflare-api-token field + 40 base64url)',\n regex: /\\b(?:cf|cloudflare)_?api_?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{40}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Mistral API key — prefix-less ~32 base62 (Christian-confirmed sample). A bare\n // [A-Za-z0-9]{32} would FP on every ID/hash, so CONTEXT-ONLY: anchored on a\n // mistral-(api-)key/token-named field. Runs before labeled-hex for attribution.\n label: 'mistral-api-key',\n description: 'Mistral API key (mistral-(api-)key/token field + 24+ base62)',\n regex: /\\bmistral(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{24,}(?![A-Za-z0-9])/gi,\n },\n {\n // DeepSeek — field-anchored fallback for any DEEPSEEK_API_KEY/TOKEN value that\n // doesn't fit the canonical sk-+hex shape (mirrors the Mistral context-only\n // approach). The field name is the signal → near-zero false positives. The\n // sk-+hex format pattern above already attributes the canonical shape; this\n // backstops a format change or an opaque token.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (deepseek-(api-)key/token field + 20+ token)',\n regex: /\\bdeepseek(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Vimeo personal access token — ~32 lowercase hex, no prefix (sanne). A bare\n // hex32 would FP massively (MD5/UUID), so CONTEXT-ONLY: anchored on a\n // vimeo-(access-)token-named field.\n label: 'vimeo-access-token',\n description: 'Vimeo access token (vimeo-(access-)token field + 20+ base62)',\n regex: /\\bvimeo(?:[_-]?access)?[_-]?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{20,}(?![A-Za-z0-9])/gi,\n },\n {\n // Context-based catch for prefix-less high-entropy service secrets\n // (CMS_JWT_SECRET, revalidateSecret, fleet openssl-rand-hex secrets): a 40+\n // hex value assigned to a field whose name contains\n // secret/token/password/api-key. The name requirement keeps the\n // false-positive rate near zero (a bare 40/64-hex would hit shas/hashes).\n label: 'labeled-hex-secret',\n description: 'A 40+ hex value assigned to a secret/token/password/api-key-named field',\n regex: /\\b[A-Za-z0-9_-]*(?:secret|token|password|api[_-]?key)\\b\\s*[:=]\\s*[\"'`]?[0-9a-f]{40,}/gi,\n },\n {\n // Discord bot token — three base64url segments. Anchored both sides so it\n // can't partial-match a longer dotted string.\n label: 'discord-bot-token',\n description: 'Discord bot token (3 base64url segments)',\n regex: /(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{24,26}\\.[A-Za-z0-9_-]{6}\\.[A-Za-z0-9_-]{27,40}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'discord-mfa-token',\n description: 'Discord MFA token (mfa. + 84 chars)',\n regex: /\\bmfa\\.[A-Za-z0-9_-]{84}\\b/g,\n },\n {\n // Cloudflare Turnstile PROD secret (sanne, verified 2/2) — 0x4 + 6×A prefix,\n // then 26 base64url (35 total). The 24-char SITE key + 1x/2x/3x TEST keys are\n // intentionally NOT matched (the {26} length gate misses them) so a public\n // key is never redacted.\n label: 'cloudflare-turnstile-secret',\n description: 'Cloudflare Turnstile secret key (0x4AAAAAA + 26 base64url, 35 total)',\n regex: /0x4AAAAAA[A-Za-z0-9_-]{26}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'cloudflare-global-key',\n description: 'Cloudflare global API key (37-hex)',\n regex: /\\b[0-9a-f]{37}\\b/g,\n },\n];\n\nexport interface RedactionFinding {\n label: string;\n count: number;\n}\n\nexport interface RedactionResult {\n /** input with every secret replaced by `[REDACTED:<label>]` */\n redacted: string;\n /** per-pattern counts of what was redacted (empty = clean) */\n findings: RedactionFinding[];\n}\n\nexport interface RedactOptions {\n /**\n * Extra consumer/per-tenant patterns, run AFTER the canonical set (so canonical\n * attribution wins). Backs a future self-service \"paste a key → detector\" UI.\n */\n extraPatterns?: SecretPattern[];\n}\n\n/** Replacement marker for a redacted secret. */\nexport const redactionMarker = (label: string): string => `[REDACTED:${label}]`;\n\nfunction patternsFor(opts?: RedactOptions): SecretPattern[] {\n return opts?.extraPatterns && opts.extraPatterns.length > 0\n ? [...SECRET_PATTERNS, ...opts.extraPatterns]\n : SECRET_PATTERNS;\n}\n\n/**\n * Scan `text` and replace every detected secret with its redaction marker.\n * Pure: clean input returns byte-identical (`findings: []`).\n */\nexport function redactSecrets(text: string, opts?: RedactOptions): RedactionResult {\n if (!text) return { redacted: text, findings: [] };\n let redacted = text;\n const findings: RedactionFinding[] = [];\n for (const p of patternsFor(opts)) {\n let count = 0;\n redacted = redacted.replace(p.regex, () => {\n count++;\n return redactionMarker(p.label);\n });\n if (count > 0) findings.push({ label: p.label, count });\n }\n return { redacted, findings };\n}\n\n/** True if `text` contains at least one detectable secret. */\nexport function hasSecret(text: string, opts?: RedactOptions): boolean {\n return patternsFor(opts).some((p) => {\n p.regex.lastIndex = 0;\n return p.regex.test(text);\n });\n}\n\nexport interface ClassifyResult {\n /** the matching pattern's stable label (e.g. `openai-api-key`) */\n label: string;\n /** the matching pattern's human description (e.g. `OpenAI API key (sk-… / sk-proj-…)`) */\n description: string;\n}\n\n/**\n * Classify a SINGLE pasted token — the INVERSE of redaction. Returns the first\n * (most-specific) pattern the value matches, or `null`. Backs a \"paste a key →\n * detect its type\" UI (cardmem F214 Secrets Vault) so every consumer shares the\n * same classification, not just the same redaction.\n *\n * First-match-wins over the ordered `SECRET_PATTERNS`, so `sk-ant-…` classifies\n * as `anthropic-api-key`, never the generic `openai-api-key`. Field-anchored\n * context-only patterns (mistral / vimeo / cloudflare-api-token /\n * labeled-hex-secret / deepseek-fallback) only match when the pasted value\n * includes their `NAME=` context; a bare provider token classifies via its\n * prefix pattern, and a prefix-less bare token (e.g. a raw Mistral key) is\n * genuinely unidentifiable → `null`. `opts.extraPatterns` run AFTER the\n * canonical set (canonical attribution wins). Input is trimmed; empty /\n * whitespace-only → `null`.\n */\nexport function classify(value: string, opts?: RedactOptions): ClassifyResult | null {\n if (!value) return null;\n const v = value.trim();\n if (!v) return null;\n for (const p of patternsFor(opts)) {\n p.regex.lastIndex = 0;\n if (p.regex.test(v)) return { label: p.label, description: p.description };\n }\n return null;\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @broberg/secret-scan — fleet secret/credential redaction.\n *\n * `redactSecrets(text)` replaces every matched secret with `[REDACTED:<label>]`\n * and reports what it found. PURE + deterministic (regex/string only, no deps,\n * no I/O) so an engine write-gate, an egress scrub, a CLI, an admin preview UI,\n * and any repo all share the EXACT same detection — and it's trivially testable.\n *\n * Lifted verbatim from broberg/trail F197 (the second-brain safeguard); see\n * docs/features/F035-secret-scan.md. components owns + publishes this; @trail/shared\n * re-exports it.\n *\n * Design choices:\n * - Pattern-based, NOT entropy/generic-randomness — a redacted real fact would\n * corrupt knowledge, so we accept missing an exotic token over false positives.\n * - Order matters: most-specific patterns run first (e.g. `sk-ant-` before the\n * generic OpenAI `sk-`; `sk-or-v1-` before `sk-`), because each match is\n * consumed before the next pattern runs → order = attribution.\n * - Redact, never reject — the surrounding knowledge survives; only the\n * credential substring is neutralised.\n * - NEVER a bare high-entropy/hex pattern (it would hit git shas/hashes).\n * Prefix-less service secrets are caught only via `labeled-hex-secret` (a 40+\n * hex value assigned to a secret/token/password/api-key-named field).\n *\n * Two recommended integration shapes for consumers:\n * (a) write boundary — `redactSecrets(text)` before persist (ingest gate);\n * (b) egress — scrub before a value leaves to a user/LLM (highest-value guard).\n */\n\nexport interface SecretPattern {\n /** stable id shown in the redaction marker + findings */\n label: string;\n /** human description of what this matches */\n description: string;\n /** global regex (used for replace-all + counting) */\n regex: RegExp;\n}\n\n/** Ordered most-specific → least. Every regex carries the `g` flag. */\nexport const SECRET_PATTERNS: SecretPattern[] = [\n {\n label: 'private-key',\n description: 'PEM private key block (RSA/EC/OPENSSH/DSA/PGP)',\n regex:\n /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\\s\\S]*?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g,\n },\n {\n label: 'anthropic-api-key',\n description: 'Anthropic API key (sk-ant-…)',\n regex: /sk-ant-(?:api03-)?[A-Za-z0-9_-]{20,}/g,\n },\n {\n // OpenRouter — distinct from OpenAI; runs BEFORE the generic sk- (which would\n // otherwise also match + mislabel it).\n label: 'openrouter-api-key',\n description: 'OpenRouter API key (sk-or-v1- + 64 hex)',\n regex: /\\bsk-or-v1-[0-9a-f]{64}/g,\n },\n {\n // DeepSeek — shares the sk- prefix with OpenAI, so it MUST run before the\n // generic openai pattern (specific-before-generic = correct attribution).\n // DeepSeek's documented shape is sk- + 32 lowercase hex (GitGuardian confirms\n // an sk- prefix but hides the exact regex); the hex-only body + {32,} length\n // distinguishes it from OpenAI's mixed-case base62 keys, so a real OpenAI key\n // is never mislabelled. The field-anchored fallback below catches any\n // DEEPSEEK_API_KEY value that doesn't fit this canonical shape.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (sk- + 32 lowercase hex)',\n regex: /\\bsk-[0-9a-f]{32,}(?![0-9a-z])/g,\n },\n {\n label: 'openai-api-key',\n description: 'OpenAI API key (sk-… / sk-proj-…)',\n regex: /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/g,\n },\n {\n // ElevenLabs — sk_ with UNDERSCORE (vs OpenAI sk-), 48 hex.\n label: 'elevenlabs-api-key',\n description: 'ElevenLabs API key (sk_ + 48 hex)',\n regex: /\\bsk_[0-9a-f]{48}\\b/g,\n },\n {\n // fal.ai — uuid:hex32 (key_id:key_secret); the colon is the signal.\n label: 'fal-api-key',\n description: 'fal.ai key (uuid:hex32)',\n regex: /\\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:[0-9a-f]{32}\\b/g,\n },\n {\n // Black Forest Labs (FLUX) API key — bfl_ prefix + a long token (sample\n // bfl_Qo1…). The distinctive prefix + {20,} length keeps false positives near\n // zero; image-provider sibling of the fal key above.\n label: 'bfl-api-key',\n description: 'Black Forest Labs / FLUX API key (bfl_ + token)',\n regex: /\\bbfl_[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'google-api-key',\n description: 'Google / Gemini API key (AIza…)',\n regex: /AIza[0-9A-Za-z_-]{35}/g,\n },\n {\n label: 'google-oauth-secret',\n description: 'Google OAuth client secret (GOCSPX-…)',\n regex: /GOCSPX-[A-Za-z0-9_-]{28}/g,\n },\n {\n label: 'aws-access-key-id',\n description: 'AWS access key id (AKIA…)',\n regex: /\\bAKIA[0-9A-Z]{16}\\b/g,\n },\n {\n label: 'github-token',\n description: 'GitHub token (ghp_/gho_/ghs_/ghu_/ghr_…)',\n regex: /\\bgh[posru]_[A-Za-z0-9]{36,}\\b/g,\n },\n {\n // GitHub fine-grained PAT — distinct prefix `github_pat_` (not caught by the\n // classic gh[posru]_ above), then base62 + a `_` separator (~82 chars total).\n // The prefix is so distinctive that {50,} keeps false positives at zero.\n label: 'github-fine-grained-pat',\n description: 'GitHub fine-grained personal access token (github_pat_…)',\n regex: /\\bgithub_pat_[A-Za-z0-9_]{50,}/g,\n },\n {\n label: 'gitlab-token',\n description: 'GitLab personal access token (glpat-…)',\n regex: /\\bglpat-[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'slack-token',\n description: 'Slack token (xox[baprs]-…)',\n regex: /\\bxox[baprs]-[A-Za-z0-9-]{10,}/g,\n },\n {\n label: 'stripe-secret-key',\n description: 'Stripe live secret/restricted key (sk_live_/rk_live_…)',\n regex: /\\b[rs]k_live_[A-Za-z0-9]{20,}/g,\n },\n {\n // Resend (re_…). Lookahead requires a digit in the body so we don't redact\n // long snake_case identifiers like re_compute_the_thing.\n label: 'resend-api-key',\n description: 'Resend API key (re_ + token)',\n regex: /\\bre_(?=[A-Za-z0-9_]*\\d)[A-Za-z0-9_]{24,}\\b/g,\n },\n {\n label: 'supabase-access-token',\n description: 'Supabase personal/management access token (sbp_ + 40 hex)',\n regex: /\\bsbp_[0-9a-f]{40}/g,\n },\n {\n label: 'supabase-secret-key',\n description: 'Supabase secret API key (sb_secret_…)',\n regex: /\\bsb_secret_[A-Za-z0-9_-]{20,}/g,\n },\n {\n // Used by every @broberg/* publish — the highest-value leak from a .env / commit history.\n label: 'npm-token',\n description: 'npm publish/automation token (npm_ + 36 base62)',\n regex: /\\bnpm_[A-Za-z0-9]{36}\\b/g,\n },\n {\n label: 'fly-api-token',\n description: 'Fly.io API token (FlyV1 fm2_… / fo1_…)',\n regex: /(?:FlyV1 fm2_[A-Za-z0-9+/=_-]{20,}|\\bfo1_[A-Za-z0-9_-]{20,})/g,\n },\n {\n // Also covers Turso DB/platform auth tokens AND Supabase anon/service_role\n // keys — both are JWTs (eyJ…), so the single JWT pattern catches them.\n label: 'jwt',\n description: 'JSON Web Token (eyJ…) — incl. Turso + Supabase service_role tokens',\n regex: /\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}/g,\n },\n {\n // genApiKey = randomBytes(24).hex → uk_ + exactly 48 lowercase hex.\n label: 'upmetrics-key',\n description: 'Upmetrics project key (uk_ + 48 hex)',\n regex: /\\buk_[0-9a-f]{48}/g,\n },\n {\n label: 'cardmem-key',\n description: 'Cardmem personal/incident/project key (pa_/pi_/pk_ + 64 hex)',\n regex: /\\bp[aik]_[A-Za-z0-9]{20,}/g,\n },\n {\n // cardmem inbox-webhook key — piw_ isn't matched by p[aik]_ above (3rd char 'w' ≠ '_').\n label: 'cardmem-webhook-key',\n description: 'Cardmem inbox-webhook key (piw_ + 64 hex)',\n regex: /\\bpiw_[0-9a-f]{64}/g,\n },\n {\n label: 'trail-key',\n description: 'Trail personal API key (trail_…)',\n regex: /\\btrail_[A-Za-z0-9]{20,}/g,\n },\n {\n // Cronjobs API key (cronjobs.webhouse.net) — cj_ + randomBytes(32).base64url =\n // exactly 43 base64url chars (46 total). Prefix + fixed length = very low FP.\n // The UI's truncated cj_<8 chars>… preview is shorter than {43} → not matched.\n // Negative lookahead (not \\b) because base64url's `-` breaks a trailing \\b.\n label: 'cronjobs-api-key',\n description: 'Cronjobs API key (cj_ + 43 base64url)',\n regex: /\\bcj_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g,\n },\n {\n // randomBytes(32).hex → wh_ + 64 lowercase hex (67 chars total).\n label: 'cms-access-token',\n description: 'webhouse.app CMS access token (wh_ + 64 hex)',\n regex: /\\bwh_[0-9a-f]{64}/g,\n },\n {\n // Cloudflare API token (R2 / DNS management) — 40 base64url chars, NO prefix.\n // A bare {40} would false-positive broadly, so this is CONTEXT-ONLY: it only\n // fires next to a cf/cloudflare-api-token-named field. Runs before\n // labeled-hex-secret so a hex-valued CF token is attributed correctly.\n label: 'cloudflare-api-token',\n description: 'Cloudflare API token (cf/cloudflare-api-token field + 40 base64url)',\n regex: /\\b(?:cf|cloudflare)_?api_?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{40}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Mistral API key — prefix-less ~32 base62 (Christian-confirmed sample). A bare\n // [A-Za-z0-9]{32} would FP on every ID/hash, so CONTEXT-ONLY: anchored on a\n // mistral-(api-)key/token-named field. Runs before labeled-hex for attribution.\n label: 'mistral-api-key',\n description: 'Mistral API key (mistral-(api-)key/token field + 24+ base62)',\n regex: /\\bmistral(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{24,}(?![A-Za-z0-9])/gi,\n },\n {\n // DeepSeek — field-anchored fallback for any DEEPSEEK_API_KEY/TOKEN value that\n // doesn't fit the canonical sk-+hex shape (mirrors the Mistral context-only\n // approach). The field name is the signal → near-zero false positives. The\n // sk-+hex format pattern above already attributes the canonical shape; this\n // backstops a format change or an opaque token.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (deepseek-(api-)key/token field + 20+ token)',\n regex: /\\bdeepseek(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Vimeo personal access token — ~32 lowercase hex, no prefix (sanne). A bare\n // hex32 would FP massively (MD5/UUID), so CONTEXT-ONLY: anchored on a\n // vimeo-(access-)token-named field.\n label: 'vimeo-access-token',\n description: 'Vimeo access token (vimeo-(access-)token field + 20+ base62)',\n regex: /\\bvimeo(?:[_-]?access)?[_-]?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{20,}(?![A-Za-z0-9])/gi,\n },\n {\n // Context-based catch for prefix-less high-entropy service secrets\n // (CMS_JWT_SECRET, revalidateSecret, fleet openssl-rand-hex secrets): a 40+\n // hex value assigned to a field whose name contains\n // secret/token/password/api-key. The name requirement keeps the\n // false-positive rate near zero (a bare 40/64-hex would hit shas/hashes).\n label: 'labeled-hex-secret',\n description: 'A 40+ hex value assigned to a secret/token/password/api-key-named field',\n regex: /\\b[A-Za-z0-9_-]*(?:secret|token|password|api[_-]?key)\\b\\s*[:=]\\s*[\"'`]?[0-9a-f]{40,}/gi,\n },\n {\n // Discord bot token — three base64url segments. Anchored both sides so it\n // can't partial-match a longer dotted string.\n label: 'discord-bot-token',\n description: 'Discord bot token (3 base64url segments)',\n regex: /(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{24,26}\\.[A-Za-z0-9_-]{6}\\.[A-Za-z0-9_-]{27,40}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'discord-mfa-token',\n description: 'Discord MFA token (mfa. + 84 chars)',\n regex: /\\bmfa\\.[A-Za-z0-9_-]{84}\\b/g,\n },\n {\n // Cloudflare Turnstile PROD secret (sanne, verified 2/2) — 0x4 + 6×A prefix,\n // then 26 base64url (35 total). The 24-char SITE key + 1x/2x/3x TEST keys are\n // intentionally NOT matched (the {26} length gate misses them) so a public\n // key is never redacted.\n label: 'cloudflare-turnstile-secret',\n description: 'Cloudflare Turnstile secret key (0x4AAAAAA + 26 base64url, 35 total)',\n regex: /0x4AAAAAA[A-Za-z0-9_-]{26}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'cloudflare-global-key',\n description: 'Cloudflare global API key (37-hex)',\n regex: /\\b[0-9a-f]{37}\\b/g,\n },\n {\n // LAST on purpose: this is the only unprefixed shape in the list, so every\n // anchored pattern above must get first refusal.\n //\n // Philips Hue v2 application key — 40 chars of [A-Za-z0-9-] with NO prefix,\n // so there is nothing to anchor on. The negative lookahead is load-bearing,\n // not decoration: a bare [A-Za-z0-9-]{40} also matches a GIT COMMIT SHA, and\n // telemetry/error output is full of those. A redactor that eats commit\n // hashes gets switched off within a week, after which it protects nothing.\n // Hue keys are mixed-case; SHAs are lowercase hex — that asymmetry is the\n // whole guard. (Pattern contributed + field-tested by beacon, F035.7.)\n label: 'hue-application-key',\n description: 'Philips Hue v2 application key (40 chars, no prefix)',\n regex: /\\b(?![0-9a-f]{40}\\b)[A-Za-z0-9-]{40}\\b/g,\n },\n];\n\n/**\n * WHY a finding was flagged — the two detection axes this package has.\n *\n * `format` the VALUE carries the signal: `sk-ant-…`, `ghp_…`, `AKIA…`. Shape\n * alone identifies it, so it is safe to run on anything.\n * `announced` the LABEL carries the signal: `Adgangskode: hunter2`. The value is\n * arbitrary human text with no shape to match, so the only evidence\n * is that someone wrote the word \"password\" next to it.\n */\nexport type SecretConfidence = 'format' | 'announced';\n\nexport interface RedactionFinding {\n label: string;\n count: number;\n /** which axis matched — see SecretConfidence. */\n confidence: SecretConfidence;\n}\n\nexport interface RedactionResult {\n /** input with every secret replaced by `[REDACTED:<label>]` */\n redacted: string;\n /** per-pattern counts of what was redacted (empty = clean) */\n findings: RedactionFinding[];\n}\n\nexport interface RedactOptions {\n /**\n * Extra consumer/per-tenant patterns, run AFTER the canonical set (so canonical\n * attribution wins). Backs a future self-service \"paste a key → detector\" UI.\n */\n extraPatterns?: SecretPattern[];\n /**\n * Also detect ANNOUNCED secrets — `Adgangskode: hunter2` — where the label is\n * the only evidence. **Off by default, and it must stay that way.** See\n * ANNOUNCED_LABEL for the measurement that decided it.\n */\n announced?: boolean;\n}\n\n/** Marker label for a secret detected by its announcing label rather than shape. */\nexport const ANNOUNCED_LABEL = 'announced-secret';\n\n/**\n * Label + separator + value. The label list is deliberately short and concrete;\n * this is not a general \"looks like config\" detector.\n *\n * WHY THIS IS OPT-IN, MEASURED RATHER THAN GUESSED. Over this repo on\n * 2026-08-14 — 548 tracked files, 544 readable as text, containing essentially\n * no real secrets — this exact regex matched **97 times**, and every one was\n * noise. Per label: `secret` 61, `api key` 33, `password` 4, and every Danish\n * label 0. So 94 of the 97 are the two words that are also ordinary IDENTIFIERS\n * in source code (`secret: config.secret`, `apiKey: Record<…>`).\n *\n * That is the real finding, and it is sharper than \"the pattern is noisy\": its\n * precision depends entirely on WHAT IS BEING SCANNED. In an inbound mail body\n * — buddy's actual case — `Adgangskode:` is a strong signal. In a TypeScript\n * file it is a variable name. **The package cannot know which corpus it is\n * looking at; only the caller can.** So the caller makes the decision, and the\n * default cannot be on. (This is the opposite of this repo's usual defaults-ON\n * stance — webpush F067.1, lens-engine F065 — and the numbers above are why.)\n *\n * A broader label+separator+value pattern measured 305 on the same corpus, and\n * refining it only reached 202 — no amount of tuning makes a generic version\n * safe. A template/env-reference guard (`${FOO}`, `<your-key>`) was written and\n * then dropped: it changed the count by exactly 0, because the noise here is\n * identifiers, not templates.\n *\n * The value must not already be a redaction marker, so this can run AFTER the\n * format pass without flattening its more specific attribution.\n */\nconst ANNOUNCED_SECRET =\n /(\\b(?:adgangskode|kodeord|hemmelighed|password|passwd|api[ -]?key|apinøgle|secret|kode|pwd)\\s*[:=]\\s*)(?!\\[REDACTED:)\\S+/gi;\n\n/** Replacement marker for a redacted secret. */\nexport const redactionMarker = (label: string): string => `[REDACTED:${label}]`;\n\nfunction patternsFor(opts?: RedactOptions): SecretPattern[] {\n return opts?.extraPatterns && opts.extraPatterns.length > 0\n ? [...SECRET_PATTERNS, ...opts.extraPatterns]\n : SECRET_PATTERNS;\n}\n\n/**\n * Scan `text` and replace every detected secret with its redaction marker.\n * Pure: clean input returns byte-identical (`findings: []`).\n */\nexport function redactSecrets(text: string, opts?: RedactOptions): RedactionResult {\n if (!text) return { redacted: text, findings: [] };\n let redacted = text;\n const findings: RedactionFinding[] = [];\n for (const p of patternsFor(opts)) {\n let count = 0;\n redacted = redacted.replace(p.regex, () => {\n count++;\n return redactionMarker(p.label);\n });\n if (count > 0) findings.push({ label: p.label, count, confidence: 'format' });\n }\n // Announced runs LAST, and only on request. Order is not cosmetic: the format\n // pass has already replaced everything it recognises, and this regex refuses a\n // value that is already a marker — so `API key: sk-ant-…` keeps its specific\n // `anthropic-api-key` attribution instead of being flattened to a generic one.\n // The announcing label itself is KEPT in the output; only the value goes, so\n // the redacted text still reads `Adgangskode: [REDACTED:announced-secret]` and\n // a human or model reading it can still tell what was removed.\n if (opts?.announced) {\n let count = 0;\n const redactedAnnounced = redacted.replace(ANNOUNCED_SECRET, (_match, prefix: string) => {\n count++;\n return prefix + redactionMarker(ANNOUNCED_LABEL);\n });\n if (count > 0) {\n redacted = redactedAnnounced;\n findings.push({ label: ANNOUNCED_LABEL, count, confidence: 'announced' });\n }\n }\n return { redacted, findings };\n}\n\n/**\n * True if `text` announces a credential by label — `Adgangskode: hunter2` —\n * without building a redaction. Cheap enough to run on every inbound message.\n *\n * This exists because for untrusted inbound text heading to a model, the right\n * response is often to REFUSE rather than redact: a false positive costs a\n * slightly worse classification, a false negative costs a leak. That use needs a\n * boolean, not a redactor. (buddy's reasoning, F035.8.)\n */\nexport function hasAnnouncedSecret(text: string): boolean {\n if (!text) return false;\n ANNOUNCED_SECRET.lastIndex = 0;\n return ANNOUNCED_SECRET.test(text);\n}\n\n/**\n * True if `text` contains at least one detectable secret. Honours\n * `opts.announced` — a caller who asks for the announced axis and is told\n * `false` must be able to believe it.\n */\nexport function hasSecret(text: string, opts?: RedactOptions): boolean {\n if (opts?.announced && hasAnnouncedSecret(text)) return true;\n return patternsFor(opts).some((p) => {\n p.regex.lastIndex = 0;\n return p.regex.test(text);\n });\n}\n\nexport interface ClassifyResult {\n /** the matching pattern's stable label (e.g. `openai-api-key`) */\n label: string;\n /** the matching pattern's human description (e.g. `OpenAI API key (sk-… / sk-proj-…)`) */\n description: string;\n}\n\n/**\n * Classify a SINGLE pasted token — the INVERSE of redaction. Returns the first\n * (most-specific) pattern the value matches, or `null`. Backs a \"paste a key →\n * detect its type\" UI (cardmem F214 Secrets Vault) so every consumer shares the\n * same classification, not just the same redaction.\n *\n * First-match-wins over the ordered `SECRET_PATTERNS`, so `sk-ant-…` classifies\n * as `anthropic-api-key`, never the generic `openai-api-key`. Field-anchored\n * context-only patterns (mistral / vimeo / cloudflare-api-token /\n * labeled-hex-secret / deepseek-fallback) only match when the pasted value\n * includes their `NAME=` context; a bare provider token classifies via its\n * prefix pattern, and a prefix-less bare token (e.g. a raw Mistral key) is\n * genuinely unidentifiable → `null`. `opts.extraPatterns` run AFTER the\n * canonical set (canonical attribution wins). Input is trimmed; empty /\n * whitespace-only → `null`.\n */\nexport function classify(value: string, opts?: RedactOptions): ClassifyResult | null {\n if (!value) return null;\n const v = value.trim();\n if (!v) return null;\n for (const p of patternsFor(opts)) {\n p.regex.lastIndex = 0;\n if (p.regex.test(v)) return { label: p.label, description: p.description };\n }\n return null;\n}\n"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -36,9 +36,21 @@ interface SecretPattern {
|
|
|
36
36
|
}
|
|
37
37
|
/** Ordered most-specific → least. Every regex carries the `g` flag. */
|
|
38
38
|
declare const SECRET_PATTERNS: SecretPattern[];
|
|
39
|
+
/**
|
|
40
|
+
* WHY a finding was flagged — the two detection axes this package has.
|
|
41
|
+
*
|
|
42
|
+
* `format` the VALUE carries the signal: `sk-ant-…`, `ghp_…`, `AKIA…`. Shape
|
|
43
|
+
* alone identifies it, so it is safe to run on anything.
|
|
44
|
+
* `announced` the LABEL carries the signal: `Adgangskode: hunter2`. The value is
|
|
45
|
+
* arbitrary human text with no shape to match, so the only evidence
|
|
46
|
+
* is that someone wrote the word "password" next to it.
|
|
47
|
+
*/
|
|
48
|
+
type SecretConfidence = 'format' | 'announced';
|
|
39
49
|
interface RedactionFinding {
|
|
40
50
|
label: string;
|
|
41
51
|
count: number;
|
|
52
|
+
/** which axis matched — see SecretConfidence. */
|
|
53
|
+
confidence: SecretConfidence;
|
|
42
54
|
}
|
|
43
55
|
interface RedactionResult {
|
|
44
56
|
/** input with every secret replaced by `[REDACTED:<label>]` */
|
|
@@ -52,7 +64,15 @@ interface RedactOptions {
|
|
|
52
64
|
* attribution wins). Backs a future self-service "paste a key → detector" UI.
|
|
53
65
|
*/
|
|
54
66
|
extraPatterns?: SecretPattern[];
|
|
67
|
+
/**
|
|
68
|
+
* Also detect ANNOUNCED secrets — `Adgangskode: hunter2` — where the label is
|
|
69
|
+
* the only evidence. **Off by default, and it must stay that way.** See
|
|
70
|
+
* ANNOUNCED_LABEL for the measurement that decided it.
|
|
71
|
+
*/
|
|
72
|
+
announced?: boolean;
|
|
55
73
|
}
|
|
74
|
+
/** Marker label for a secret detected by its announcing label rather than shape. */
|
|
75
|
+
declare const ANNOUNCED_LABEL = "announced-secret";
|
|
56
76
|
/** Replacement marker for a redacted secret. */
|
|
57
77
|
declare const redactionMarker: (label: string) => string;
|
|
58
78
|
/**
|
|
@@ -60,7 +80,21 @@ declare const redactionMarker: (label: string) => string;
|
|
|
60
80
|
* Pure: clean input returns byte-identical (`findings: []`).
|
|
61
81
|
*/
|
|
62
82
|
declare function redactSecrets(text: string, opts?: RedactOptions): RedactionResult;
|
|
63
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* True if `text` announces a credential by label — `Adgangskode: hunter2` —
|
|
85
|
+
* without building a redaction. Cheap enough to run on every inbound message.
|
|
86
|
+
*
|
|
87
|
+
* This exists because for untrusted inbound text heading to a model, the right
|
|
88
|
+
* response is often to REFUSE rather than redact: a false positive costs a
|
|
89
|
+
* slightly worse classification, a false negative costs a leak. That use needs a
|
|
90
|
+
* boolean, not a redactor. (buddy's reasoning, F035.8.)
|
|
91
|
+
*/
|
|
92
|
+
declare function hasAnnouncedSecret(text: string): boolean;
|
|
93
|
+
/**
|
|
94
|
+
* True if `text` contains at least one detectable secret. Honours
|
|
95
|
+
* `opts.announced` — a caller who asks for the announced axis and is told
|
|
96
|
+
* `false` must be able to believe it.
|
|
97
|
+
*/
|
|
64
98
|
declare function hasSecret(text: string, opts?: RedactOptions): boolean;
|
|
65
99
|
interface ClassifyResult {
|
|
66
100
|
/** the matching pattern's stable label (e.g. `openai-api-key`) */
|
|
@@ -86,4 +120,4 @@ interface ClassifyResult {
|
|
|
86
120
|
*/
|
|
87
121
|
declare function classify(value: string, opts?: RedactOptions): ClassifyResult | null;
|
|
88
122
|
|
|
89
|
-
export { type ClassifyResult, type RedactOptions, type RedactionFinding, type RedactionResult, SECRET_PATTERNS, type SecretPattern, classify, hasSecret, redactSecrets, redactionMarker };
|
|
123
|
+
export { ANNOUNCED_LABEL, type ClassifyResult, type RedactOptions, type RedactionFinding, type RedactionResult, SECRET_PATTERNS, type SecretConfidence, type SecretPattern, classify, hasAnnouncedSecret, hasSecret, redactSecrets, redactionMarker };
|
package/dist/index.d.ts
CHANGED
|
@@ -36,9 +36,21 @@ interface SecretPattern {
|
|
|
36
36
|
}
|
|
37
37
|
/** Ordered most-specific → least. Every regex carries the `g` flag. */
|
|
38
38
|
declare const SECRET_PATTERNS: SecretPattern[];
|
|
39
|
+
/**
|
|
40
|
+
* WHY a finding was flagged — the two detection axes this package has.
|
|
41
|
+
*
|
|
42
|
+
* `format` the VALUE carries the signal: `sk-ant-…`, `ghp_…`, `AKIA…`. Shape
|
|
43
|
+
* alone identifies it, so it is safe to run on anything.
|
|
44
|
+
* `announced` the LABEL carries the signal: `Adgangskode: hunter2`. The value is
|
|
45
|
+
* arbitrary human text with no shape to match, so the only evidence
|
|
46
|
+
* is that someone wrote the word "password" next to it.
|
|
47
|
+
*/
|
|
48
|
+
type SecretConfidence = 'format' | 'announced';
|
|
39
49
|
interface RedactionFinding {
|
|
40
50
|
label: string;
|
|
41
51
|
count: number;
|
|
52
|
+
/** which axis matched — see SecretConfidence. */
|
|
53
|
+
confidence: SecretConfidence;
|
|
42
54
|
}
|
|
43
55
|
interface RedactionResult {
|
|
44
56
|
/** input with every secret replaced by `[REDACTED:<label>]` */
|
|
@@ -52,7 +64,15 @@ interface RedactOptions {
|
|
|
52
64
|
* attribution wins). Backs a future self-service "paste a key → detector" UI.
|
|
53
65
|
*/
|
|
54
66
|
extraPatterns?: SecretPattern[];
|
|
67
|
+
/**
|
|
68
|
+
* Also detect ANNOUNCED secrets — `Adgangskode: hunter2` — where the label is
|
|
69
|
+
* the only evidence. **Off by default, and it must stay that way.** See
|
|
70
|
+
* ANNOUNCED_LABEL for the measurement that decided it.
|
|
71
|
+
*/
|
|
72
|
+
announced?: boolean;
|
|
55
73
|
}
|
|
74
|
+
/** Marker label for a secret detected by its announcing label rather than shape. */
|
|
75
|
+
declare const ANNOUNCED_LABEL = "announced-secret";
|
|
56
76
|
/** Replacement marker for a redacted secret. */
|
|
57
77
|
declare const redactionMarker: (label: string) => string;
|
|
58
78
|
/**
|
|
@@ -60,7 +80,21 @@ declare const redactionMarker: (label: string) => string;
|
|
|
60
80
|
* Pure: clean input returns byte-identical (`findings: []`).
|
|
61
81
|
*/
|
|
62
82
|
declare function redactSecrets(text: string, opts?: RedactOptions): RedactionResult;
|
|
63
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* True if `text` announces a credential by label — `Adgangskode: hunter2` —
|
|
85
|
+
* without building a redaction. Cheap enough to run on every inbound message.
|
|
86
|
+
*
|
|
87
|
+
* This exists because for untrusted inbound text heading to a model, the right
|
|
88
|
+
* response is often to REFUSE rather than redact: a false positive costs a
|
|
89
|
+
* slightly worse classification, a false negative costs a leak. That use needs a
|
|
90
|
+
* boolean, not a redactor. (buddy's reasoning, F035.8.)
|
|
91
|
+
*/
|
|
92
|
+
declare function hasAnnouncedSecret(text: string): boolean;
|
|
93
|
+
/**
|
|
94
|
+
* True if `text` contains at least one detectable secret. Honours
|
|
95
|
+
* `opts.announced` — a caller who asks for the announced axis and is told
|
|
96
|
+
* `false` must be able to believe it.
|
|
97
|
+
*/
|
|
64
98
|
declare function hasSecret(text: string, opts?: RedactOptions): boolean;
|
|
65
99
|
interface ClassifyResult {
|
|
66
100
|
/** the matching pattern's stable label (e.g. `openai-api-key`) */
|
|
@@ -86,4 +120,4 @@ interface ClassifyResult {
|
|
|
86
120
|
*/
|
|
87
121
|
declare function classify(value: string, opts?: RedactOptions): ClassifyResult | null;
|
|
88
122
|
|
|
89
|
-
export { type ClassifyResult, type RedactOptions, type RedactionFinding, type RedactionResult, SECRET_PATTERNS, type SecretPattern, classify, hasSecret, redactSecrets, redactionMarker };
|
|
123
|
+
export { ANNOUNCED_LABEL, type ClassifyResult, type RedactOptions, type RedactionFinding, type RedactionResult, SECRET_PATTERNS, type SecretConfidence, type SecretPattern, classify, hasAnnouncedSecret, hasSecret, redactSecrets, redactionMarker };
|
package/dist/index.js
CHANGED
|
@@ -239,8 +239,25 @@ var SECRET_PATTERNS = [
|
|
|
239
239
|
label: "cloudflare-global-key",
|
|
240
240
|
description: "Cloudflare global API key (37-hex)",
|
|
241
241
|
regex: /\b[0-9a-f]{37}\b/g
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
// LAST on purpose: this is the only unprefixed shape in the list, so every
|
|
245
|
+
// anchored pattern above must get first refusal.
|
|
246
|
+
//
|
|
247
|
+
// Philips Hue v2 application key — 40 chars of [A-Za-z0-9-] with NO prefix,
|
|
248
|
+
// so there is nothing to anchor on. The negative lookahead is load-bearing,
|
|
249
|
+
// not decoration: a bare [A-Za-z0-9-]{40} also matches a GIT COMMIT SHA, and
|
|
250
|
+
// telemetry/error output is full of those. A redactor that eats commit
|
|
251
|
+
// hashes gets switched off within a week, after which it protects nothing.
|
|
252
|
+
// Hue keys are mixed-case; SHAs are lowercase hex — that asymmetry is the
|
|
253
|
+
// whole guard. (Pattern contributed + field-tested by beacon, F035.7.)
|
|
254
|
+
label: "hue-application-key",
|
|
255
|
+
description: "Philips Hue v2 application key (40 chars, no prefix)",
|
|
256
|
+
regex: /\b(?![0-9a-f]{40}\b)[A-Za-z0-9-]{40}\b/g
|
|
242
257
|
}
|
|
243
258
|
];
|
|
259
|
+
var ANNOUNCED_LABEL = "announced-secret";
|
|
260
|
+
var ANNOUNCED_SECRET = /(\b(?:adgangskode|kodeord|hemmelighed|password|passwd|api[ -]?key|apinøgle|secret|kode|pwd)\s*[:=]\s*)(?!\[REDACTED:)\S+/gi;
|
|
244
261
|
var redactionMarker = (label) => `[REDACTED:${label}]`;
|
|
245
262
|
function patternsFor(opts) {
|
|
246
263
|
return opts?.extraPatterns && opts.extraPatterns.length > 0 ? [...SECRET_PATTERNS, ...opts.extraPatterns] : SECRET_PATTERNS;
|
|
@@ -255,11 +272,28 @@ function redactSecrets(text, opts) {
|
|
|
255
272
|
count++;
|
|
256
273
|
return redactionMarker(p.label);
|
|
257
274
|
});
|
|
258
|
-
if (count > 0) findings.push({ label: p.label, count });
|
|
275
|
+
if (count > 0) findings.push({ label: p.label, count, confidence: "format" });
|
|
276
|
+
}
|
|
277
|
+
if (opts?.announced) {
|
|
278
|
+
let count = 0;
|
|
279
|
+
const redactedAnnounced = redacted.replace(ANNOUNCED_SECRET, (_match, prefix) => {
|
|
280
|
+
count++;
|
|
281
|
+
return prefix + redactionMarker(ANNOUNCED_LABEL);
|
|
282
|
+
});
|
|
283
|
+
if (count > 0) {
|
|
284
|
+
redacted = redactedAnnounced;
|
|
285
|
+
findings.push({ label: ANNOUNCED_LABEL, count, confidence: "announced" });
|
|
286
|
+
}
|
|
259
287
|
}
|
|
260
288
|
return { redacted, findings };
|
|
261
289
|
}
|
|
290
|
+
function hasAnnouncedSecret(text) {
|
|
291
|
+
if (!text) return false;
|
|
292
|
+
ANNOUNCED_SECRET.lastIndex = 0;
|
|
293
|
+
return ANNOUNCED_SECRET.test(text);
|
|
294
|
+
}
|
|
262
295
|
function hasSecret(text, opts) {
|
|
296
|
+
if (opts?.announced && hasAnnouncedSecret(text)) return true;
|
|
263
297
|
return patternsFor(opts).some((p) => {
|
|
264
298
|
p.regex.lastIndex = 0;
|
|
265
299
|
return p.regex.test(text);
|
|
@@ -276,6 +310,6 @@ function classify(value, opts) {
|
|
|
276
310
|
return null;
|
|
277
311
|
}
|
|
278
312
|
|
|
279
|
-
export { SECRET_PATTERNS, classify, hasSecret, redactSecrets, redactionMarker };
|
|
313
|
+
export { ANNOUNCED_LABEL, SECRET_PATTERNS, classify, hasAnnouncedSecret, hasSecret, redactSecrets, redactionMarker };
|
|
280
314
|
//# sourceMappingURL=index.js.map
|
|
281
315
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @broberg/secret-scan — fleet secret/credential redaction.\n *\n * `redactSecrets(text)` replaces every matched secret with `[REDACTED:<label>]`\n * and reports what it found. PURE + deterministic (regex/string only, no deps,\n * no I/O) so an engine write-gate, an egress scrub, a CLI, an admin preview UI,\n * and any repo all share the EXACT same detection — and it's trivially testable.\n *\n * Lifted verbatim from broberg/trail F197 (the second-brain safeguard); see\n * docs/features/F035-secret-scan.md. components owns + publishes this; @trail/shared\n * re-exports it.\n *\n * Design choices:\n * - Pattern-based, NOT entropy/generic-randomness — a redacted real fact would\n * corrupt knowledge, so we accept missing an exotic token over false positives.\n * - Order matters: most-specific patterns run first (e.g. `sk-ant-` before the\n * generic OpenAI `sk-`; `sk-or-v1-` before `sk-`), because each match is\n * consumed before the next pattern runs → order = attribution.\n * - Redact, never reject — the surrounding knowledge survives; only the\n * credential substring is neutralised.\n * - NEVER a bare high-entropy/hex pattern (it would hit git shas/hashes).\n * Prefix-less service secrets are caught only via `labeled-hex-secret` (a 40+\n * hex value assigned to a secret/token/password/api-key-named field).\n *\n * Two recommended integration shapes for consumers:\n * (a) write boundary — `redactSecrets(text)` before persist (ingest gate);\n * (b) egress — scrub before a value leaves to a user/LLM (highest-value guard).\n */\n\nexport interface SecretPattern {\n /** stable id shown in the redaction marker + findings */\n label: string;\n /** human description of what this matches */\n description: string;\n /** global regex (used for replace-all + counting) */\n regex: RegExp;\n}\n\n/** Ordered most-specific → least. Every regex carries the `g` flag. */\nexport const SECRET_PATTERNS: SecretPattern[] = [\n {\n label: 'private-key',\n description: 'PEM private key block (RSA/EC/OPENSSH/DSA/PGP)',\n regex:\n /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\\s\\S]*?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g,\n },\n {\n label: 'anthropic-api-key',\n description: 'Anthropic API key (sk-ant-…)',\n regex: /sk-ant-(?:api03-)?[A-Za-z0-9_-]{20,}/g,\n },\n {\n // OpenRouter — distinct from OpenAI; runs BEFORE the generic sk- (which would\n // otherwise also match + mislabel it).\n label: 'openrouter-api-key',\n description: 'OpenRouter API key (sk-or-v1- + 64 hex)',\n regex: /\\bsk-or-v1-[0-9a-f]{64}/g,\n },\n {\n // DeepSeek — shares the sk- prefix with OpenAI, so it MUST run before the\n // generic openai pattern (specific-before-generic = correct attribution).\n // DeepSeek's documented shape is sk- + 32 lowercase hex (GitGuardian confirms\n // an sk- prefix but hides the exact regex); the hex-only body + {32,} length\n // distinguishes it from OpenAI's mixed-case base62 keys, so a real OpenAI key\n // is never mislabelled. The field-anchored fallback below catches any\n // DEEPSEEK_API_KEY value that doesn't fit this canonical shape.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (sk- + 32 lowercase hex)',\n regex: /\\bsk-[0-9a-f]{32,}(?![0-9a-z])/g,\n },\n {\n label: 'openai-api-key',\n description: 'OpenAI API key (sk-… / sk-proj-…)',\n regex: /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/g,\n },\n {\n // ElevenLabs — sk_ with UNDERSCORE (vs OpenAI sk-), 48 hex.\n label: 'elevenlabs-api-key',\n description: 'ElevenLabs API key (sk_ + 48 hex)',\n regex: /\\bsk_[0-9a-f]{48}\\b/g,\n },\n {\n // fal.ai — uuid:hex32 (key_id:key_secret); the colon is the signal.\n label: 'fal-api-key',\n description: 'fal.ai key (uuid:hex32)',\n regex: /\\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:[0-9a-f]{32}\\b/g,\n },\n {\n // Black Forest Labs (FLUX) API key — bfl_ prefix + a long token (sample\n // bfl_Qo1…). The distinctive prefix + {20,} length keeps false positives near\n // zero; image-provider sibling of the fal key above.\n label: 'bfl-api-key',\n description: 'Black Forest Labs / FLUX API key (bfl_ + token)',\n regex: /\\bbfl_[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'google-api-key',\n description: 'Google / Gemini API key (AIza…)',\n regex: /AIza[0-9A-Za-z_-]{35}/g,\n },\n {\n label: 'google-oauth-secret',\n description: 'Google OAuth client secret (GOCSPX-…)',\n regex: /GOCSPX-[A-Za-z0-9_-]{28}/g,\n },\n {\n label: 'aws-access-key-id',\n description: 'AWS access key id (AKIA…)',\n regex: /\\bAKIA[0-9A-Z]{16}\\b/g,\n },\n {\n label: 'github-token',\n description: 'GitHub token (ghp_/gho_/ghs_/ghu_/ghr_…)',\n regex: /\\bgh[posru]_[A-Za-z0-9]{36,}\\b/g,\n },\n {\n // GitHub fine-grained PAT — distinct prefix `github_pat_` (not caught by the\n // classic gh[posru]_ above), then base62 + a `_` separator (~82 chars total).\n // The prefix is so distinctive that {50,} keeps false positives at zero.\n label: 'github-fine-grained-pat',\n description: 'GitHub fine-grained personal access token (github_pat_…)',\n regex: /\\bgithub_pat_[A-Za-z0-9_]{50,}/g,\n },\n {\n label: 'gitlab-token',\n description: 'GitLab personal access token (glpat-…)',\n regex: /\\bglpat-[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'slack-token',\n description: 'Slack token (xox[baprs]-…)',\n regex: /\\bxox[baprs]-[A-Za-z0-9-]{10,}/g,\n },\n {\n label: 'stripe-secret-key',\n description: 'Stripe live secret/restricted key (sk_live_/rk_live_…)',\n regex: /\\b[rs]k_live_[A-Za-z0-9]{20,}/g,\n },\n {\n // Resend (re_…). Lookahead requires a digit in the body so we don't redact\n // long snake_case identifiers like re_compute_the_thing.\n label: 'resend-api-key',\n description: 'Resend API key (re_ + token)',\n regex: /\\bre_(?=[A-Za-z0-9_]*\\d)[A-Za-z0-9_]{24,}\\b/g,\n },\n {\n label: 'supabase-access-token',\n description: 'Supabase personal/management access token (sbp_ + 40 hex)',\n regex: /\\bsbp_[0-9a-f]{40}/g,\n },\n {\n label: 'supabase-secret-key',\n description: 'Supabase secret API key (sb_secret_…)',\n regex: /\\bsb_secret_[A-Za-z0-9_-]{20,}/g,\n },\n {\n // Used by every @broberg/* publish — the highest-value leak from a .env / commit history.\n label: 'npm-token',\n description: 'npm publish/automation token (npm_ + 36 base62)',\n regex: /\\bnpm_[A-Za-z0-9]{36}\\b/g,\n },\n {\n label: 'fly-api-token',\n description: 'Fly.io API token (FlyV1 fm2_… / fo1_…)',\n regex: /(?:FlyV1 fm2_[A-Za-z0-9+/=_-]{20,}|\\bfo1_[A-Za-z0-9_-]{20,})/g,\n },\n {\n // Also covers Turso DB/platform auth tokens AND Supabase anon/service_role\n // keys — both are JWTs (eyJ…), so the single JWT pattern catches them.\n label: 'jwt',\n description: 'JSON Web Token (eyJ…) — incl. Turso + Supabase service_role tokens',\n regex: /\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}/g,\n },\n {\n // genApiKey = randomBytes(24).hex → uk_ + exactly 48 lowercase hex.\n label: 'upmetrics-key',\n description: 'Upmetrics project key (uk_ + 48 hex)',\n regex: /\\buk_[0-9a-f]{48}/g,\n },\n {\n label: 'cardmem-key',\n description: 'Cardmem personal/incident/project key (pa_/pi_/pk_ + 64 hex)',\n regex: /\\bp[aik]_[A-Za-z0-9]{20,}/g,\n },\n {\n // cardmem inbox-webhook key — piw_ isn't matched by p[aik]_ above (3rd char 'w' ≠ '_').\n label: 'cardmem-webhook-key',\n description: 'Cardmem inbox-webhook key (piw_ + 64 hex)',\n regex: /\\bpiw_[0-9a-f]{64}/g,\n },\n {\n label: 'trail-key',\n description: 'Trail personal API key (trail_…)',\n regex: /\\btrail_[A-Za-z0-9]{20,}/g,\n },\n {\n // Cronjobs API key (cronjobs.webhouse.net) — cj_ + randomBytes(32).base64url =\n // exactly 43 base64url chars (46 total). Prefix + fixed length = very low FP.\n // The UI's truncated cj_<8 chars>… preview is shorter than {43} → not matched.\n // Negative lookahead (not \\b) because base64url's `-` breaks a trailing \\b.\n label: 'cronjobs-api-key',\n description: 'Cronjobs API key (cj_ + 43 base64url)',\n regex: /\\bcj_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g,\n },\n {\n // randomBytes(32).hex → wh_ + 64 lowercase hex (67 chars total).\n label: 'cms-access-token',\n description: 'webhouse.app CMS access token (wh_ + 64 hex)',\n regex: /\\bwh_[0-9a-f]{64}/g,\n },\n {\n // Cloudflare API token (R2 / DNS management) — 40 base64url chars, NO prefix.\n // A bare {40} would false-positive broadly, so this is CONTEXT-ONLY: it only\n // fires next to a cf/cloudflare-api-token-named field. Runs before\n // labeled-hex-secret so a hex-valued CF token is attributed correctly.\n label: 'cloudflare-api-token',\n description: 'Cloudflare API token (cf/cloudflare-api-token field + 40 base64url)',\n regex: /\\b(?:cf|cloudflare)_?api_?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{40}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Mistral API key — prefix-less ~32 base62 (Christian-confirmed sample). A bare\n // [A-Za-z0-9]{32} would FP on every ID/hash, so CONTEXT-ONLY: anchored on a\n // mistral-(api-)key/token-named field. Runs before labeled-hex for attribution.\n label: 'mistral-api-key',\n description: 'Mistral API key (mistral-(api-)key/token field + 24+ base62)',\n regex: /\\bmistral(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{24,}(?![A-Za-z0-9])/gi,\n },\n {\n // DeepSeek — field-anchored fallback for any DEEPSEEK_API_KEY/TOKEN value that\n // doesn't fit the canonical sk-+hex shape (mirrors the Mistral context-only\n // approach). The field name is the signal → near-zero false positives. The\n // sk-+hex format pattern above already attributes the canonical shape; this\n // backstops a format change or an opaque token.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (deepseek-(api-)key/token field + 20+ token)',\n regex: /\\bdeepseek(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Vimeo personal access token — ~32 lowercase hex, no prefix (sanne). A bare\n // hex32 would FP massively (MD5/UUID), so CONTEXT-ONLY: anchored on a\n // vimeo-(access-)token-named field.\n label: 'vimeo-access-token',\n description: 'Vimeo access token (vimeo-(access-)token field + 20+ base62)',\n regex: /\\bvimeo(?:[_-]?access)?[_-]?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{20,}(?![A-Za-z0-9])/gi,\n },\n {\n // Context-based catch for prefix-less high-entropy service secrets\n // (CMS_JWT_SECRET, revalidateSecret, fleet openssl-rand-hex secrets): a 40+\n // hex value assigned to a field whose name contains\n // secret/token/password/api-key. The name requirement keeps the\n // false-positive rate near zero (a bare 40/64-hex would hit shas/hashes).\n label: 'labeled-hex-secret',\n description: 'A 40+ hex value assigned to a secret/token/password/api-key-named field',\n regex: /\\b[A-Za-z0-9_-]*(?:secret|token|password|api[_-]?key)\\b\\s*[:=]\\s*[\"'`]?[0-9a-f]{40,}/gi,\n },\n {\n // Discord bot token — three base64url segments. Anchored both sides so it\n // can't partial-match a longer dotted string.\n label: 'discord-bot-token',\n description: 'Discord bot token (3 base64url segments)',\n regex: /(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{24,26}\\.[A-Za-z0-9_-]{6}\\.[A-Za-z0-9_-]{27,40}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'discord-mfa-token',\n description: 'Discord MFA token (mfa. + 84 chars)',\n regex: /\\bmfa\\.[A-Za-z0-9_-]{84}\\b/g,\n },\n {\n // Cloudflare Turnstile PROD secret (sanne, verified 2/2) — 0x4 + 6×A prefix,\n // then 26 base64url (35 total). The 24-char SITE key + 1x/2x/3x TEST keys are\n // intentionally NOT matched (the {26} length gate misses them) so a public\n // key is never redacted.\n label: 'cloudflare-turnstile-secret',\n description: 'Cloudflare Turnstile secret key (0x4AAAAAA + 26 base64url, 35 total)',\n regex: /0x4AAAAAA[A-Za-z0-9_-]{26}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'cloudflare-global-key',\n description: 'Cloudflare global API key (37-hex)',\n regex: /\\b[0-9a-f]{37}\\b/g,\n },\n];\n\nexport interface RedactionFinding {\n label: string;\n count: number;\n}\n\nexport interface RedactionResult {\n /** input with every secret replaced by `[REDACTED:<label>]` */\n redacted: string;\n /** per-pattern counts of what was redacted (empty = clean) */\n findings: RedactionFinding[];\n}\n\nexport interface RedactOptions {\n /**\n * Extra consumer/per-tenant patterns, run AFTER the canonical set (so canonical\n * attribution wins). Backs a future self-service \"paste a key → detector\" UI.\n */\n extraPatterns?: SecretPattern[];\n}\n\n/** Replacement marker for a redacted secret. */\nexport const redactionMarker = (label: string): string => `[REDACTED:${label}]`;\n\nfunction patternsFor(opts?: RedactOptions): SecretPattern[] {\n return opts?.extraPatterns && opts.extraPatterns.length > 0\n ? [...SECRET_PATTERNS, ...opts.extraPatterns]\n : SECRET_PATTERNS;\n}\n\n/**\n * Scan `text` and replace every detected secret with its redaction marker.\n * Pure: clean input returns byte-identical (`findings: []`).\n */\nexport function redactSecrets(text: string, opts?: RedactOptions): RedactionResult {\n if (!text) return { redacted: text, findings: [] };\n let redacted = text;\n const findings: RedactionFinding[] = [];\n for (const p of patternsFor(opts)) {\n let count = 0;\n redacted = redacted.replace(p.regex, () => {\n count++;\n return redactionMarker(p.label);\n });\n if (count > 0) findings.push({ label: p.label, count });\n }\n return { redacted, findings };\n}\n\n/** True if `text` contains at least one detectable secret. */\nexport function hasSecret(text: string, opts?: RedactOptions): boolean {\n return patternsFor(opts).some((p) => {\n p.regex.lastIndex = 0;\n return p.regex.test(text);\n });\n}\n\nexport interface ClassifyResult {\n /** the matching pattern's stable label (e.g. `openai-api-key`) */\n label: string;\n /** the matching pattern's human description (e.g. `OpenAI API key (sk-… / sk-proj-…)`) */\n description: string;\n}\n\n/**\n * Classify a SINGLE pasted token — the INVERSE of redaction. Returns the first\n * (most-specific) pattern the value matches, or `null`. Backs a \"paste a key →\n * detect its type\" UI (cardmem F214 Secrets Vault) so every consumer shares the\n * same classification, not just the same redaction.\n *\n * First-match-wins over the ordered `SECRET_PATTERNS`, so `sk-ant-…` classifies\n * as `anthropic-api-key`, never the generic `openai-api-key`. Field-anchored\n * context-only patterns (mistral / vimeo / cloudflare-api-token /\n * labeled-hex-secret / deepseek-fallback) only match when the pasted value\n * includes their `NAME=` context; a bare provider token classifies via its\n * prefix pattern, and a prefix-less bare token (e.g. a raw Mistral key) is\n * genuinely unidentifiable → `null`. `opts.extraPatterns` run AFTER the\n * canonical set (canonical attribution wins). Input is trimmed; empty /\n * whitespace-only → `null`.\n */\nexport function classify(value: string, opts?: RedactOptions): ClassifyResult | null {\n if (!value) return null;\n const v = value.trim();\n if (!v) return null;\n for (const p of patternsFor(opts)) {\n p.regex.lastIndex = 0;\n if (p.regex.test(v)) return { label: p.label, description: p.description };\n }\n return null;\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @broberg/secret-scan — fleet secret/credential redaction.\n *\n * `redactSecrets(text)` replaces every matched secret with `[REDACTED:<label>]`\n * and reports what it found. PURE + deterministic (regex/string only, no deps,\n * no I/O) so an engine write-gate, an egress scrub, a CLI, an admin preview UI,\n * and any repo all share the EXACT same detection — and it's trivially testable.\n *\n * Lifted verbatim from broberg/trail F197 (the second-brain safeguard); see\n * docs/features/F035-secret-scan.md. components owns + publishes this; @trail/shared\n * re-exports it.\n *\n * Design choices:\n * - Pattern-based, NOT entropy/generic-randomness — a redacted real fact would\n * corrupt knowledge, so we accept missing an exotic token over false positives.\n * - Order matters: most-specific patterns run first (e.g. `sk-ant-` before the\n * generic OpenAI `sk-`; `sk-or-v1-` before `sk-`), because each match is\n * consumed before the next pattern runs → order = attribution.\n * - Redact, never reject — the surrounding knowledge survives; only the\n * credential substring is neutralised.\n * - NEVER a bare high-entropy/hex pattern (it would hit git shas/hashes).\n * Prefix-less service secrets are caught only via `labeled-hex-secret` (a 40+\n * hex value assigned to a secret/token/password/api-key-named field).\n *\n * Two recommended integration shapes for consumers:\n * (a) write boundary — `redactSecrets(text)` before persist (ingest gate);\n * (b) egress — scrub before a value leaves to a user/LLM (highest-value guard).\n */\n\nexport interface SecretPattern {\n /** stable id shown in the redaction marker + findings */\n label: string;\n /** human description of what this matches */\n description: string;\n /** global regex (used for replace-all + counting) */\n regex: RegExp;\n}\n\n/** Ordered most-specific → least. Every regex carries the `g` flag. */\nexport const SECRET_PATTERNS: SecretPattern[] = [\n {\n label: 'private-key',\n description: 'PEM private key block (RSA/EC/OPENSSH/DSA/PGP)',\n regex:\n /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\\s\\S]*?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g,\n },\n {\n label: 'anthropic-api-key',\n description: 'Anthropic API key (sk-ant-…)',\n regex: /sk-ant-(?:api03-)?[A-Za-z0-9_-]{20,}/g,\n },\n {\n // OpenRouter — distinct from OpenAI; runs BEFORE the generic sk- (which would\n // otherwise also match + mislabel it).\n label: 'openrouter-api-key',\n description: 'OpenRouter API key (sk-or-v1- + 64 hex)',\n regex: /\\bsk-or-v1-[0-9a-f]{64}/g,\n },\n {\n // DeepSeek — shares the sk- prefix with OpenAI, so it MUST run before the\n // generic openai pattern (specific-before-generic = correct attribution).\n // DeepSeek's documented shape is sk- + 32 lowercase hex (GitGuardian confirms\n // an sk- prefix but hides the exact regex); the hex-only body + {32,} length\n // distinguishes it from OpenAI's mixed-case base62 keys, so a real OpenAI key\n // is never mislabelled. The field-anchored fallback below catches any\n // DEEPSEEK_API_KEY value that doesn't fit this canonical shape.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (sk- + 32 lowercase hex)',\n regex: /\\bsk-[0-9a-f]{32,}(?![0-9a-z])/g,\n },\n {\n label: 'openai-api-key',\n description: 'OpenAI API key (sk-… / sk-proj-…)',\n regex: /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/g,\n },\n {\n // ElevenLabs — sk_ with UNDERSCORE (vs OpenAI sk-), 48 hex.\n label: 'elevenlabs-api-key',\n description: 'ElevenLabs API key (sk_ + 48 hex)',\n regex: /\\bsk_[0-9a-f]{48}\\b/g,\n },\n {\n // fal.ai — uuid:hex32 (key_id:key_secret); the colon is the signal.\n label: 'fal-api-key',\n description: 'fal.ai key (uuid:hex32)',\n regex: /\\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:[0-9a-f]{32}\\b/g,\n },\n {\n // Black Forest Labs (FLUX) API key — bfl_ prefix + a long token (sample\n // bfl_Qo1…). The distinctive prefix + {20,} length keeps false positives near\n // zero; image-provider sibling of the fal key above.\n label: 'bfl-api-key',\n description: 'Black Forest Labs / FLUX API key (bfl_ + token)',\n regex: /\\bbfl_[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'google-api-key',\n description: 'Google / Gemini API key (AIza…)',\n regex: /AIza[0-9A-Za-z_-]{35}/g,\n },\n {\n label: 'google-oauth-secret',\n description: 'Google OAuth client secret (GOCSPX-…)',\n regex: /GOCSPX-[A-Za-z0-9_-]{28}/g,\n },\n {\n label: 'aws-access-key-id',\n description: 'AWS access key id (AKIA…)',\n regex: /\\bAKIA[0-9A-Z]{16}\\b/g,\n },\n {\n label: 'github-token',\n description: 'GitHub token (ghp_/gho_/ghs_/ghu_/ghr_…)',\n regex: /\\bgh[posru]_[A-Za-z0-9]{36,}\\b/g,\n },\n {\n // GitHub fine-grained PAT — distinct prefix `github_pat_` (not caught by the\n // classic gh[posru]_ above), then base62 + a `_` separator (~82 chars total).\n // The prefix is so distinctive that {50,} keeps false positives at zero.\n label: 'github-fine-grained-pat',\n description: 'GitHub fine-grained personal access token (github_pat_…)',\n regex: /\\bgithub_pat_[A-Za-z0-9_]{50,}/g,\n },\n {\n label: 'gitlab-token',\n description: 'GitLab personal access token (glpat-…)',\n regex: /\\bglpat-[A-Za-z0-9_-]{20,}/g,\n },\n {\n label: 'slack-token',\n description: 'Slack token (xox[baprs]-…)',\n regex: /\\bxox[baprs]-[A-Za-z0-9-]{10,}/g,\n },\n {\n label: 'stripe-secret-key',\n description: 'Stripe live secret/restricted key (sk_live_/rk_live_…)',\n regex: /\\b[rs]k_live_[A-Za-z0-9]{20,}/g,\n },\n {\n // Resend (re_…). Lookahead requires a digit in the body so we don't redact\n // long snake_case identifiers like re_compute_the_thing.\n label: 'resend-api-key',\n description: 'Resend API key (re_ + token)',\n regex: /\\bre_(?=[A-Za-z0-9_]*\\d)[A-Za-z0-9_]{24,}\\b/g,\n },\n {\n label: 'supabase-access-token',\n description: 'Supabase personal/management access token (sbp_ + 40 hex)',\n regex: /\\bsbp_[0-9a-f]{40}/g,\n },\n {\n label: 'supabase-secret-key',\n description: 'Supabase secret API key (sb_secret_…)',\n regex: /\\bsb_secret_[A-Za-z0-9_-]{20,}/g,\n },\n {\n // Used by every @broberg/* publish — the highest-value leak from a .env / commit history.\n label: 'npm-token',\n description: 'npm publish/automation token (npm_ + 36 base62)',\n regex: /\\bnpm_[A-Za-z0-9]{36}\\b/g,\n },\n {\n label: 'fly-api-token',\n description: 'Fly.io API token (FlyV1 fm2_… / fo1_…)',\n regex: /(?:FlyV1 fm2_[A-Za-z0-9+/=_-]{20,}|\\bfo1_[A-Za-z0-9_-]{20,})/g,\n },\n {\n // Also covers Turso DB/platform auth tokens AND Supabase anon/service_role\n // keys — both are JWTs (eyJ…), so the single JWT pattern catches them.\n label: 'jwt',\n description: 'JSON Web Token (eyJ…) — incl. Turso + Supabase service_role tokens',\n regex: /\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}/g,\n },\n {\n // genApiKey = randomBytes(24).hex → uk_ + exactly 48 lowercase hex.\n label: 'upmetrics-key',\n description: 'Upmetrics project key (uk_ + 48 hex)',\n regex: /\\buk_[0-9a-f]{48}/g,\n },\n {\n label: 'cardmem-key',\n description: 'Cardmem personal/incident/project key (pa_/pi_/pk_ + 64 hex)',\n regex: /\\bp[aik]_[A-Za-z0-9]{20,}/g,\n },\n {\n // cardmem inbox-webhook key — piw_ isn't matched by p[aik]_ above (3rd char 'w' ≠ '_').\n label: 'cardmem-webhook-key',\n description: 'Cardmem inbox-webhook key (piw_ + 64 hex)',\n regex: /\\bpiw_[0-9a-f]{64}/g,\n },\n {\n label: 'trail-key',\n description: 'Trail personal API key (trail_…)',\n regex: /\\btrail_[A-Za-z0-9]{20,}/g,\n },\n {\n // Cronjobs API key (cronjobs.webhouse.net) — cj_ + randomBytes(32).base64url =\n // exactly 43 base64url chars (46 total). Prefix + fixed length = very low FP.\n // The UI's truncated cj_<8 chars>… preview is shorter than {43} → not matched.\n // Negative lookahead (not \\b) because base64url's `-` breaks a trailing \\b.\n label: 'cronjobs-api-key',\n description: 'Cronjobs API key (cj_ + 43 base64url)',\n regex: /\\bcj_[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g,\n },\n {\n // randomBytes(32).hex → wh_ + 64 lowercase hex (67 chars total).\n label: 'cms-access-token',\n description: 'webhouse.app CMS access token (wh_ + 64 hex)',\n regex: /\\bwh_[0-9a-f]{64}/g,\n },\n {\n // Cloudflare API token (R2 / DNS management) — 40 base64url chars, NO prefix.\n // A bare {40} would false-positive broadly, so this is CONTEXT-ONLY: it only\n // fires next to a cf/cloudflare-api-token-named field. Runs before\n // labeled-hex-secret so a hex-valued CF token is attributed correctly.\n label: 'cloudflare-api-token',\n description: 'Cloudflare API token (cf/cloudflare-api-token field + 40 base64url)',\n regex: /\\b(?:cf|cloudflare)_?api_?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{40}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Mistral API key — prefix-less ~32 base62 (Christian-confirmed sample). A bare\n // [A-Za-z0-9]{32} would FP on every ID/hash, so CONTEXT-ONLY: anchored on a\n // mistral-(api-)key/token-named field. Runs before labeled-hex for attribution.\n label: 'mistral-api-key',\n description: 'Mistral API key (mistral-(api-)key/token field + 24+ base62)',\n regex: /\\bmistral(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{24,}(?![A-Za-z0-9])/gi,\n },\n {\n // DeepSeek — field-anchored fallback for any DEEPSEEK_API_KEY/TOKEN value that\n // doesn't fit the canonical sk-+hex shape (mirrors the Mistral context-only\n // approach). The field name is the signal → near-zero false positives. The\n // sk-+hex format pattern above already attributes the canonical shape; this\n // backstops a format change or an opaque token.\n label: 'deepseek-api-key',\n description: 'DeepSeek API key (deepseek-(api-)key/token field + 20+ token)',\n regex: /\\bdeepseek(?:[_-]?api)?[_-]?(?:key|token)\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])/gi,\n },\n {\n // Vimeo personal access token — ~32 lowercase hex, no prefix (sanne). A bare\n // hex32 would FP massively (MD5/UUID), so CONTEXT-ONLY: anchored on a\n // vimeo-(access-)token-named field.\n label: 'vimeo-access-token',\n description: 'Vimeo access token (vimeo-(access-)token field + 20+ base62)',\n regex: /\\bvimeo(?:[_-]?access)?[_-]?token\\b\\s*[:=]\\s*[\"'`]?[A-Za-z0-9]{20,}(?![A-Za-z0-9])/gi,\n },\n {\n // Context-based catch for prefix-less high-entropy service secrets\n // (CMS_JWT_SECRET, revalidateSecret, fleet openssl-rand-hex secrets): a 40+\n // hex value assigned to a field whose name contains\n // secret/token/password/api-key. The name requirement keeps the\n // false-positive rate near zero (a bare 40/64-hex would hit shas/hashes).\n label: 'labeled-hex-secret',\n description: 'A 40+ hex value assigned to a secret/token/password/api-key-named field',\n regex: /\\b[A-Za-z0-9_-]*(?:secret|token|password|api[_-]?key)\\b\\s*[:=]\\s*[\"'`]?[0-9a-f]{40,}/gi,\n },\n {\n // Discord bot token — three base64url segments. Anchored both sides so it\n // can't partial-match a longer dotted string.\n label: 'discord-bot-token',\n description: 'Discord bot token (3 base64url segments)',\n regex: /(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{24,26}\\.[A-Za-z0-9_-]{6}\\.[A-Za-z0-9_-]{27,40}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'discord-mfa-token',\n description: 'Discord MFA token (mfa. + 84 chars)',\n regex: /\\bmfa\\.[A-Za-z0-9_-]{84}\\b/g,\n },\n {\n // Cloudflare Turnstile PROD secret (sanne, verified 2/2) — 0x4 + 6×A prefix,\n // then 26 base64url (35 total). The 24-char SITE key + 1x/2x/3x TEST keys are\n // intentionally NOT matched (the {26} length gate misses them) so a public\n // key is never redacted.\n label: 'cloudflare-turnstile-secret',\n description: 'Cloudflare Turnstile secret key (0x4AAAAAA + 26 base64url, 35 total)',\n regex: /0x4AAAAAA[A-Za-z0-9_-]{26}(?![A-Za-z0-9_-])/g,\n },\n {\n label: 'cloudflare-global-key',\n description: 'Cloudflare global API key (37-hex)',\n regex: /\\b[0-9a-f]{37}\\b/g,\n },\n {\n // LAST on purpose: this is the only unprefixed shape in the list, so every\n // anchored pattern above must get first refusal.\n //\n // Philips Hue v2 application key — 40 chars of [A-Za-z0-9-] with NO prefix,\n // so there is nothing to anchor on. The negative lookahead is load-bearing,\n // not decoration: a bare [A-Za-z0-9-]{40} also matches a GIT COMMIT SHA, and\n // telemetry/error output is full of those. A redactor that eats commit\n // hashes gets switched off within a week, after which it protects nothing.\n // Hue keys are mixed-case; SHAs are lowercase hex — that asymmetry is the\n // whole guard. (Pattern contributed + field-tested by beacon, F035.7.)\n label: 'hue-application-key',\n description: 'Philips Hue v2 application key (40 chars, no prefix)',\n regex: /\\b(?![0-9a-f]{40}\\b)[A-Za-z0-9-]{40}\\b/g,\n },\n];\n\n/**\n * WHY a finding was flagged — the two detection axes this package has.\n *\n * `format` the VALUE carries the signal: `sk-ant-…`, `ghp_…`, `AKIA…`. Shape\n * alone identifies it, so it is safe to run on anything.\n * `announced` the LABEL carries the signal: `Adgangskode: hunter2`. The value is\n * arbitrary human text with no shape to match, so the only evidence\n * is that someone wrote the word \"password\" next to it.\n */\nexport type SecretConfidence = 'format' | 'announced';\n\nexport interface RedactionFinding {\n label: string;\n count: number;\n /** which axis matched — see SecretConfidence. */\n confidence: SecretConfidence;\n}\n\nexport interface RedactionResult {\n /** input with every secret replaced by `[REDACTED:<label>]` */\n redacted: string;\n /** per-pattern counts of what was redacted (empty = clean) */\n findings: RedactionFinding[];\n}\n\nexport interface RedactOptions {\n /**\n * Extra consumer/per-tenant patterns, run AFTER the canonical set (so canonical\n * attribution wins). Backs a future self-service \"paste a key → detector\" UI.\n */\n extraPatterns?: SecretPattern[];\n /**\n * Also detect ANNOUNCED secrets — `Adgangskode: hunter2` — where the label is\n * the only evidence. **Off by default, and it must stay that way.** See\n * ANNOUNCED_LABEL for the measurement that decided it.\n */\n announced?: boolean;\n}\n\n/** Marker label for a secret detected by its announcing label rather than shape. */\nexport const ANNOUNCED_LABEL = 'announced-secret';\n\n/**\n * Label + separator + value. The label list is deliberately short and concrete;\n * this is not a general \"looks like config\" detector.\n *\n * WHY THIS IS OPT-IN, MEASURED RATHER THAN GUESSED. Over this repo on\n * 2026-08-14 — 548 tracked files, 544 readable as text, containing essentially\n * no real secrets — this exact regex matched **97 times**, and every one was\n * noise. Per label: `secret` 61, `api key` 33, `password` 4, and every Danish\n * label 0. So 94 of the 97 are the two words that are also ordinary IDENTIFIERS\n * in source code (`secret: config.secret`, `apiKey: Record<…>`).\n *\n * That is the real finding, and it is sharper than \"the pattern is noisy\": its\n * precision depends entirely on WHAT IS BEING SCANNED. In an inbound mail body\n * — buddy's actual case — `Adgangskode:` is a strong signal. In a TypeScript\n * file it is a variable name. **The package cannot know which corpus it is\n * looking at; only the caller can.** So the caller makes the decision, and the\n * default cannot be on. (This is the opposite of this repo's usual defaults-ON\n * stance — webpush F067.1, lens-engine F065 — and the numbers above are why.)\n *\n * A broader label+separator+value pattern measured 305 on the same corpus, and\n * refining it only reached 202 — no amount of tuning makes a generic version\n * safe. A template/env-reference guard (`${FOO}`, `<your-key>`) was written and\n * then dropped: it changed the count by exactly 0, because the noise here is\n * identifiers, not templates.\n *\n * The value must not already be a redaction marker, so this can run AFTER the\n * format pass without flattening its more specific attribution.\n */\nconst ANNOUNCED_SECRET =\n /(\\b(?:adgangskode|kodeord|hemmelighed|password|passwd|api[ -]?key|apinøgle|secret|kode|pwd)\\s*[:=]\\s*)(?!\\[REDACTED:)\\S+/gi;\n\n/** Replacement marker for a redacted secret. */\nexport const redactionMarker = (label: string): string => `[REDACTED:${label}]`;\n\nfunction patternsFor(opts?: RedactOptions): SecretPattern[] {\n return opts?.extraPatterns && opts.extraPatterns.length > 0\n ? [...SECRET_PATTERNS, ...opts.extraPatterns]\n : SECRET_PATTERNS;\n}\n\n/**\n * Scan `text` and replace every detected secret with its redaction marker.\n * Pure: clean input returns byte-identical (`findings: []`).\n */\nexport function redactSecrets(text: string, opts?: RedactOptions): RedactionResult {\n if (!text) return { redacted: text, findings: [] };\n let redacted = text;\n const findings: RedactionFinding[] = [];\n for (const p of patternsFor(opts)) {\n let count = 0;\n redacted = redacted.replace(p.regex, () => {\n count++;\n return redactionMarker(p.label);\n });\n if (count > 0) findings.push({ label: p.label, count, confidence: 'format' });\n }\n // Announced runs LAST, and only on request. Order is not cosmetic: the format\n // pass has already replaced everything it recognises, and this regex refuses a\n // value that is already a marker — so `API key: sk-ant-…` keeps its specific\n // `anthropic-api-key` attribution instead of being flattened to a generic one.\n // The announcing label itself is KEPT in the output; only the value goes, so\n // the redacted text still reads `Adgangskode: [REDACTED:announced-secret]` and\n // a human or model reading it can still tell what was removed.\n if (opts?.announced) {\n let count = 0;\n const redactedAnnounced = redacted.replace(ANNOUNCED_SECRET, (_match, prefix: string) => {\n count++;\n return prefix + redactionMarker(ANNOUNCED_LABEL);\n });\n if (count > 0) {\n redacted = redactedAnnounced;\n findings.push({ label: ANNOUNCED_LABEL, count, confidence: 'announced' });\n }\n }\n return { redacted, findings };\n}\n\n/**\n * True if `text` announces a credential by label — `Adgangskode: hunter2` —\n * without building a redaction. Cheap enough to run on every inbound message.\n *\n * This exists because for untrusted inbound text heading to a model, the right\n * response is often to REFUSE rather than redact: a false positive costs a\n * slightly worse classification, a false negative costs a leak. That use needs a\n * boolean, not a redactor. (buddy's reasoning, F035.8.)\n */\nexport function hasAnnouncedSecret(text: string): boolean {\n if (!text) return false;\n ANNOUNCED_SECRET.lastIndex = 0;\n return ANNOUNCED_SECRET.test(text);\n}\n\n/**\n * True if `text` contains at least one detectable secret. Honours\n * `opts.announced` — a caller who asks for the announced axis and is told\n * `false` must be able to believe it.\n */\nexport function hasSecret(text: string, opts?: RedactOptions): boolean {\n if (opts?.announced && hasAnnouncedSecret(text)) return true;\n return patternsFor(opts).some((p) => {\n p.regex.lastIndex = 0;\n return p.regex.test(text);\n });\n}\n\nexport interface ClassifyResult {\n /** the matching pattern's stable label (e.g. `openai-api-key`) */\n label: string;\n /** the matching pattern's human description (e.g. `OpenAI API key (sk-… / sk-proj-…)`) */\n description: string;\n}\n\n/**\n * Classify a SINGLE pasted token — the INVERSE of redaction. Returns the first\n * (most-specific) pattern the value matches, or `null`. Backs a \"paste a key →\n * detect its type\" UI (cardmem F214 Secrets Vault) so every consumer shares the\n * same classification, not just the same redaction.\n *\n * First-match-wins over the ordered `SECRET_PATTERNS`, so `sk-ant-…` classifies\n * as `anthropic-api-key`, never the generic `openai-api-key`. Field-anchored\n * context-only patterns (mistral / vimeo / cloudflare-api-token /\n * labeled-hex-secret / deepseek-fallback) only match when the pasted value\n * includes their `NAME=` context; a bare provider token classifies via its\n * prefix pattern, and a prefix-less bare token (e.g. a raw Mistral key) is\n * genuinely unidentifiable → `null`. `opts.extraPatterns` run AFTER the\n * canonical set (canonical attribution wins). Input is trimmed; empty /\n * whitespace-only → `null`.\n */\nexport function classify(value: string, opts?: RedactOptions): ClassifyResult | null {\n if (!value) return null;\n const v = value.trim();\n if (!v) return null;\n for (const p of patternsFor(opts)) {\n p.regex.lastIndex = 0;\n if (p.regex.test(v)) return { label: p.label, description: p.description };\n }\n return null;\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@broberg/secret-scan",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|
|
7
7
|
"sideEffects": false,
|
|
8
|
-
"files": [
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
9
12
|
"main": "./dist/index.cjs",
|
|
10
13
|
"module": "./dist/index.js",
|
|
11
14
|
"types": "./dist/index.d.ts",
|
|
@@ -41,5 +44,7 @@
|
|
|
41
44
|
"url": "https://github.com/broberg-ai/components",
|
|
42
45
|
"directory": "packages/secret-scan"
|
|
43
46
|
},
|
|
44
|
-
"publishConfig": {
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
}
|
|
45
50
|
}
|