@mnemonik/shared 7.140.3 → 7.141.1
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 +1 -1
- package/dist/codeScanner.d.ts.map +1 -1
- package/dist/codeScanner.js +14 -11
- package/dist/codeScanner.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/redactCredentials.d.ts +28 -0
- package/dist/redactCredentials.d.ts.map +1 -0
- package/dist/redactCredentials.js +491 -0
- package/dist/redactCredentials.js.map +1 -0
- package/dist/secretPatterns.d.ts +3 -0
- package/dist/secretPatterns.d.ts.map +1 -1
- package/dist/secretPatterns.js +85 -6
- package/dist/secretPatterns.js.map +1 -1
- package/dist/sourceRedaction.d.ts +3 -0
- package/dist/sourceRedaction.d.ts.map +1 -0
- package/dist/sourceRedaction.js +143 -0
- package/dist/sourceRedaction.js.map +1 -0
- package/package.json +1 -1
- package/src/codeScanner.ts +20 -12
- package/src/index.ts +6 -0
- package/src/redactCredentials.ts +594 -0
- package/src/secretPatterns.ts +89 -8
- package/src/sourceRedaction.ts +149 -0
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
const REDACTED = '<redacted credential>';
|
|
2
|
+
const PEM_BEGIN = '-----BEGIN ';
|
|
3
|
+
const MAX_PEM_LABEL_LENGTH = 64;
|
|
4
|
+
const MAX_PEM_BODY_LENGTH = 8 * 1024;
|
|
5
|
+
/**
|
|
6
|
+
* A password redacted where a rule recognised it (an sshpass command) is also
|
|
7
|
+
* redacted where the same string recurs (the same password named in prose),
|
|
8
|
+
* but only from this length up: a short value recurs in ordinary words by
|
|
9
|
+
* chance. See isDistinctiveValue for the other condition.
|
|
10
|
+
*/
|
|
11
|
+
const MIN_REPEATED_VALUE_LENGTH = 6;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A value worth chasing mixes at least two of lower case, upper case, digits
|
|
15
|
+
* and other characters. A password that is a plain word ("postgres" in a local
|
|
16
|
+
* connection string, a project name) recurs as that word, in URLs and prose
|
|
17
|
+
* that are not about the password at all.
|
|
18
|
+
*/
|
|
19
|
+
function isDistinctiveValue(value: string): boolean {
|
|
20
|
+
const classes = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((re) => re.test(value));
|
|
21
|
+
return value.length >= MIN_REPEATED_VALUE_LENGTH && classes.length >= 2;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type CredentialPattern = {
|
|
25
|
+
kind: string;
|
|
26
|
+
pattern: RegExp;
|
|
27
|
+
/**
|
|
28
|
+
* A replacement string, or a function that receives the match and its
|
|
29
|
+
* capture groups and may return the match unchanged (a placeholder). Only
|
|
30
|
+
* changed matches count as redactions.
|
|
31
|
+
*/
|
|
32
|
+
replacement: string | ((match: string, ...groups: string[]) => string);
|
|
33
|
+
/**
|
|
34
|
+
* The capture group (1-based) holding the secret itself, for a rule whose
|
|
35
|
+
* shape is its context rather than the secret: the value is then redacted
|
|
36
|
+
* wherever else it recurs (`repeated_credential`). Self-identifying tokens
|
|
37
|
+
* (provider prefixes, JWTs, keys) need no such chase: their own rule finds
|
|
38
|
+
* every copy.
|
|
39
|
+
*/
|
|
40
|
+
secretGroup?: number;
|
|
41
|
+
/** A command-line password rule: the only rules `commandLineOnly` runs. */
|
|
42
|
+
commandLine?: boolean;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export interface RedactCredentialsOptions {
|
|
46
|
+
/**
|
|
47
|
+
* Values already found to be credentials elsewhere (another row of the same
|
|
48
|
+
* store), redacted wherever they recur in this text. The redaction script
|
|
49
|
+
* passes the values its census found, so a password written once in a
|
|
50
|
+
* command and once in prose is gone from every row.
|
|
51
|
+
*/
|
|
52
|
+
knownValues?: Iterable<string>;
|
|
53
|
+
/**
|
|
54
|
+
* Run only the command-line password rules (and the recurrence pass). For
|
|
55
|
+
* scanner code chunks, whose content the scanner already scrubbed with its
|
|
56
|
+
* own patterns and where the broad named-value rule misreads code.
|
|
57
|
+
*/
|
|
58
|
+
commandLineOnly?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Every rule's value must hold a letter or a digit. Prose and code put
|
|
63
|
+
* punctuation where a value would go ("--password=; repeated copies",
|
|
64
|
+
* "API_KEY = ================", "Bearer ...", "user:***@"), and a run of
|
|
65
|
+
* punctuation is never a credential. Placeholders are judged separately, as
|
|
66
|
+
* before.
|
|
67
|
+
*/
|
|
68
|
+
const LETTER_OR_DIGIT = /[A-Za-z0-9]/;
|
|
69
|
+
|
|
70
|
+
function hasLetterOrDigit(value: string): boolean {
|
|
71
|
+
return LETTER_OR_DIGIT.test(value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Replace the value of a `<context><value>` match, unless the value holds no letter or digit. */
|
|
75
|
+
function redactContextValue(match: string, head = '', value = ''): string {
|
|
76
|
+
return hasLetterOrDigit(value) ? `${head}${REDACTED}` : match;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A URL password (connection string or HTTP basic auth), or a value found
|
|
80
|
+
// elsewhere, that is documentation, not a credential: a marker another redactor
|
|
81
|
+
// left, a template variable, a mask, a stock word, or a "your-api-key-here"
|
|
82
|
+
// phrase (production held one in consolidation history, 2026-10-13; chased, it
|
|
83
|
+
// would have replaced the phrase in a memory, a fact and two versions).
|
|
84
|
+
const PLACEHOLDER_PASSWORD =
|
|
85
|
+
/^(?:\[REDACTED(?::[A-Za-z0-9_-]+)?\]|redacted|<[^<>]*>|\{\{[^{}]*\}\}|\$\{[^{}]+\}|%[A-Za-z_][A-Za-z0-9_]*%|\*+|x{3,}|\.\.\.|\u2026|password|passwd|pass|pwd|pw|secret|changeme|change[_-]me|example|test|(?:your|my)[a-z_-]*?(?:password|passwd|pass|pwd|secret|key|token|here))$/i;
|
|
86
|
+
// $VAR is matched case-sensitively: an upper-case name is an environment
|
|
87
|
+
// variable; a lower-case `$ecret1` could be a real password.
|
|
88
|
+
const PLACEHOLDER_ENV_VAR = /^\$[A-Z_][A-Z0-9_]*$/;
|
|
89
|
+
|
|
90
|
+
function isPlaceholderPassword(password: string): boolean {
|
|
91
|
+
return PLACEHOLDER_PASSWORD.test(password) || PLACEHOLDER_ENV_VAR.test(password);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Replace the password of a `scheme://user:password@` match, keeping the rest. */
|
|
95
|
+
function redactUrlPassword(match: string, head = '', password = ''): string {
|
|
96
|
+
return isPlaceholderPassword(password) || !hasLetterOrDigit(password)
|
|
97
|
+
? match
|
|
98
|
+
: `${head}${REDACTED}@`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A shell word in a password position: single-quoted, double-quoted, or bare.
|
|
102
|
+
// A bare word stops at whitespace, quotes, backticks and shell operators. It
|
|
103
|
+
// never starts on a marker this module left, so a second pass changes nothing,
|
|
104
|
+
// nor on a bracket, brace, slash, backslash or comma, which in that position is
|
|
105
|
+
// code (a regex or template under construction, an awk pattern such as
|
|
106
|
+
// `/^PGPASSWORD=/`), not a password.
|
|
107
|
+
const SHELL_WORD = String.raw`'[^'\n]*'|"[^"\n]*"|(?!${REDACTED})[^\s'"\`;|&()[\]{}\\,/][^\s'"\`;|&]*`;
|
|
108
|
+
// A quote that may open a string around the whole assignment or command
|
|
109
|
+
// (`"PGPASSWORD=" pw` in awk, `"--password=" + pw` in code).
|
|
110
|
+
const OPENING_QUOTE = String.raw`(["'\`]?)`;
|
|
111
|
+
// Trailing punctuation of a bare word in prose ("... sshpass -p <password>.").
|
|
112
|
+
const TRAILING_PUNCTUATION = /[.,:;!?)\]}]+$/;
|
|
113
|
+
// Words that follow a password flag in prose about the flag, not a password:
|
|
114
|
+
// "sshpass -p is unsafe", "pass it with sshpass -p in a script".
|
|
115
|
+
const PROSE_WORDS = new Set([
|
|
116
|
+
'a',
|
|
117
|
+
'an',
|
|
118
|
+
'and',
|
|
119
|
+
'as',
|
|
120
|
+
'for',
|
|
121
|
+
'in',
|
|
122
|
+
'is',
|
|
123
|
+
'it',
|
|
124
|
+
'on',
|
|
125
|
+
'or',
|
|
126
|
+
'the',
|
|
127
|
+
'to',
|
|
128
|
+
'with',
|
|
129
|
+
'flag',
|
|
130
|
+
'option',
|
|
131
|
+
'argument',
|
|
132
|
+
'instead',
|
|
133
|
+
'which',
|
|
134
|
+
'that',
|
|
135
|
+
'this',
|
|
136
|
+
'puts',
|
|
137
|
+
'passes',
|
|
138
|
+
'leaks',
|
|
139
|
+
'exposes',
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
// A regex delimiter inside a bare word: a `/` that ends it or that code
|
|
143
|
+
// punctuation follows (`abc/ {`, `abc/{print`, `abc/)`). An awk or sed pattern
|
|
144
|
+
// such as `/^PGPASSWORD=abc/ { ... }`, not a password.
|
|
145
|
+
const REGEX_DELIMITER = /\/(?:[{}()[\];]|$)/;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The password a shell word carries, or null when it carries none: an
|
|
149
|
+
* expansion (`$PASS`, `"$PASS"`, `"$(cat file)"`; only single quotes make `$`
|
|
150
|
+
* literal), a documentation placeholder, a word with no letter or digit (code
|
|
151
|
+
* punctuation, an ellipsis), a quoted word that starts or ends with whitespace
|
|
152
|
+
* (its first quote closed a string, as in `"PGPASSWORD=" pw; ... "`), a bare
|
|
153
|
+
* word ending at a regex delimiter, or a prose word.
|
|
154
|
+
*/
|
|
155
|
+
function shellWordSecret(word: string): string | null {
|
|
156
|
+
const quote = word[0];
|
|
157
|
+
if ((quote === "'" || quote === '"') && word.length >= 2 && word.endsWith(quote)) {
|
|
158
|
+
const inner = word.slice(1, -1);
|
|
159
|
+
if (!hasLetterOrDigit(inner) || (quote === '"' && inner.startsWith('$'))) return null;
|
|
160
|
+
if (inner !== inner.trim()) return null;
|
|
161
|
+
return isPlaceholderPassword(inner) ? null : inner;
|
|
162
|
+
}
|
|
163
|
+
if (word.startsWith('$')) return null;
|
|
164
|
+
const core = word.replace(TRAILING_PUNCTUATION, '');
|
|
165
|
+
if (!hasLetterOrDigit(core) || isPlaceholderPassword(core)) return null;
|
|
166
|
+
if (REGEX_DELIMITER.test(core)) return null;
|
|
167
|
+
return PROSE_WORDS.has(core.toLowerCase()) ? null : core;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Replace the password word of a command, keeping the command around it. */
|
|
171
|
+
function redactShellWord(match: string, head = '', word = '', tail = ''): string {
|
|
172
|
+
return shellWordSecret(word) === null ? match : `${head}${REDACTED}${tail}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* As redactShellWord, for a rule that may start inside a string: when the
|
|
177
|
+
* assignment or option is itself quoted and its value position holds the same
|
|
178
|
+
* quote (`"PGPASSWORD=" pw`, `'--password=' + pw`), that quote closes the
|
|
179
|
+
* string; the program supplies the value from a variable.
|
|
180
|
+
*/
|
|
181
|
+
function redactQuotedShellWord(
|
|
182
|
+
match: string,
|
|
183
|
+
head = '',
|
|
184
|
+
openingQuote = '',
|
|
185
|
+
word = '',
|
|
186
|
+
tail = ''
|
|
187
|
+
): string {
|
|
188
|
+
if (openingQuote !== '' && word.startsWith(openingQuote)) return match;
|
|
189
|
+
return redactShellWord(match, head, word, tail);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// The programs sshpass is run in front of. A bare lower-case word after -p is
|
|
193
|
+
// taken for a password only when one of these follows it.
|
|
194
|
+
const SSHPASS_COMMAND = String.raw`ssh|scp|sftp|rsync|ssh-copy-id|ansible[\w-]*`;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* As redactShellWord, for `sshpass -p`, whose flag agents also write about
|
|
198
|
+
* ("the sshpass -p form, ...", "rules for sshpass -p passwords"): a bare
|
|
199
|
+
* all-lower-case word counts as a password only when the command sshpass runs
|
|
200
|
+
* follows it. Every sshpass password in production on 2026-10-12 (63 copies)
|
|
201
|
+
* was followed by ssh or scp; the one prose mention was a lower-case word
|
|
202
|
+
* followed by more prose. A lower-case password at the end of a line is the
|
|
203
|
+
* miss this accepts.
|
|
204
|
+
*/
|
|
205
|
+
function redactSshpassWord(
|
|
206
|
+
match: string,
|
|
207
|
+
head = '',
|
|
208
|
+
openingQuote = '',
|
|
209
|
+
word = '',
|
|
210
|
+
tail = '',
|
|
211
|
+
command = ''
|
|
212
|
+
): string {
|
|
213
|
+
if (openingQuote !== '' && word.startsWith(openingQuote)) return match;
|
|
214
|
+
const secret = shellWordSecret(word);
|
|
215
|
+
if (secret === null) return match;
|
|
216
|
+
const bare = word[0] !== "'" && word[0] !== '"';
|
|
217
|
+
if (bare && /^[a-z]+$/.test(secret) && command === '') return match;
|
|
218
|
+
return `${head}${REDACTED}${tail}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const PATTERNS: CredentialPattern[] = [
|
|
222
|
+
{
|
|
223
|
+
kind: 'bearer',
|
|
224
|
+
pattern: /(\bBearer\s+)([^\s]{40,})/gi,
|
|
225
|
+
replacement: redactContextValue,
|
|
226
|
+
secretGroup: 2,
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
// Before openai, whose sk- shape would otherwise claim these keys.
|
|
230
|
+
kind: 'anthropic',
|
|
231
|
+
pattern: /(?<![A-Za-z0-9_-])sk-ant-(?=[_-]*[A-Za-z0-9])[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])/g,
|
|
232
|
+
replacement: REDACTED,
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
kind: 'openai',
|
|
236
|
+
pattern:
|
|
237
|
+
/(?<![A-Za-z0-9_-])sk-(?:proj-)?(?=[_-]*[A-Za-z0-9])[A-Za-z0-9_-]{16,}(?![A-Za-z0-9_-])/g,
|
|
238
|
+
replacement: REDACTED,
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
kind: 'shopify',
|
|
242
|
+
pattern: /(?<![A-Za-z0-9_])shp(?:at|ss|ca)_[A-Za-z0-9]{16,}(?![A-Za-z0-9])/g,
|
|
243
|
+
replacement: REDACTED,
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
kind: 'aws_access_key',
|
|
247
|
+
pattern: /(?<![A-Z0-9])AKIA[A-Z0-9]{16}(?![A-Z0-9])/g,
|
|
248
|
+
replacement: REDACTED,
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
kind: 'github',
|
|
252
|
+
pattern: /(?<![A-Za-z0-9_])gh[opu]_[A-Za-z0-9]{20,}(?![A-Za-z0-9])/g,
|
|
253
|
+
replacement: REDACTED,
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
kind: 'slack',
|
|
257
|
+
pattern: /(?<![A-Za-z0-9-])xox[abp]-(?=-*[A-Za-z0-9])[A-Za-z0-9-]{10,}(?![A-Za-z0-9-])/g,
|
|
258
|
+
replacement: REDACTED,
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
kind: 'sentry',
|
|
262
|
+
pattern: /(?<![A-Za-z0-9_])sntry[us]_(?=[_-]*[A-Za-z0-9])[A-Za-z0-9_-]{16,}(?![A-Za-z0-9_-])/g,
|
|
263
|
+
replacement: REDACTED,
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
kind: 'fly',
|
|
267
|
+
pattern:
|
|
268
|
+
/(?<![A-Za-z0-9_])(?:FlyV1\s+)?fm2_(?=[_-]*[A-Za-z0-9])[A-Za-z0-9_-]{16,}(?![A-Za-z0-9_-])/g,
|
|
269
|
+
replacement: REDACTED,
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
kind: 'google_api_key',
|
|
273
|
+
pattern: /(?<![A-Za-z0-9_-])AIza(?=[_-]*[A-Za-z0-9])[0-9A-Za-z_-]{35}(?![A-Za-z0-9_-])/g,
|
|
274
|
+
replacement: REDACTED,
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
kind: 'jwt',
|
|
278
|
+
// header.payload.signature, both JSON segments starting eyJ. The length
|
|
279
|
+
// floors keep `eyJ...` prose and signature-less examples out.
|
|
280
|
+
pattern:
|
|
281
|
+
/(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{16,}(?![A-Za-z0-9_-])/g,
|
|
282
|
+
replacement: REDACTED,
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
kind: 'mnemonik_component_token',
|
|
286
|
+
// mnc_ / mncr_ + 43 base64url (oauth/components.ts); 32+ also catches a
|
|
287
|
+
// partial token. The body must contain an upper-case letter or digit, so a
|
|
288
|
+
// snake_case identifier is not mistaken for one; the 8-character
|
|
289
|
+
// display prefix (mnc_ + 8) is too short.
|
|
290
|
+
pattern: /(?<![A-Za-z0-9_])mncr?_(?=[A-Za-z0-9_-]*[A-Z0-9])[A-Za-z0-9_-]{32,}/g,
|
|
291
|
+
replacement: REDACTED,
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
kind: 'stripe',
|
|
295
|
+
// Secret and restricted keys (sk_/rk_ live|test) and webhook signing secrets.
|
|
296
|
+
pattern:
|
|
297
|
+
/(?<![A-Za-z0-9_])(?:(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}|whsec_[A-Za-z0-9]{20,})(?![A-Za-z0-9])/g,
|
|
298
|
+
replacement: REDACTED,
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
kind: 'mnemonik_proxy_token',
|
|
302
|
+
// pxt_ + 32 hex (the format server.ts validates). A bare prefix in prose is left.
|
|
303
|
+
pattern: /(?<![A-Za-z0-9_])pxt_[0-9a-f]{32,}/g,
|
|
304
|
+
replacement: REDACTED,
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
kind: 'mnemonik_api_key',
|
|
308
|
+
// mnk_ + 64 hex (AuthManager.generateApiKey); 32+ also catches a half key.
|
|
309
|
+
// The stored 12-character key_prefix (mnk_ + 8 hex) is not a credential.
|
|
310
|
+
pattern: /(?<![A-Za-z0-9_])mnk_[0-9a-f]{32,}/g,
|
|
311
|
+
replacement: REDACTED,
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
kind: 'mnemonik_api_key_legacy',
|
|
315
|
+
// mnk_ + 43 base64url: keys minted by hand before AuthManager's hex form
|
|
316
|
+
// (production's only key in 2026-10 has this shape, and one stored memory
|
|
317
|
+
// held a development key in it). Authentication accepts any mnk_ bearer.
|
|
318
|
+
// The body needs upper and lower case and a digit, so a snake_case
|
|
319
|
+
// identifier that starts mnk_ is not taken for one.
|
|
320
|
+
pattern:
|
|
321
|
+
/(?<![A-Za-z0-9_])mnk_(?=[A-Za-z0-9_-]*[A-Z])(?=[A-Za-z0-9_-]*[a-z])(?=[A-Za-z0-9_-]*[0-9])[A-Za-z0-9_-]{40,}(?![A-Za-z0-9_-])/g,
|
|
322
|
+
replacement: REDACTED,
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
kind: 'named_value',
|
|
326
|
+
pattern:
|
|
327
|
+
/(\b[A-Za-z_][A-Za-z0-9_]*(?:_TOKEN|_KEY|_SECRET|_PASSWORD|_PASS|APIKEY|API_KEY)\s*(?:=|:)\s*)([^\s]{16,})/gi,
|
|
328
|
+
replacement: redactContextValue,
|
|
329
|
+
secretGroup: 2,
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
kind: 'connection_string',
|
|
333
|
+
// scheme://user:password@host: only the password is replaced; scheme,
|
|
334
|
+
// user, host and database stay readable. The user may be empty
|
|
335
|
+
// (redis://:password@host). The password runs to the last `@` before a
|
|
336
|
+
// path, query, fragment, quote or whitespace, so an unencoded `@` inside
|
|
337
|
+
// it is still covered. Runs after named_value, which already replaces a
|
|
338
|
+
// whole URL assigned to a secret-named variable.
|
|
339
|
+
pattern:
|
|
340
|
+
/\b((?:postgres(?:ql)?|mysql|mariadb|mongodb|rediss?|amqps?|mssql|sqlserver)(?:\+[a-z0-9]+)?:\/\/[^\s:/?#@'"`<>]{0,256}:)([^\s/?#'"`]{1,512})@/gi,
|
|
341
|
+
replacement: redactUrlPassword,
|
|
342
|
+
secretGroup: 2,
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
kind: 'url_basic_auth',
|
|
346
|
+
// https://user:password@host: same shape and placeholder rules as a
|
|
347
|
+
// connection string; only the password is replaced.
|
|
348
|
+
pattern: /\b(https?:\/\/[^\s:/?#@'"`<>]{0,256}:)([^\s/?#'"`]{1,512})@/gi,
|
|
349
|
+
replacement: redactUrlPassword,
|
|
350
|
+
secretGroup: 2,
|
|
351
|
+
},
|
|
352
|
+
// Command-line passwords. Each keeps the command and replaces only the
|
|
353
|
+
// password word; an expansion or placeholder in that position is left.
|
|
354
|
+
{
|
|
355
|
+
// sshpass -p <password> (also -p'<password>' and -p"<password>"), after
|
|
356
|
+
// any other sshpass flags. 13 memories, 11 scanner chunks, 15 versions and
|
|
357
|
+
// 6 dated facts in production held one on 2026-10-12.
|
|
358
|
+
kind: 'sshpass_password',
|
|
359
|
+
pattern: new RegExp(
|
|
360
|
+
String.raw`(${OPENING_QUOTE}\bsshpass(?:[ \t]+-[a-oq-zA-Z]\S*)*[ \t]+-p[ \t]*)(${SHELL_WORD})()(?=((?:[ \t]+(?:${SSHPASS_COMMAND})(?![\w-]))?))`,
|
|
361
|
+
'g'
|
|
362
|
+
),
|
|
363
|
+
replacement: redactSshpassWord,
|
|
364
|
+
secretGroup: 3,
|
|
365
|
+
commandLine: true,
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
// Environment variables whose only purpose is a password, in shell
|
|
369
|
+
// assignment form (no space around `=`): libpq's PGPASSWORD (one memory,
|
|
370
|
+
// version and fact in production), MySQL's MYSQL_PWD, sshpass -e's
|
|
371
|
+
// SSHPASS. named_value needs an `_PASSWORD`-style suffix and 16
|
|
372
|
+
// characters, so it misses all three. Not an awk program that matches or
|
|
373
|
+
// prints the assignment: `/^PGPASSWORD=/ { print "PGPASSWORD=" pw }`.
|
|
374
|
+
kind: 'password_env_var',
|
|
375
|
+
pattern: new RegExp(
|
|
376
|
+
String.raw`(${OPENING_QUOTE}\b(?:PGPASSWORD|MYSQL_PWD|SSHPASS)=)(${SHELL_WORD})()`,
|
|
377
|
+
'g'
|
|
378
|
+
),
|
|
379
|
+
replacement: redactQuotedShellWord,
|
|
380
|
+
secretGroup: 3,
|
|
381
|
+
commandLine: true,
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
// --password=<value>, the long option every CLI that takes one spells
|
|
385
|
+
// this way. The `=` keeps prose about the option ("--password is
|
|
386
|
+
// required") out; the spaced form is not matched for that reason.
|
|
387
|
+
kind: 'password_option',
|
|
388
|
+
pattern: new RegExp(String.raw`(${OPENING_QUOTE}(?<![\w-])--password=)(${SHELL_WORD})()`, 'g'),
|
|
389
|
+
replacement: redactQuotedShellWord,
|
|
390
|
+
secretGroup: 3,
|
|
391
|
+
commandLine: true,
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
// echo <password> | sudo -S: sudo reads the password from stdin. One
|
|
395
|
+
// memory, two versions and two facts in production.
|
|
396
|
+
kind: 'sudo_password',
|
|
397
|
+
pattern: new RegExp(
|
|
398
|
+
String.raw`(\becho[ \t]+(?:-[neE]+[ \t]+)?)(${SHELL_WORD})([ \t]*\|[ \t]*sudo\b[^|\n]*?[ \t](?:-[A-Za-z]*S[A-Za-z]*|--stdin)\b)`,
|
|
399
|
+
'g'
|
|
400
|
+
),
|
|
401
|
+
replacement: redactShellWord,
|
|
402
|
+
secretGroup: 2,
|
|
403
|
+
commandLine: true,
|
|
404
|
+
},
|
|
405
|
+
];
|
|
406
|
+
|
|
407
|
+
function isPrivateKeyLabel(label: string): boolean {
|
|
408
|
+
if (label === 'PRIVATE KEY') return true;
|
|
409
|
+
if (!label.endsWith(' PRIVATE KEY')) return false;
|
|
410
|
+
const prefix = label.slice(0, -' PRIVATE KEY'.length);
|
|
411
|
+
return prefix.split(' ').every(
|
|
412
|
+
(word) =>
|
|
413
|
+
word.length > 0 &&
|
|
414
|
+
[...word].every((char) => {
|
|
415
|
+
const code = char.charCodeAt(0);
|
|
416
|
+
return (code >= 48 && code <= 57) || (code >= 65 && code <= 90);
|
|
417
|
+
})
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Linear, bounded PEM scanner. Unterminated or oversized blocks are preserved. */
|
|
422
|
+
function redactPemPrivateKeys(text: string): { text: string; redactions: number } {
|
|
423
|
+
const pieces: string[] = [];
|
|
424
|
+
let copiedThrough = 0;
|
|
425
|
+
let cursor = 0;
|
|
426
|
+
let redactions = 0;
|
|
427
|
+
|
|
428
|
+
while (cursor < text.length) {
|
|
429
|
+
const begin = text.indexOf(PEM_BEGIN, cursor);
|
|
430
|
+
if (begin === -1) break;
|
|
431
|
+
|
|
432
|
+
const labelStart = begin + PEM_BEGIN.length;
|
|
433
|
+
const headerEnd = text.indexOf('-----', labelStart);
|
|
434
|
+
if (headerEnd === -1 || headerEnd - labelStart > MAX_PEM_LABEL_LENGTH) {
|
|
435
|
+
cursor = labelStart;
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const label = text.slice(labelStart, headerEnd);
|
|
440
|
+
if (!isPrivateKeyLabel(label)) {
|
|
441
|
+
cursor = headerEnd + 5;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const bodyStart = headerEnd + 5;
|
|
446
|
+
const endMarker = `-----END ${label}-----`;
|
|
447
|
+
const nextBegin = text.indexOf(PEM_BEGIN, bodyStart);
|
|
448
|
+
const boundedEnd = Math.min(text.length, bodyStart + MAX_PEM_BODY_LENGTH + endMarker.length);
|
|
449
|
+
const scanEnd = nextBegin === -1 ? boundedEnd : Math.min(nextBegin, boundedEnd);
|
|
450
|
+
const endOffset = text.slice(bodyStart, scanEnd).indexOf(endMarker);
|
|
451
|
+
|
|
452
|
+
if (endOffset >= 0 && endOffset <= MAX_PEM_BODY_LENGTH) {
|
|
453
|
+
const end = bodyStart + endOffset + endMarker.length;
|
|
454
|
+
pieces.push(text.slice(copiedThrough, begin), REDACTED);
|
|
455
|
+
copiedThrough = end;
|
|
456
|
+
cursor = end;
|
|
457
|
+
redactions++;
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
cursor = scanEnd > begin ? scanEnd : labelStart;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (redactions === 0) return { text, redactions };
|
|
465
|
+
pieces.push(text.slice(copiedThrough));
|
|
466
|
+
return { text: pieces.join(''), redactions };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* The value a rule redacted, in the form worth chasing elsewhere in the text,
|
|
471
|
+
* or null when it is too short or too plain (isDistinctiveValue), a
|
|
472
|
+
* placeholder, or part of this module's own marker.
|
|
473
|
+
*/
|
|
474
|
+
function chaseableValue(raw: string, commandLine: boolean): string | null {
|
|
475
|
+
let value: string | null = raw;
|
|
476
|
+
if (commandLine) value = shellWordSecret(raw);
|
|
477
|
+
else if (/^(["'`]).+\1$/.test(raw)) value = raw.slice(1, -1);
|
|
478
|
+
if (value === null || !isDistinctiveValue(value)) return null;
|
|
479
|
+
if (isPlaceholderPassword(value) || REDACTED.includes(value)) return null;
|
|
480
|
+
return value;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function escapeRegExp(value: string): string {
|
|
484
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Redact every recurrence of `values` that stands as a whole word (not inside
|
|
489
|
+
* a longer run of letters, digits or underscores), outside the markers already
|
|
490
|
+
* in the text.
|
|
491
|
+
*/
|
|
492
|
+
function redactRecurrences(
|
|
493
|
+
text: string,
|
|
494
|
+
values: ReadonlySet<string>
|
|
495
|
+
): { text: string; redactions: number } {
|
|
496
|
+
const present = [...values].filter((value) => text.includes(value));
|
|
497
|
+
if (present.length === 0) return { text, redactions: 0 };
|
|
498
|
+
// Longest first, so a value that contains another is replaced whole.
|
|
499
|
+
present.sort((a, b) => b.length - a.length);
|
|
500
|
+
const pattern = new RegExp(
|
|
501
|
+
`(?<![A-Za-z0-9_])(?:${present.map(escapeRegExp).join('|')})(?![A-Za-z0-9_])`,
|
|
502
|
+
'g'
|
|
503
|
+
);
|
|
504
|
+
let redactions = 0;
|
|
505
|
+
const pieces = text.split(REDACTED).map((piece) =>
|
|
506
|
+
piece.replace(pattern, () => {
|
|
507
|
+
redactions++;
|
|
508
|
+
return REDACTED;
|
|
509
|
+
})
|
|
510
|
+
);
|
|
511
|
+
return { text: pieces.join(REDACTED), redactions };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function redact(
|
|
515
|
+
text: string,
|
|
516
|
+
options: RedactCredentialsOptions,
|
|
517
|
+
values: Set<string>
|
|
518
|
+
): { text: string; redactions: number; kinds: string[] } {
|
|
519
|
+
const commandLineOnly = options.commandLineOnly === true;
|
|
520
|
+
const pem = commandLineOnly ? { text, redactions: 0 } : redactPemPrivateKeys(text);
|
|
521
|
+
let redactedText = pem.text;
|
|
522
|
+
let redactions = pem.redactions;
|
|
523
|
+
const kinds: string[] = pem.redactions > 0 ? ['pem_private_key'] : [];
|
|
524
|
+
|
|
525
|
+
for (const { kind, pattern, replacement, secretGroup, commandLine } of PATTERNS) {
|
|
526
|
+
if (commandLineOnly && !commandLine) continue;
|
|
527
|
+
let matches = 0;
|
|
528
|
+
if (typeof replacement === 'string') {
|
|
529
|
+
matches = redactedText.match(pattern)?.length ?? 0;
|
|
530
|
+
redactedText = redactedText.replace(pattern, replacement);
|
|
531
|
+
} else {
|
|
532
|
+
redactedText = redactedText.replace(pattern, (match: string, ...args: unknown[]) => {
|
|
533
|
+
// args = [...captureGroups, offset, input]; a non-participating group is undefined.
|
|
534
|
+
const offsetIndex = args.findIndex((arg) => typeof arg === 'number');
|
|
535
|
+
const groups = args
|
|
536
|
+
.slice(0, offsetIndex)
|
|
537
|
+
.map((group) => (typeof group === 'string' ? group : ''));
|
|
538
|
+
const next = replacement(match, ...groups);
|
|
539
|
+
if (next !== match) {
|
|
540
|
+
matches++;
|
|
541
|
+
const secret = secretGroup ? groups[secretGroup - 1] : undefined;
|
|
542
|
+
const value = secret ? chaseableValue(secret, commandLine === true) : null;
|
|
543
|
+
if (value !== null) values.add(value);
|
|
544
|
+
}
|
|
545
|
+
return next;
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
if (matches > 0) {
|
|
549
|
+
redactions += matches;
|
|
550
|
+
kinds.push(kind);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const chase = new Set(values);
|
|
555
|
+
for (const known of options.knownValues ?? []) {
|
|
556
|
+
if (isDistinctiveValue(known) && !isPlaceholderPassword(known) && !REDACTED.includes(known)) {
|
|
557
|
+
chase.add(known);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
const repeated = redactRecurrences(redactedText, chase);
|
|
561
|
+
if (repeated.redactions > 0) {
|
|
562
|
+
redactedText = repeated.text;
|
|
563
|
+
redactions += repeated.redactions;
|
|
564
|
+
kinds.push('repeated_credential');
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return { text: redactedText, redactions, kinds };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
export function redactCredentials(
|
|
571
|
+
text: string,
|
|
572
|
+
options: RedactCredentialsOptions = {}
|
|
573
|
+
): {
|
|
574
|
+
text: string;
|
|
575
|
+
redactions: number;
|
|
576
|
+
kinds: string[];
|
|
577
|
+
} {
|
|
578
|
+
return redact(text, options, new Set());
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* The credential values the context rules (command-line passwords, URL
|
|
583
|
+
* passwords, named values, Bearer tokens) would redact from `text`, for a
|
|
584
|
+
* caller that redacts them across many texts (`knownValues`). The values are
|
|
585
|
+
* secrets: never log, print, store or return them.
|
|
586
|
+
*/
|
|
587
|
+
export function credentialValues(
|
|
588
|
+
text: string,
|
|
589
|
+
options: Pick<RedactCredentialsOptions, 'commandLineOnly'> = {}
|
|
590
|
+
): Set<string> {
|
|
591
|
+
const values = new Set<string>();
|
|
592
|
+
redact(text, options, values);
|
|
593
|
+
return values;
|
|
594
|
+
}
|