@broberg/secret-scan 0.1.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 ADDED
@@ -0,0 +1,93 @@
1
+ # @broberg/secret-scan
2
+
3
+ Pure, dependency-free **secret/credential redaction** for the broberg.ai fleet.
4
+ Catch leaked API keys and tokens at your write + egress boundaries so a key never
5
+ lands in a database, a chat answer, a search result, or a shared knowledge base.
6
+
7
+ Lifted from [`broberg/trail` F197](https://github.com/broberg-ai/trail) — the
8
+ second-brain safeguard that found 9 real leaked keys already sitting in a shared
9
+ KB. `components` owns + publishes it; every repo consumes the same canonical
10
+ pattern set, so detection never drifts.
11
+
12
+ ```bash
13
+ npm i @broberg/secret-scan
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ```ts
19
+ import { redactSecrets, hasSecret } from "@broberg/secret-scan";
20
+
21
+ const { redacted, findings } = redactSecrets("the key is sk-ant-api03-… use it");
22
+ // redacted → "the key is [REDACTED:anthropic-api-key] use it"
23
+ // findings → [{ label: "anthropic-api-key", count: 1 }]
24
+
25
+ hasSecret("nothing here"); // false
26
+ ```
27
+
28
+ `redactSecrets` is **pure + deterministic**: clean input returns byte-identical
29
+ with `findings: []`. It replaces every detected secret with `[REDACTED:<label>]`
30
+ and never blocks the write — the surrounding knowledge survives.
31
+
32
+ ## Two recommended integration shapes
33
+
34
+ 1. **Write boundary (ingest gate)** — redact before you persist, so secrets never
35
+ enter storage:
36
+ ```ts
37
+ await db.insert({ content: redactSecrets(content).redacted });
38
+ ```
39
+ 2. **Egress guardrail** — scrub before a value leaves to a user or an LLM. The
40
+ highest-value guard is scrubbing retrieved context before it enters a prompt,
41
+ so the model can never see (and never echo) a secret that predates the gate.
42
+
43
+ ## Custom / per-tenant patterns
44
+
45
+ Add your own patterns on top of the canonical set — they run **after** the
46
+ canonical patterns, so canonical attribution always wins:
47
+
48
+ ```ts
49
+ redactSecrets(text, {
50
+ extraPatterns: [{ label: "acme-key", description: "ACME key", regex: /\bACME-[0-9]{6}\b/g }],
51
+ });
52
+ ```
53
+
54
+ ## What it detects
55
+
56
+ A curated, **ordered** set (`SECRET_PATTERNS`) of named, low-false-positive
57
+ regexes — most-specific first so attribution is correct:
58
+
59
+ - **LLM:** Anthropic (`sk-ant-…`, incl. `oat01-`), OpenAI (`sk-`/`sk-proj-`),
60
+ OpenRouter (`sk-or-v1-`), ElevenLabs, fal.ai, Google/Gemini (`AIza…`),
61
+ Google OAuth (`GOCSPX-`).
62
+ - **Cloud / infra:** AWS (`AKIA…`), GitHub, GitLab, Slack, Stripe live, Resend,
63
+ Fly.io, Cloudflare global key, Supabase (`sbp_` / `sb_secret_`), npm (`npm_…`).
64
+ - **Fleet:** upmetrics (`uk_`), cardmem (`pa_/pi_/pk_`, `piw_`), cms (`wh_`),
65
+ trail (`trail_`).
66
+ - **Generic:** JWT (`eyJ…` — also Turso + Supabase service_role tokens), PEM
67
+ private-key blocks, Discord bot/MFA tokens, and `labeled-hex-secret` (a 40+ hex
68
+ value assigned to a `secret`/`token`/`password`/`api-key`-named field).
69
+
70
+ ### Design notes
71
+
72
+ - **Pattern-based, not entropy** — a redacted *real* fact corrupts knowledge, so
73
+ we accept missing an exotic token over false-positiving.
74
+ - **Never a bare hex pattern** — it would hit git shas/hashes. Prefix-less service
75
+ secrets are caught only via the `labeled-hex-secret` name-context rule.
76
+ - **Order is API** — specific patterns run before generic ones (`sk-ant-` before
77
+ `sk-`); a test asserts it.
78
+
79
+ ## API
80
+
81
+ ```ts
82
+ interface SecretPattern { label: string; description: string; regex: RegExp; }
83
+ interface RedactionFinding { label: string; count: number; }
84
+ interface RedactionResult { redacted: string; findings: RedactionFinding[]; }
85
+ interface RedactOptions { extraPatterns?: SecretPattern[]; }
86
+
87
+ const SECRET_PATTERNS: SecretPattern[];
88
+ function redactSecrets(text: string, opts?: RedactOptions): RedactionResult;
89
+ function hasSecret(text: string, opts?: RedactOptions): boolean;
90
+ function redactionMarker(label: string): string; // `[REDACTED:${label}]`
91
+ ```
92
+
93
+ MIT · part of the [`@broberg/*`](https://github.com/broberg-ai/components) shared-library family.
package/dist/index.cjs ADDED
@@ -0,0 +1,195 @@
1
+ 'use strict';
2
+
3
+ // src/index.ts
4
+ var SECRET_PATTERNS = [
5
+ {
6
+ label: "private-key",
7
+ description: "PEM private key block (RSA/EC/OPENSSH/DSA/PGP)",
8
+ regex: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g
9
+ },
10
+ {
11
+ label: "anthropic-api-key",
12
+ description: "Anthropic API key (sk-ant-\u2026)",
13
+ regex: /sk-ant-(?:api03-)?[A-Za-z0-9_-]{20,}/g
14
+ },
15
+ {
16
+ // OpenRouter — distinct from OpenAI; runs BEFORE the generic sk- (which would
17
+ // otherwise also match + mislabel it).
18
+ label: "openrouter-api-key",
19
+ description: "OpenRouter API key (sk-or-v1- + 64 hex)",
20
+ regex: /\bsk-or-v1-[0-9a-f]{64}/g
21
+ },
22
+ {
23
+ label: "openai-api-key",
24
+ description: "OpenAI API key (sk-\u2026 / sk-proj-\u2026)",
25
+ regex: /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/g
26
+ },
27
+ {
28
+ // ElevenLabs — sk_ with UNDERSCORE (vs OpenAI sk-), 48 hex.
29
+ label: "elevenlabs-api-key",
30
+ description: "ElevenLabs API key (sk_ + 48 hex)",
31
+ regex: /\bsk_[0-9a-f]{48}\b/g
32
+ },
33
+ {
34
+ // fal.ai — uuid:hex32 (key_id:key_secret); the colon is the signal.
35
+ label: "fal-api-key",
36
+ description: "fal.ai key (uuid:hex32)",
37
+ 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
38
+ },
39
+ {
40
+ label: "google-api-key",
41
+ description: "Google / Gemini API key (AIza\u2026)",
42
+ regex: /AIza[0-9A-Za-z_-]{35}/g
43
+ },
44
+ {
45
+ label: "google-oauth-secret",
46
+ description: "Google OAuth client secret (GOCSPX-\u2026)",
47
+ regex: /GOCSPX-[A-Za-z0-9_-]{28}/g
48
+ },
49
+ {
50
+ label: "aws-access-key-id",
51
+ description: "AWS access key id (AKIA\u2026)",
52
+ regex: /\bAKIA[0-9A-Z]{16}\b/g
53
+ },
54
+ {
55
+ label: "github-token",
56
+ description: "GitHub token (ghp_/gho_/ghs_/ghu_/ghr_\u2026)",
57
+ regex: /\bgh[posru]_[A-Za-z0-9]{36,}\b/g
58
+ },
59
+ {
60
+ label: "gitlab-token",
61
+ description: "GitLab personal access token (glpat-\u2026)",
62
+ regex: /\bglpat-[A-Za-z0-9_-]{20,}/g
63
+ },
64
+ {
65
+ label: "slack-token",
66
+ description: "Slack token (xox[baprs]-\u2026)",
67
+ regex: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g
68
+ },
69
+ {
70
+ label: "stripe-secret-key",
71
+ description: "Stripe live secret/restricted key (sk_live_/rk_live_\u2026)",
72
+ regex: /\b[rs]k_live_[A-Za-z0-9]{20,}/g
73
+ },
74
+ {
75
+ // Resend (re_…). Lookahead requires a digit in the body so we don't redact
76
+ // long snake_case identifiers like re_compute_the_thing.
77
+ label: "resend-api-key",
78
+ description: "Resend API key (re_ + token)",
79
+ regex: /\bre_(?=[A-Za-z0-9_]*\d)[A-Za-z0-9_]{24,}\b/g
80
+ },
81
+ {
82
+ label: "supabase-access-token",
83
+ description: "Supabase personal/management access token (sbp_ + 40 hex)",
84
+ regex: /\bsbp_[0-9a-f]{40}/g
85
+ },
86
+ {
87
+ label: "supabase-secret-key",
88
+ description: "Supabase secret API key (sb_secret_\u2026)",
89
+ regex: /\bsb_secret_[A-Za-z0-9_-]{20,}/g
90
+ },
91
+ {
92
+ // Used by every @broberg/* publish — the highest-value leak from a .env / commit history.
93
+ label: "npm-token",
94
+ description: "npm publish/automation token (npm_ + 36 base62)",
95
+ regex: /\bnpm_[A-Za-z0-9]{36}\b/g
96
+ },
97
+ {
98
+ label: "fly-api-token",
99
+ description: "Fly.io API token (FlyV1 fm2_\u2026 / fo1_\u2026)",
100
+ regex: /(?:FlyV1 fm2_[A-Za-z0-9+/=_-]{20,}|\bfo1_[A-Za-z0-9_-]{20,})/g
101
+ },
102
+ {
103
+ // Also covers Turso DB/platform auth tokens AND Supabase anon/service_role
104
+ // keys — both are JWTs (eyJ…), so the single JWT pattern catches them.
105
+ label: "jwt",
106
+ description: "JSON Web Token (eyJ\u2026) \u2014 incl. Turso + Supabase service_role tokens",
107
+ regex: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g
108
+ },
109
+ {
110
+ // genApiKey = randomBytes(24).hex → uk_ + exactly 48 lowercase hex.
111
+ label: "upmetrics-key",
112
+ description: "Upmetrics project key (uk_ + 48 hex)",
113
+ regex: /\buk_[0-9a-f]{48}/g
114
+ },
115
+ {
116
+ label: "cardmem-key",
117
+ description: "Cardmem personal/incident/project key (pa_/pi_/pk_ + 64 hex)",
118
+ regex: /\bp[aik]_[A-Za-z0-9]{20,}/g
119
+ },
120
+ {
121
+ // cardmem inbox-webhook key — piw_ isn't matched by p[aik]_ above (3rd char 'w' ≠ '_').
122
+ label: "cardmem-webhook-key",
123
+ description: "Cardmem inbox-webhook key (piw_ + 64 hex)",
124
+ regex: /\bpiw_[0-9a-f]{64}/g
125
+ },
126
+ {
127
+ label: "trail-key",
128
+ description: "Trail personal API key (trail_\u2026)",
129
+ regex: /\btrail_[A-Za-z0-9]{20,}/g
130
+ },
131
+ {
132
+ // randomBytes(32).hex → wh_ + 64 lowercase hex (67 chars total).
133
+ label: "cms-access-token",
134
+ description: "webhouse.app CMS access token (wh_ + 64 hex)",
135
+ regex: /\bwh_[0-9a-f]{64}/g
136
+ },
137
+ {
138
+ // Context-based catch for prefix-less high-entropy service secrets
139
+ // (CMS_JWT_SECRET, revalidateSecret, fleet openssl-rand-hex secrets): a 40+
140
+ // hex value assigned to a field whose name contains
141
+ // secret/token/password/api-key. The name requirement keeps the
142
+ // false-positive rate near zero (a bare 40/64-hex would hit shas/hashes).
143
+ label: "labeled-hex-secret",
144
+ description: "A 40+ hex value assigned to a secret/token/password/api-key-named field",
145
+ regex: /\b[A-Za-z0-9_-]*(?:secret|token|password|api[_-]?key)\b\s*[:=]\s*["'`]?[0-9a-f]{40,}/gi
146
+ },
147
+ {
148
+ // Discord bot token — three base64url segments. Anchored both sides so it
149
+ // can't partial-match a longer dotted string.
150
+ label: "discord-bot-token",
151
+ description: "Discord bot token (3 base64url segments)",
152
+ 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
153
+ },
154
+ {
155
+ label: "discord-mfa-token",
156
+ description: "Discord MFA token (mfa. + 84 chars)",
157
+ regex: /\bmfa\.[A-Za-z0-9_-]{84}\b/g
158
+ },
159
+ {
160
+ label: "cloudflare-global-key",
161
+ description: "Cloudflare global API key (37-hex)",
162
+ regex: /\b[0-9a-f]{37}\b/g
163
+ }
164
+ ];
165
+ var redactionMarker = (label) => `[REDACTED:${label}]`;
166
+ function patternsFor(opts) {
167
+ return opts?.extraPatterns && opts.extraPatterns.length > 0 ? [...SECRET_PATTERNS, ...opts.extraPatterns] : SECRET_PATTERNS;
168
+ }
169
+ function redactSecrets(text, opts) {
170
+ if (!text) return { redacted: text, findings: [] };
171
+ let redacted = text;
172
+ const findings = [];
173
+ for (const p of patternsFor(opts)) {
174
+ let count = 0;
175
+ redacted = redacted.replace(p.regex, () => {
176
+ count++;
177
+ return redactionMarker(p.label);
178
+ });
179
+ if (count > 0) findings.push({ label: p.label, count });
180
+ }
181
+ return { redacted, findings };
182
+ }
183
+ function hasSecret(text, opts) {
184
+ return patternsFor(opts).some((p) => {
185
+ p.regex.lastIndex = 0;
186
+ return p.regex.test(text);
187
+ });
188
+ }
189
+
190
+ exports.SECRET_PATTERNS = SECRET_PATTERNS;
191
+ exports.hasSecret = hasSecret;
192
+ exports.redactSecrets = redactSecrets;
193
+ exports.redactionMarker = redactionMarker;
194
+ //# sourceMappingURL=index.cjs.map
195
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +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 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 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 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 // 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 // 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 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"]}
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @broberg/secret-scan — fleet secret/credential redaction.
3
+ *
4
+ * `redactSecrets(text)` replaces every matched secret with `[REDACTED:<label>]`
5
+ * and reports what it found. PURE + deterministic (regex/string only, no deps,
6
+ * no I/O) so an engine write-gate, an egress scrub, a CLI, an admin preview UI,
7
+ * and any repo all share the EXACT same detection — and it's trivially testable.
8
+ *
9
+ * Lifted verbatim from broberg/trail F197 (the second-brain safeguard); see
10
+ * docs/features/F035-secret-scan.md. components owns + publishes this; @trail/shared
11
+ * re-exports it.
12
+ *
13
+ * Design choices:
14
+ * - Pattern-based, NOT entropy/generic-randomness — a redacted real fact would
15
+ * corrupt knowledge, so we accept missing an exotic token over false positives.
16
+ * - Order matters: most-specific patterns run first (e.g. `sk-ant-` before the
17
+ * generic OpenAI `sk-`; `sk-or-v1-` before `sk-`), because each match is
18
+ * consumed before the next pattern runs → order = attribution.
19
+ * - Redact, never reject — the surrounding knowledge survives; only the
20
+ * credential substring is neutralised.
21
+ * - NEVER a bare high-entropy/hex pattern (it would hit git shas/hashes).
22
+ * Prefix-less service secrets are caught only via `labeled-hex-secret` (a 40+
23
+ * hex value assigned to a secret/token/password/api-key-named field).
24
+ *
25
+ * Two recommended integration shapes for consumers:
26
+ * (a) write boundary — `redactSecrets(text)` before persist (ingest gate);
27
+ * (b) egress — scrub before a value leaves to a user/LLM (highest-value guard).
28
+ */
29
+ interface SecretPattern {
30
+ /** stable id shown in the redaction marker + findings */
31
+ label: string;
32
+ /** human description of what this matches */
33
+ description: string;
34
+ /** global regex (used for replace-all + counting) */
35
+ regex: RegExp;
36
+ }
37
+ /** Ordered most-specific → least. Every regex carries the `g` flag. */
38
+ declare const SECRET_PATTERNS: SecretPattern[];
39
+ interface RedactionFinding {
40
+ label: string;
41
+ count: number;
42
+ }
43
+ interface RedactionResult {
44
+ /** input with every secret replaced by `[REDACTED:<label>]` */
45
+ redacted: string;
46
+ /** per-pattern counts of what was redacted (empty = clean) */
47
+ findings: RedactionFinding[];
48
+ }
49
+ interface RedactOptions {
50
+ /**
51
+ * Extra consumer/per-tenant patterns, run AFTER the canonical set (so canonical
52
+ * attribution wins). Backs a future self-service "paste a key → detector" UI.
53
+ */
54
+ extraPatterns?: SecretPattern[];
55
+ }
56
+ /** Replacement marker for a redacted secret. */
57
+ declare const redactionMarker: (label: string) => string;
58
+ /**
59
+ * Scan `text` and replace every detected secret with its redaction marker.
60
+ * Pure: clean input returns byte-identical (`findings: []`).
61
+ */
62
+ declare function redactSecrets(text: string, opts?: RedactOptions): RedactionResult;
63
+ /** True if `text` contains at least one detectable secret. */
64
+ declare function hasSecret(text: string, opts?: RedactOptions): boolean;
65
+
66
+ export { type RedactOptions, type RedactionFinding, type RedactionResult, SECRET_PATTERNS, type SecretPattern, hasSecret, redactSecrets, redactionMarker };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @broberg/secret-scan — fleet secret/credential redaction.
3
+ *
4
+ * `redactSecrets(text)` replaces every matched secret with `[REDACTED:<label>]`
5
+ * and reports what it found. PURE + deterministic (regex/string only, no deps,
6
+ * no I/O) so an engine write-gate, an egress scrub, a CLI, an admin preview UI,
7
+ * and any repo all share the EXACT same detection — and it's trivially testable.
8
+ *
9
+ * Lifted verbatim from broberg/trail F197 (the second-brain safeguard); see
10
+ * docs/features/F035-secret-scan.md. components owns + publishes this; @trail/shared
11
+ * re-exports it.
12
+ *
13
+ * Design choices:
14
+ * - Pattern-based, NOT entropy/generic-randomness — a redacted real fact would
15
+ * corrupt knowledge, so we accept missing an exotic token over false positives.
16
+ * - Order matters: most-specific patterns run first (e.g. `sk-ant-` before the
17
+ * generic OpenAI `sk-`; `sk-or-v1-` before `sk-`), because each match is
18
+ * consumed before the next pattern runs → order = attribution.
19
+ * - Redact, never reject — the surrounding knowledge survives; only the
20
+ * credential substring is neutralised.
21
+ * - NEVER a bare high-entropy/hex pattern (it would hit git shas/hashes).
22
+ * Prefix-less service secrets are caught only via `labeled-hex-secret` (a 40+
23
+ * hex value assigned to a secret/token/password/api-key-named field).
24
+ *
25
+ * Two recommended integration shapes for consumers:
26
+ * (a) write boundary — `redactSecrets(text)` before persist (ingest gate);
27
+ * (b) egress — scrub before a value leaves to a user/LLM (highest-value guard).
28
+ */
29
+ interface SecretPattern {
30
+ /** stable id shown in the redaction marker + findings */
31
+ label: string;
32
+ /** human description of what this matches */
33
+ description: string;
34
+ /** global regex (used for replace-all + counting) */
35
+ regex: RegExp;
36
+ }
37
+ /** Ordered most-specific → least. Every regex carries the `g` flag. */
38
+ declare const SECRET_PATTERNS: SecretPattern[];
39
+ interface RedactionFinding {
40
+ label: string;
41
+ count: number;
42
+ }
43
+ interface RedactionResult {
44
+ /** input with every secret replaced by `[REDACTED:<label>]` */
45
+ redacted: string;
46
+ /** per-pattern counts of what was redacted (empty = clean) */
47
+ findings: RedactionFinding[];
48
+ }
49
+ interface RedactOptions {
50
+ /**
51
+ * Extra consumer/per-tenant patterns, run AFTER the canonical set (so canonical
52
+ * attribution wins). Backs a future self-service "paste a key → detector" UI.
53
+ */
54
+ extraPatterns?: SecretPattern[];
55
+ }
56
+ /** Replacement marker for a redacted secret. */
57
+ declare const redactionMarker: (label: string) => string;
58
+ /**
59
+ * Scan `text` and replace every detected secret with its redaction marker.
60
+ * Pure: clean input returns byte-identical (`findings: []`).
61
+ */
62
+ declare function redactSecrets(text: string, opts?: RedactOptions): RedactionResult;
63
+ /** True if `text` contains at least one detectable secret. */
64
+ declare function hasSecret(text: string, opts?: RedactOptions): boolean;
65
+
66
+ export { type RedactOptions, type RedactionFinding, type RedactionResult, SECRET_PATTERNS, type SecretPattern, hasSecret, redactSecrets, redactionMarker };
package/dist/index.js ADDED
@@ -0,0 +1,190 @@
1
+ // src/index.ts
2
+ var SECRET_PATTERNS = [
3
+ {
4
+ label: "private-key",
5
+ description: "PEM private key block (RSA/EC/OPENSSH/DSA/PGP)",
6
+ regex: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g
7
+ },
8
+ {
9
+ label: "anthropic-api-key",
10
+ description: "Anthropic API key (sk-ant-\u2026)",
11
+ regex: /sk-ant-(?:api03-)?[A-Za-z0-9_-]{20,}/g
12
+ },
13
+ {
14
+ // OpenRouter — distinct from OpenAI; runs BEFORE the generic sk- (which would
15
+ // otherwise also match + mislabel it).
16
+ label: "openrouter-api-key",
17
+ description: "OpenRouter API key (sk-or-v1- + 64 hex)",
18
+ regex: /\bsk-or-v1-[0-9a-f]{64}/g
19
+ },
20
+ {
21
+ label: "openai-api-key",
22
+ description: "OpenAI API key (sk-\u2026 / sk-proj-\u2026)",
23
+ regex: /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/g
24
+ },
25
+ {
26
+ // ElevenLabs — sk_ with UNDERSCORE (vs OpenAI sk-), 48 hex.
27
+ label: "elevenlabs-api-key",
28
+ description: "ElevenLabs API key (sk_ + 48 hex)",
29
+ regex: /\bsk_[0-9a-f]{48}\b/g
30
+ },
31
+ {
32
+ // fal.ai — uuid:hex32 (key_id:key_secret); the colon is the signal.
33
+ label: "fal-api-key",
34
+ description: "fal.ai key (uuid:hex32)",
35
+ 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
36
+ },
37
+ {
38
+ label: "google-api-key",
39
+ description: "Google / Gemini API key (AIza\u2026)",
40
+ regex: /AIza[0-9A-Za-z_-]{35}/g
41
+ },
42
+ {
43
+ label: "google-oauth-secret",
44
+ description: "Google OAuth client secret (GOCSPX-\u2026)",
45
+ regex: /GOCSPX-[A-Za-z0-9_-]{28}/g
46
+ },
47
+ {
48
+ label: "aws-access-key-id",
49
+ description: "AWS access key id (AKIA\u2026)",
50
+ regex: /\bAKIA[0-9A-Z]{16}\b/g
51
+ },
52
+ {
53
+ label: "github-token",
54
+ description: "GitHub token (ghp_/gho_/ghs_/ghu_/ghr_\u2026)",
55
+ regex: /\bgh[posru]_[A-Za-z0-9]{36,}\b/g
56
+ },
57
+ {
58
+ label: "gitlab-token",
59
+ description: "GitLab personal access token (glpat-\u2026)",
60
+ regex: /\bglpat-[A-Za-z0-9_-]{20,}/g
61
+ },
62
+ {
63
+ label: "slack-token",
64
+ description: "Slack token (xox[baprs]-\u2026)",
65
+ regex: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g
66
+ },
67
+ {
68
+ label: "stripe-secret-key",
69
+ description: "Stripe live secret/restricted key (sk_live_/rk_live_\u2026)",
70
+ regex: /\b[rs]k_live_[A-Za-z0-9]{20,}/g
71
+ },
72
+ {
73
+ // Resend (re_…). Lookahead requires a digit in the body so we don't redact
74
+ // long snake_case identifiers like re_compute_the_thing.
75
+ label: "resend-api-key",
76
+ description: "Resend API key (re_ + token)",
77
+ regex: /\bre_(?=[A-Za-z0-9_]*\d)[A-Za-z0-9_]{24,}\b/g
78
+ },
79
+ {
80
+ label: "supabase-access-token",
81
+ description: "Supabase personal/management access token (sbp_ + 40 hex)",
82
+ regex: /\bsbp_[0-9a-f]{40}/g
83
+ },
84
+ {
85
+ label: "supabase-secret-key",
86
+ description: "Supabase secret API key (sb_secret_\u2026)",
87
+ regex: /\bsb_secret_[A-Za-z0-9_-]{20,}/g
88
+ },
89
+ {
90
+ // Used by every @broberg/* publish — the highest-value leak from a .env / commit history.
91
+ label: "npm-token",
92
+ description: "npm publish/automation token (npm_ + 36 base62)",
93
+ regex: /\bnpm_[A-Za-z0-9]{36}\b/g
94
+ },
95
+ {
96
+ label: "fly-api-token",
97
+ description: "Fly.io API token (FlyV1 fm2_\u2026 / fo1_\u2026)",
98
+ regex: /(?:FlyV1 fm2_[A-Za-z0-9+/=_-]{20,}|\bfo1_[A-Za-z0-9_-]{20,})/g
99
+ },
100
+ {
101
+ // Also covers Turso DB/platform auth tokens AND Supabase anon/service_role
102
+ // keys — both are JWTs (eyJ…), so the single JWT pattern catches them.
103
+ label: "jwt",
104
+ description: "JSON Web Token (eyJ\u2026) \u2014 incl. Turso + Supabase service_role tokens",
105
+ regex: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g
106
+ },
107
+ {
108
+ // genApiKey = randomBytes(24).hex → uk_ + exactly 48 lowercase hex.
109
+ label: "upmetrics-key",
110
+ description: "Upmetrics project key (uk_ + 48 hex)",
111
+ regex: /\buk_[0-9a-f]{48}/g
112
+ },
113
+ {
114
+ label: "cardmem-key",
115
+ description: "Cardmem personal/incident/project key (pa_/pi_/pk_ + 64 hex)",
116
+ regex: /\bp[aik]_[A-Za-z0-9]{20,}/g
117
+ },
118
+ {
119
+ // cardmem inbox-webhook key — piw_ isn't matched by p[aik]_ above (3rd char 'w' ≠ '_').
120
+ label: "cardmem-webhook-key",
121
+ description: "Cardmem inbox-webhook key (piw_ + 64 hex)",
122
+ regex: /\bpiw_[0-9a-f]{64}/g
123
+ },
124
+ {
125
+ label: "trail-key",
126
+ description: "Trail personal API key (trail_\u2026)",
127
+ regex: /\btrail_[A-Za-z0-9]{20,}/g
128
+ },
129
+ {
130
+ // randomBytes(32).hex → wh_ + 64 lowercase hex (67 chars total).
131
+ label: "cms-access-token",
132
+ description: "webhouse.app CMS access token (wh_ + 64 hex)",
133
+ regex: /\bwh_[0-9a-f]{64}/g
134
+ },
135
+ {
136
+ // Context-based catch for prefix-less high-entropy service secrets
137
+ // (CMS_JWT_SECRET, revalidateSecret, fleet openssl-rand-hex secrets): a 40+
138
+ // hex value assigned to a field whose name contains
139
+ // secret/token/password/api-key. The name requirement keeps the
140
+ // false-positive rate near zero (a bare 40/64-hex would hit shas/hashes).
141
+ label: "labeled-hex-secret",
142
+ description: "A 40+ hex value assigned to a secret/token/password/api-key-named field",
143
+ regex: /\b[A-Za-z0-9_-]*(?:secret|token|password|api[_-]?key)\b\s*[:=]\s*["'`]?[0-9a-f]{40,}/gi
144
+ },
145
+ {
146
+ // Discord bot token — three base64url segments. Anchored both sides so it
147
+ // can't partial-match a longer dotted string.
148
+ label: "discord-bot-token",
149
+ description: "Discord bot token (3 base64url segments)",
150
+ 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
151
+ },
152
+ {
153
+ label: "discord-mfa-token",
154
+ description: "Discord MFA token (mfa. + 84 chars)",
155
+ regex: /\bmfa\.[A-Za-z0-9_-]{84}\b/g
156
+ },
157
+ {
158
+ label: "cloudflare-global-key",
159
+ description: "Cloudflare global API key (37-hex)",
160
+ regex: /\b[0-9a-f]{37}\b/g
161
+ }
162
+ ];
163
+ var redactionMarker = (label) => `[REDACTED:${label}]`;
164
+ function patternsFor(opts) {
165
+ return opts?.extraPatterns && opts.extraPatterns.length > 0 ? [...SECRET_PATTERNS, ...opts.extraPatterns] : SECRET_PATTERNS;
166
+ }
167
+ function redactSecrets(text, opts) {
168
+ if (!text) return { redacted: text, findings: [] };
169
+ let redacted = text;
170
+ const findings = [];
171
+ for (const p of patternsFor(opts)) {
172
+ let count = 0;
173
+ redacted = redacted.replace(p.regex, () => {
174
+ count++;
175
+ return redactionMarker(p.label);
176
+ });
177
+ if (count > 0) findings.push({ label: p.label, count });
178
+ }
179
+ return { redacted, findings };
180
+ }
181
+ function hasSecret(text, opts) {
182
+ return patternsFor(opts).some((p) => {
183
+ p.regex.lastIndex = 0;
184
+ return p.regex.test(text);
185
+ });
186
+ }
187
+
188
+ export { SECRET_PATTERNS, hasSecret, redactSecrets, redactionMarker };
189
+ //# sourceMappingURL=index.js.map
190
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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 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 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 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 // 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 // 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 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"]}
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@broberg/secret-scan",
3
+ "version": "0.1.0",
4
+ "description": "Pure, dependency-free secret/credential redaction for the broberg.ai fleet — redactSecrets / hasSecret over a curated, ordered SECRET_PATTERNS set. Redact at write + egress boundaries so keys never land in a DB, chat, or KB. Lifted from broberg/trail F197.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "sideEffects": false,
8
+ "files": ["dist", "README.md"],
9
+ "main": "./dist/index.cjs",
10
+ "module": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js",
16
+ "require": "./dist/index.cjs"
17
+ }
18
+ },
19
+ "scripts": {
20
+ "build": "tsup",
21
+ "test": "vitest run",
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "devDependencies": {
25
+ "tsup": "^8.3.0",
26
+ "typescript": "^5.6.0",
27
+ "vitest": "^2.1.0"
28
+ },
29
+ "keywords": [
30
+ "secret",
31
+ "redaction",
32
+ "credentials",
33
+ "security",
34
+ "api-key",
35
+ "token",
36
+ "secret-scanning",
37
+ "broberg"
38
+ ],
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "https://github.com/broberg-ai/components",
42
+ "directory": "packages/secret-scan"
43
+ },
44
+ "publishConfig": { "access": "public" }
45
+ }