@mnemonik/shared 6.47.0 → 6.50.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/dist/codeScanner.d.ts +79 -2
- package/dist/codeScanner.d.ts.map +1 -1
- package/dist/codeScanner.js +394 -68
- package/dist/codeScanner.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/secretPatterns.d.ts +25 -1
- package/dist/secretPatterns.d.ts.map +1 -1
- package/dist/secretPatterns.js +182 -2
- package/dist/secretPatterns.js.map +1 -1
- package/package.json +1 -1
- package/src/codeScanner.ts +405 -68
- package/src/index.ts +11 -1
- package/src/secretPatterns.ts +180 -2
package/src/secretPatterns.ts
CHANGED
|
@@ -15,13 +15,28 @@
|
|
|
15
15
|
* 2. Stripe-style sk_live_/pk_test_ keys
|
|
16
16
|
* 3. GitHub personal access tokens (ghp_ prefix, exact 36 chars)
|
|
17
17
|
* 4. GitLab personal access tokens (glpat- prefix, 20+ chars)
|
|
18
|
-
* 5. PEM
|
|
18
|
+
* 5. PEM private keys — whole block, header through footer
|
|
19
|
+
* 6. Provider prefixes: AWS (AKIA/ASIA + labeled secret key), JWT,
|
|
20
|
+
* Slack (xox*), Google (AIza), Anthropic (sk-ant-), npm (npm_/npms_)
|
|
21
|
+
* 7. Credentials inside scheme://user:pass@host connection strings
|
|
22
|
+
* 8. Authorization: Bearer headers
|
|
23
|
+
* 9. Standalone high-entropy tokens matching no known prefix
|
|
24
|
+
* (`redactHighEntropyTokens` — heavily guarded, see below)
|
|
19
25
|
*
|
|
20
26
|
* False-positive cost: a few legitimate strings get replaced with the
|
|
21
27
|
* placeholder. False-negative cost: a credential ships to the server and
|
|
22
28
|
* gets stored in a memory. The patterns are deliberately tight (require
|
|
23
29
|
* specific prefixes, length minimums) to keep the false-positive rate low
|
|
24
30
|
* while catching the common credential leak vectors.
|
|
31
|
+
*
|
|
32
|
+
* FIDELITY IS A PEER CONCERN, NOT A ROUNDING ERROR. Over-scrubbing is the
|
|
33
|
+
* same class of failure as under-scrubbing: a pattern that eats prose,
|
|
34
|
+
* identifiers, hashes or paths silently blinds doc-truth's authority
|
|
35
|
+
* extractors — exactly what happened when a `process.env.X` read was
|
|
36
|
+
* redacted as if it were a literal (see ENV_READ_VALUE_RE below). Every
|
|
37
|
+
* pattern here carries positive AND negative tests in
|
|
38
|
+
* tests/SecretPatterns.test.ts, and the entropy detector is additionally
|
|
39
|
+
* guarded against hashes, UUIDs, paths, slugs and identifiers.
|
|
25
40
|
*/
|
|
26
41
|
|
|
27
42
|
export const SECRET_REDACTION_PLACEHOLDER = '[REDACTED]';
|
|
@@ -38,7 +53,40 @@ export const SECRET_PATTERNS: ReadonlyArray<RegExp> = [
|
|
|
38
53
|
/(?:sk|pk)[-_][a-zA-Z0-9]{20,}/g,
|
|
39
54
|
/ghp_[a-zA-Z0-9]{36}/g,
|
|
40
55
|
/glpat-[a-zA-Z0-9-]{20,}/g,
|
|
56
|
+
// PEM private keys. The BLOCK pattern runs first so header→footer (the
|
|
57
|
+
// actual key material) is redacted as one unit; the header-only pattern
|
|
58
|
+
// stays as the fallback for the case a chunk boundary lands between
|
|
59
|
+
// header and footer, where the block never closes inside the chunk.
|
|
60
|
+
// `[\s\S]*?` is lazy so two adjacent keys don't merge into one match.
|
|
61
|
+
/-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g,
|
|
41
62
|
/-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g,
|
|
63
|
+
// AWS access key IDs. The 16-char body must be uppercase-alphanumeric and
|
|
64
|
+
// \b-delimited, so prose ("AKIAMIA is a place name") cannot match.
|
|
65
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
66
|
+
/\bASIA[0-9A-Z]{16}\b/g,
|
|
67
|
+
// AWS secret access key, labeled. The generic key=value pattern above does
|
|
68
|
+
// NOT cover this: its `secret` alternative must be followed immediately by
|
|
69
|
+
// `[:=]`, and here it is followed by `_access_key`.
|
|
70
|
+
/\baws_secret_access_key\s*[:=]\s*[A-Za-z0-9/+=]{40}\b/gi,
|
|
71
|
+
// JWT: three base64url segments. Length minimums keep dotted identifiers
|
|
72
|
+
// (`payload.header.signature`) out.
|
|
73
|
+
/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}\b/g,
|
|
74
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
|
|
75
|
+
/\bAIza[0-9A-Za-z_-]{35}\b/g,
|
|
76
|
+
/\bsk-ant-[A-Za-z0-9_-]{20,}\b/g,
|
|
77
|
+
/\b(?:npm_|npms_)[A-Za-z0-9]{36,}\b/g,
|
|
78
|
+
// scheme://user:pass@ — only the credential segment is consumed, so the
|
|
79
|
+
// host and path survive for anyone reading the code. The username part is
|
|
80
|
+
// `*` (not `+`) because the no-username form (`redis://:pass@host`) is a
|
|
81
|
+
// real and common shape. The `@` is the anchor that makes this a
|
|
82
|
+
// credential rather than a URL: `https://host:443/path` cannot match
|
|
83
|
+
// because the character classes exclude `/`.
|
|
84
|
+
/\b[a-z][a-z0-9+.-]*:\/\/[^/\s:@]*:[^/\s:@]+@/gi,
|
|
85
|
+
// Authorization: Bearer <token>. The token class deliberately excludes
|
|
86
|
+
// `<` and `$`, so documentation placeholders (`Bearer <your-api-key>`,
|
|
87
|
+
// `Bearer $MNEMONIK_PROXY_TOKEN`) are left intact — redacting those would
|
|
88
|
+
// destroy the instruction without hiding a secret.
|
|
89
|
+
/Authorization\s*:\s*Bearer\s+[A-Za-z0-9._~+/-]+=*/gi,
|
|
42
90
|
];
|
|
43
91
|
|
|
44
92
|
/**
|
|
@@ -57,6 +105,134 @@ export const SECRET_PATTERNS: ReadonlyArray<RegExp> = [
|
|
|
57
105
|
*/
|
|
58
106
|
const ENV_READ_VALUE_RE = /[:=]\s*(?:await\s+)?(?:process\.env[.[]|import\.meta\.env[.[])/;
|
|
59
107
|
|
|
108
|
+
/* ------------------------------------------------------------------ *
|
|
109
|
+
* High-entropy token detector
|
|
110
|
+
*
|
|
111
|
+
* Catches credentials that carry no recognizable provider prefix — the
|
|
112
|
+
* long random blob in `"webhook": "<40 random chars>"`. A naive entropy
|
|
113
|
+
* threshold cannot do this safely: measured over this repository, a
|
|
114
|
+
* camelCase identifier scores 4.49 bits/char against 4.66 for a real AWS
|
|
115
|
+
* secret key, so entropy ALONE separates nothing. The structural guards
|
|
116
|
+
* below carry the discrimination; entropy is only the final filter.
|
|
117
|
+
*
|
|
118
|
+
* Every candidate must clear ALL of:
|
|
119
|
+
* 1. CONTEXT — sits immediately after `=`/`:` or an opening quote.
|
|
120
|
+
* Bare prose positions are never touched, which is what
|
|
121
|
+
* keeps the detector out of documentation.
|
|
122
|
+
* 2. LENGTH — >= 36 characters. The floor started at 32 and was
|
|
123
|
+
* raised by the claim-yield guard: 32-char opaque
|
|
124
|
+
* RESOURCE IDS (Vercel `dpl_`/`prj_`, Stripe object ids)
|
|
125
|
+
* are public identifiers, not credentials, and eating
|
|
126
|
+
* them cost 39 real `symbol_reference` claims in
|
|
127
|
+
* docs/deployment/LAUNCH_PLAN.md. Genuine credential
|
|
128
|
+
* blobs sit at 36+ (GitHub 40, AWS 40, Google 39, a
|
|
129
|
+
* base64 32-byte key 44); shorter non-hex randomness is
|
|
130
|
+
* almost always an object id, and anything hex is
|
|
131
|
+
* already exempt by rule 4.
|
|
132
|
+
* 3. CHARSET MIX — contains lowercase AND uppercase AND a digit.
|
|
133
|
+
* 4. NOT HEX-ISH — hex after stripping `-`/`_` is a digest or UUID
|
|
134
|
+
* (commit SHAs, contentHash, snippet_hash, project ids).
|
|
135
|
+
* 5. NOT STRUCTURED — if every `/`, `-`, `_` separated segment is <= 16
|
|
136
|
+
* chars the token is a path, slug or SCREAMING_SNAKE
|
|
137
|
+
* identifier ("tests/fixtures/docTruth/dogfood-2026-06-06").
|
|
138
|
+
* 6. NOT WORDY — a lowercase run longer than 5 means natural words
|
|
139
|
+
* ("...FromCache..."), not random output.
|
|
140
|
+
* 7. ENTROPY — Shannon entropy >= 4.0 bits/char.
|
|
141
|
+
*
|
|
142
|
+
* Calibrated by sweeping every file this scanner would chunk across this
|
|
143
|
+
* repository (1426 files): 8 tokens fire, every one of them a credential
|
|
144
|
+
* shape — JWT headers, an AWS example secret key, a leaked Stripe
|
|
145
|
+
* `whsec_`, and this suite's own fixtures. Zero prose, zero paths, zero
|
|
146
|
+
* identifiers, zero resource ids.
|
|
147
|
+
*
|
|
148
|
+
* The bias is deliberately toward MISSING a secret rather than mangling
|
|
149
|
+
* text: provider prefixes above are the primary net, this is the backstop.
|
|
150
|
+
* ------------------------------------------------------------------ */
|
|
151
|
+
|
|
152
|
+
const ENTROPY_MIN_LENGTH = 36;
|
|
153
|
+
const ENTROPY_MIN_BITS_PER_CHAR = 4.0;
|
|
154
|
+
const ENTROPY_MAX_STRUCTURED_SEGMENT = 16;
|
|
155
|
+
const ENTROPY_MAX_LOWERCASE_RUN = 5;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Maximal runs of base64url/base64 characters. `=` is admitted only as
|
|
159
|
+
* trailing padding — allowing it inside the run let a candidate span an
|
|
160
|
+
* assignment (`HARNESS_PROJECT_ID=<uuid>` matched as one token, defeating
|
|
161
|
+
* the UUID exemption).
|
|
162
|
+
*/
|
|
163
|
+
const ENTROPY_CANDIDATE_RE = /[A-Za-z0-9+/_-]{36,}={0,2}/g;
|
|
164
|
+
/** Opening quote, or `=`/`:` with optional whitespace and optional quote. */
|
|
165
|
+
const ENTROPY_CONTEXT_RE = /(?:["'`]|[:=]\s*["'`]?)$/;
|
|
166
|
+
/** `==`, `=>`, `!=`, `<=`, `>=`, `+=` … are comparisons, not assignments. */
|
|
167
|
+
const ENTROPY_OPERATOR_TAIL_RE = /[=!<>+\-*/%&|^]$/;
|
|
168
|
+
const ENTROPY_HEXISH_RE = /^[0-9a-fA-F]+$/;
|
|
169
|
+
const ENTROPY_STRUCTURED_RE = /^[A-Za-z0-9]+(?:[/_-][A-Za-z0-9]+)+$/;
|
|
170
|
+
|
|
171
|
+
function shannonEntropy(token: string): number {
|
|
172
|
+
const counts = new Map<string, number>();
|
|
173
|
+
for (const ch of token) counts.set(ch, (counts.get(ch) ?? 0) + 1);
|
|
174
|
+
let bits = 0;
|
|
175
|
+
for (const count of counts.values()) {
|
|
176
|
+
const p = count / token.length;
|
|
177
|
+
bits -= p * Math.log2(p);
|
|
178
|
+
}
|
|
179
|
+
return bits;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function maxLowercaseRun(token: string): number {
|
|
183
|
+
let max = 0;
|
|
184
|
+
let current = 0;
|
|
185
|
+
for (const ch of token) {
|
|
186
|
+
if (ch >= 'a' && ch <= 'z') {
|
|
187
|
+
current += 1;
|
|
188
|
+
if (current > max) max = current;
|
|
189
|
+
} else {
|
|
190
|
+
current = 0;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return max;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** True when `token` looks like random credential material, not text. */
|
|
197
|
+
function isHighEntropySecret(token: string): boolean {
|
|
198
|
+
if (token.length < ENTROPY_MIN_LENGTH) return false;
|
|
199
|
+
const stripped = token.replace(/[-_]/g, '');
|
|
200
|
+
if (stripped.length > 0 && ENTROPY_HEXISH_RE.test(stripped)) return false; // digests, UUIDs
|
|
201
|
+
if (!/[a-z]/.test(token) || !/[A-Z]/.test(token) || !/[0-9]/.test(token)) return false;
|
|
202
|
+
if (
|
|
203
|
+
ENTROPY_STRUCTURED_RE.test(token) &&
|
|
204
|
+
token.split(/[/_-]/).every((seg) => seg.length <= ENTROPY_MAX_STRUCTURED_SEGMENT)
|
|
205
|
+
) {
|
|
206
|
+
return false; // paths, slugs, snake/kebab identifiers
|
|
207
|
+
}
|
|
208
|
+
if (maxLowercaseRun(token) > ENTROPY_MAX_LOWERCASE_RUN) return false; // natural words
|
|
209
|
+
return shannonEntropy(token) >= ENTROPY_MIN_BITS_PER_CHAR;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Redact standalone high-entropy tokens that no provider pattern claimed.
|
|
214
|
+
* Only the token is replaced — the surrounding key, quote and punctuation
|
|
215
|
+
* survive, so `{"webhook": "<secret>"}` stays valid, readable JSON.
|
|
216
|
+
*
|
|
217
|
+
* Exported for direct testing of the guard behavior; production callers
|
|
218
|
+
* should use `scrubSecrets`, which applies this after the pattern sweep.
|
|
219
|
+
*/
|
|
220
|
+
export function redactHighEntropyTokens(text: string): string {
|
|
221
|
+
if (!text) return text;
|
|
222
|
+
return text.replace(ENTROPY_CANDIDATE_RE, (match, offset: number) => {
|
|
223
|
+
const before = text.slice(Math.max(0, offset - 8), offset);
|
|
224
|
+
if (!ENTROPY_CONTEXT_RE.test(before)) return match;
|
|
225
|
+
// Strip the trailing quote (if any) to inspect the operator underneath.
|
|
226
|
+
const operatorContext = before
|
|
227
|
+
.replace(/["'`]$/, '')
|
|
228
|
+
.trimEnd()
|
|
229
|
+
.slice(0, -1);
|
|
230
|
+
if (ENTROPY_OPERATOR_TAIL_RE.test(operatorContext)) return match;
|
|
231
|
+
if (ENV_READ_VALUE_RE.test(before + match)) return match;
|
|
232
|
+
return isHighEntropySecret(match) ? SECRET_REDACTION_PLACEHOLDER : match;
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
60
236
|
export function scrubSecrets(text: string): string {
|
|
61
237
|
if (!text) return text;
|
|
62
238
|
let result = text;
|
|
@@ -65,5 +241,7 @@ export function scrubSecrets(text: string): string {
|
|
|
65
241
|
ENV_READ_VALUE_RE.test(match) ? match : SECRET_REDACTION_PLACEHOLDER
|
|
66
242
|
);
|
|
67
243
|
}
|
|
68
|
-
|
|
244
|
+
// Backstop for credentials with no recognizable prefix. Runs last so the
|
|
245
|
+
// precise provider patterns get first claim on their own shapes.
|
|
246
|
+
return redactHighEntropyTokens(result);
|
|
69
247
|
}
|