@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.
@@ -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-style private key headers
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
- return result;
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
  }