@link-foundation/sensitive-data-sanitizer 0.19.3 → 0.21.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +22 -2
  3. package/bin/sensitive-data-sanitizer.js +50 -28
  4. package/docs/API.md +355 -16
  5. package/docs/COVERAGE.md +19 -9
  6. package/docs/RULES.md +218 -0
  7. package/examples/second-pass-placeholders.mjs +28 -0
  8. package/package.json +12 -3
  9. package/src/allow-list.js +93 -0
  10. package/src/confidence.js +24 -9
  11. package/src/context.js +287 -40
  12. package/src/credential-spans.js +180 -0
  13. package/src/credentials-entry.d.ts +23 -0
  14. package/src/credentials-entry.js +62 -0
  15. package/src/credentials.js +51 -0
  16. package/src/crypto-addresses.js +74 -0
  17. package/src/detection.js +87 -41
  18. package/src/document-person-cues.js +1 -1
  19. package/src/encoded.js +137 -51
  20. package/src/encrypt.js +95 -0
  21. package/src/engines.js +33 -10
  22. package/src/entities.js +4 -3
  23. package/src/exempt.js +72 -0
  24. package/src/fake-memory.js +32 -0
  25. package/src/fake.js +33 -29
  26. package/src/features.js +29 -0
  27. package/src/files.js +57 -12
  28. package/src/gitleaks.js +82 -27
  29. package/src/html-tags.js +8 -0
  30. package/src/identifier-detector.js +36 -0
  31. package/src/identity-types.js +63 -0
  32. package/src/identity.js +3 -47
  33. package/src/index.d.ts +157 -3
  34. package/src/index.js +13 -0
  35. package/src/known.js +33 -15
  36. package/src/national-checks.js +209 -0
  37. package/src/national.js +310 -92
  38. package/src/pattern-detector.js +219 -0
  39. package/src/personal-context.js +3 -2
  40. package/src/personal-evidence.js +233 -0
  41. package/src/personal-features.js +39 -0
  42. package/src/personal.js +66 -10
  43. package/src/placeholder-types.js +154 -4
  44. package/src/placeholders.js +127 -45
  45. package/src/projection.js +78 -26
  46. package/src/proper-names.js +48 -5
  47. package/src/prose-words.js +120 -0
  48. package/src/record-worker.js +1 -0
  49. package/src/rule-catalog.js +426 -0
  50. package/src/rules.js +8 -0
  51. package/src/sanitizer.js +133 -23
  52. package/src/sarif.js +127 -0
  53. package/src/stream-worker.js +1 -0
  54. package/src/strict-attributes.js +48 -4
  55. package/src/strict.js +1 -1
  56. package/src/structured.js +2 -0
  57. package/src/token-checksum.js +49 -0
  58. package/src/transforms.js +146 -33
  59. package/src/vendor/betterleaks/LICENSE +21 -0
  60. package/src/vendor/betterleaks/provenance.json +11 -0
  61. package/src/vendor/betterleaks/rules-0.json +463 -0
  62. package/src/vendor/betterleaks/rules-1.json +454 -0
  63. package/src/vendor/betterleaks/rules-2.json +461 -0
  64. package/src/vendor/betterleaks/rules-3.json +455 -0
  65. package/src/vendor/betterleaks/rules-4.json +483 -0
  66. package/src/vendor/betterleaks/rules-5.json +35 -0
  67. package/src/vendor/entities/aws.json +1 -1
  68. package/src/vendor/entities/azure.json +6 -2
  69. package/src/vendor/entities/google.json +2 -1
  70. package/src/vocabulary.js +27 -1
  71. package/src/worker-options.js +1 -0
package/src/context.js CHANGED
@@ -2,7 +2,9 @@ import { labels } from './rules.js';
2
2
  import { escapePattern, REDACTED } from './detection.js';
3
3
  import { credentialPlaceholder, referencePrefix } from './credentials.js';
4
4
  import { servicePath } from './public.js';
5
+ import { cookieValues } from './credential-spans.js';
5
6
  import { plausiblePerson } from './names.js';
7
+ import { htmlTag } from './html-tags.js';
6
8
 
7
9
  // Bounded vocabularies are explicit so the short PASS/PW forms do not match
8
10
  // unrelated words such as passport. Verb forms cover the supported locales.
@@ -50,14 +52,34 @@ const label = labels.credential
50
52
  .filter((word) => !['auth', 'authorization'].includes(word))
51
53
  .map(escapePattern)
52
54
  .join('|');
53
- const key = `(?:[\\p{L}\\p{N}]+[_.-])*(?:auth|authorization|PGPASSWORD|${label}|pass|pw)(?:[_.-][\\p{L}\\p{N}]+)*`;
55
+ // Glued service prefixes (`dbpass`, `rootpassword`, `bindpw`, `tlspassword`)
56
+ // are fixed so that ordinary words ending in "pass" stay prose.
57
+ const glued =
58
+ '(?:db|user|root|admin|smtp|mail|ftp|sftp|ssh|redis|ldap|bind|proxy|tls|ssl|keystore|truststore|store|master|sys|app|login|account|acct|wifi|wpa)(?:password|passwd|pass|pwd|pw|secret|token)';
59
+ // Plural credential names (`--access-tokens`, `API_KEYS`) hold credentials;
60
+ // other plurals (`--max-tokens`, `tokens: 1234`) are accounting.
61
+ const plural =
62
+ '(?:access|refresh|auth|session|api|bearer|client)[_.-]?(?:tokens|keys|secrets)';
63
+ const key = `(?:[\\p{L}\\p{N}]+[_.-])*(?:auth|authorization|PGPASSWORD|${glued}|${plural}|${label}|pass|pw)(?:[_.-][\\p{L}\\p{N}]+)*`;
64
+ // Ruby/Perl/PHP hash rockets (`'password' => 'x'`, `:token => "x"`) take a
65
+ // quoted literal; an arrow function (`token => token.trim()`) does not.
66
+ const rocket = `=>(?=[ \\t]*\\\\*["'])`;
54
67
  // A qualifier such as "for staging" stays on one logical line: it never
55
68
  // crosses an escaped line break, an escape sequence or an enclosing quote.
56
69
  // Verbs are whole words, so "request" does not end in the verb "est".
57
70
  const credentialPrefix = new RegExp(
58
- `(?<![\\p{L}\\p{N}_])(?:\\\\*["'])?(${key})(?![\\p{L}\\p{N}_])(?:\\\\*["'])?(?:[ \\t]+(?:от|для|к|for|de|du|del)[ \\t]+[^:=\\r\\n"\\\\]{1,64})?[ \\t]*([:=:]|(?<![\\p{L}\\p{N}_])(?:${verbs})(?![\\p{L}\\p{N}_]))[ \\t]*`,
71
+ `(?<![\\p{L}\\p{N}_])(?:\\\\*["'])?(${key})(?![\\p{L}\\p{N}_])(?:\\\\*["'])?(?:[ \\t]+(?:от|для|к|for|de|du|del)[ \\t]+[^:=\\r\\n"\\\\]{1,64})?[ \\t]*(${rocket}|[:=:]|(?<![\\p{L}\\p{N}_])(?:${verbs})(?![\\p{L}\\p{N}_]))[ \\t]*`,
59
72
  'giu'
60
73
  );
74
+ // camelCase and PascalCase keys (`clientSecret`, `secretKey`, `dbPassword`,
75
+ // `ApiSecret`) are case-sensitive: the credential word starts a new word, so
76
+ // `tokenType`, `secretName` and `maxTokens` stay configuration metadata.
77
+ const camelKey =
78
+ '[\\p{L}][\\p{Ll}\\p{N}]*(?:\\p{Lu}[\\p{Ll}\\p{N}]*)*?(?:Secret|Password|Passwd|Passphrase|Pass|Pwd|Token|Credentials?|(?:Api|Private|Access|Secret|Signing|Encryption|Master|Client|App|Subscription|Account|Storage)Key)(?:Key|Value)?|(?:[Ss]ecret|[Pp]assword|[Pp]asswd|[Tt]oken|[Pp]rivate|[Aa]ccess|[Ss]igning|[Ee]ncryption)(?:Key|Value|Secret|Token)';
79
+ const camelPrefix = new RegExp(
80
+ `(?<![\\p{L}\\p{N}_$])(?:\\\\*["'])?(${camelKey})(?![\\p{L}\\p{N}_])(?:\\\\*["'])?[ \\t]*(${rocket}|[:=])[ \\t]*`,
81
+ 'gu'
82
+ );
61
83
  // Bare generic labels are common English nouns. After a verb they need a
62
84
  // value that looks generated; "the token is valid" is prose, not a secret.
63
85
  function generatedWord(text, range) {
@@ -107,8 +129,20 @@ function emitValue(text, start, end, emit, details, keyName = '') {
107
129
  emit({ start, end, ...details });
108
130
  }
109
131
 
132
+ // `HIVE_MIND_ROUTER_AUTH_GUARD=0`, `GIT_AUTH_TRANSPORT=1`, `AUTH_MODE=2`:
133
+ // a number or a boolean under a configuration name is a setting. Credential
134
+ // names (`AUTH_TOKEN=123456`, `AUTH_PASSWORD=1`) do not end in one of these.
135
+ const configurationSuffix =
136
+ /[_.-](?:guard|transport|mode|method|type|kind|flow|strategy|scheme|protocol|port|level|state|status|enabled?|disabled?|required|strict|debug|verbose|timeout|ttl|interval|retries|retry|attempts|count|limit|max|min|size|length|version|flag)$/i;
137
+ const configurationValue =
138
+ /^(?:[+-]?\d{1,9}(?:\.\d+)?|true|false|yes|no|on|off|enabled|disabled|none)$/i;
139
+ export function configurationSetting(keyName, value) {
140
+ return configurationSuffix.test(keyName) && configurationValue.test(value);
141
+ }
142
+
110
143
  function credentialMetadata(keyName, value) {
111
144
  return (
145
+ configurationSetting(keyName, value) ||
112
146
  (/(?:^|[_.-])auth[_.-](?:mode|method|type)$/i.test(keyName) &&
113
147
  /^(?:chatgpt|oauth2?|apikey|api_key|bearer|basic|none)$/i.test(value)) ||
114
148
  (keyName === 'id-token' && /^(?:write|read|none)$/.test(value)) ||
@@ -154,15 +188,27 @@ export function valueRange(text, start, mode = 'token', maxLength = Infinity) {
154
188
  }
155
189
  // Header credentials occupy a scheme and one token even inside shell/JSON
156
190
  // strings. A closing quote or escape belongs to the surrounding log record.
157
- export function authorizationRange(text, start) {
191
+ // The scheme (`token`, `Bearer`, `Digest`) stays readable; only the
192
+ // credential is returned. Auth-param lists (`Digest username="a", nonce=…`,
193
+ // AWS SigV4) return each secret parameter value.
194
+ export function authorizationRanges(text, start) {
158
195
  const opening = /^(\\*)(["'])/.exec(text.slice(start));
159
196
  start += opening?.[0].length ?? 0;
160
197
  const reference = referencePrefix(text.slice(start, start + 512));
161
198
  if (reference) {
162
- return { start, end: start + reference[0].length };
199
+ return [{ start, end: start + reference[0].length }];
163
200
  }
164
201
  const scheme = /^[a-z][a-z0-9_-]*[ \t]+/i.exec(text.slice(start));
165
202
  const tokenStart = start + (scheme?.[0].length ?? 0);
203
+ const params = scheme ? authParams(text, tokenStart) : undefined;
204
+ if (params) {
205
+ return params;
206
+ }
207
+ const end = authorizationEnd(text, tokenStart);
208
+ const token = text.slice(tokenStart, end);
209
+ return usableAuthorization(token) ? [{ start: tokenStart, end }] : [];
210
+ }
211
+ function authorizationEnd(text, tokenStart) {
166
212
  const brackets = [];
167
213
  let end = tokenStart;
168
214
  for (; end < text.length; end++) {
@@ -182,8 +228,69 @@ export function authorizationRange(text, start) {
182
228
  break;
183
229
  }
184
230
  }
185
- const token = text.slice(tokenStart, end);
186
- return usableAuthorization(token) ? { start, end } : undefined;
231
+ return end;
232
+ }
233
+
234
+ // RFC 7616/7235 parameters that name the request, not a secret.
235
+ const publicAuthParam =
236
+ /^(?:username|realm|uri|qop|algorithm|nc|charset|userhash|domain|stale|SignedHeaders|oauth_signature_method|oauth_version|oauth_timestamp|oauth_callback|keyId|headers|created|expires)$/i;
237
+ const authParam = /^([A-Za-z][\w-]*)[ \t]*=[ \t]*/;
238
+ function authParams(text, start) {
239
+ const ranges = [];
240
+ let cursor = start,
241
+ count = 0,
242
+ quoted = false;
243
+ for (;;) {
244
+ const name = authParam.exec(text.slice(cursor, cursor + 128));
245
+ if (!name) {
246
+ break;
247
+ }
248
+ const valueStart = cursor + name[0].length;
249
+ const value = authParamValue(text, valueStart);
250
+ if (!value) {
251
+ break;
252
+ }
253
+ count++;
254
+ quoted ||= value.quoted;
255
+ if (!publicAuthParam.test(name[1]) && value.end > value.start) {
256
+ ranges.push({ start: value.start, end: value.end });
257
+ }
258
+ const separator = /^[ \t]*,[ \t]*/.exec(
259
+ text.slice(value.after, value.after + 64)
260
+ );
261
+ if (!separator) {
262
+ break;
263
+ }
264
+ cursor = value.after + separator[0].length;
265
+ }
266
+ // `Basic dXNlcg==` is one token68 credential, not a parameter list.
267
+ return count > 1 || (count === 1 && quoted) ? ranges : undefined;
268
+ }
269
+ function authParamValue(text, start) {
270
+ const quote = /^(\\*)"/.exec(text.slice(start, start + 64));
271
+ if (quote) {
272
+ // Parameter values are short; a quote further away is not their end.
273
+ const window = text.slice(start, start + 4096);
274
+ const offset = window.indexOf(quote[0], quote[0].length);
275
+ const close = start + offset;
276
+ if (offset < 0 || /[\r\n]/.test(window.slice(0, offset))) {
277
+ return undefined;
278
+ }
279
+ const after = close + quote[0].length;
280
+ // A quoted value ends the header or continues the list.
281
+ if (
282
+ !/^(?:[ \t]*(?:,|$|[\r\n])|\\*["'\r\n])/.test(
283
+ text.slice(after, after + 64)
284
+ )
285
+ ) {
286
+ return undefined;
287
+ }
288
+ return { start: start + quote[0].length, end: close, after, quoted: true };
289
+ }
290
+ const token = /^[^\s,"'\\]{1,4096}/.exec(text.slice(start, start + 4096));
291
+ return token
292
+ ? { start, end: start + token[0].length, after: start + token[0].length }
293
+ : undefined;
187
294
  }
188
295
  function usableAuthorization(token) {
189
296
  return Boolean(token) && !credentialPlaceholder(token);
@@ -262,6 +369,25 @@ function operationalLocationPath(value) {
262
369
  servicePath(value) || /^(?:\.?\.?\/)?[\w.-]+(?:\/[\w.-]+)+\/?$/.test(value)
263
370
  );
264
371
  }
372
+ // Types with a fixed value shape decide on the value alone; `undefined` leaves
373
+ // the decision to the general checks.
374
+ function shapedValueAllowed(value, type) {
375
+ if (type === 'PHONE') {
376
+ return (
377
+ /^\+?[\d () .-]{7,40}$/.test(value) &&
378
+ /\d{3}/.test(value.replace(/\D/g, ''))
379
+ );
380
+ }
381
+ if (type === 'EMAIL') {
382
+ return /^[^\s@]+@[^\s@]+\.[\p{L}]{2,63}$/u.test(value);
383
+ }
384
+ // A URL holds no whitespace, so prose under a `url` key is left to the
385
+ // detectors of what the prose contains.
386
+ if (type === 'URL' && /\s/u.test(value)) {
387
+ return false;
388
+ }
389
+ return undefined;
390
+ }
265
391
  // Shared by native labels and the vendor catalogs. Code expressions and
266
392
  // line escapes are not personal field values, even when a key is familiar.
267
393
  export function personalValueAllowed(text, range, type, labelStart) {
@@ -275,14 +401,9 @@ export function personalValueAllowed(text, range, type, labelStart) {
275
401
  ) {
276
402
  return false;
277
403
  }
278
- if (type === 'PHONE') {
279
- return (
280
- /^\+?[\d () .-]{7,40}$/.test(value) &&
281
- /\d{3}/.test(value.replace(/\D/g, ''))
282
- );
283
- }
284
- if (type === 'EMAIL') {
285
- return /^[^\s@]+@[^\s@]+\.[\p{L}]{2,63}$/u.test(value);
404
+ const shaped = shapedValueAllowed(value, type);
405
+ if (shaped !== undefined) {
406
+ return shaped;
286
407
  }
287
408
  if (/^(?:\\[nrt]|[\r\n])/.test(value)) {
288
409
  return false;
@@ -359,36 +480,103 @@ function sourceReference(label, tail) {
359
480
  );
360
481
  }
361
482
 
483
+ // A camelCase key is usually source code; an unquoted member chain, call or
484
+ // type annotation after it (`config.clientSecret`, `apiToken: string;`) is
485
+ // a reference, not the secret itself.
486
+ function codeValue(text, start) {
487
+ return /^(?:[A-Za-z_$][\w$]*(?:\??\.[A-Za-z_$][\w$]*)+(?:\([^()\r\n]*\))?|[A-Za-z_$][\w$]*\([^()\r\n]*\)|(?:string|number|boolean|any|unknown|str|bytes|String|SecretStr|Optional\[str\])(?![\w$]))[ \t]*(?:[,;)}\]]|\r?\n|$)/u.test(
488
+ text.slice(start, start + 256)
489
+ );
490
+ }
491
+
492
+ // Picks how far a credential value runs: to the end of a query parameter, a
493
+ // token, a line or a prose word.
494
+ function credentialMode(text, match) {
495
+ const prefix = text.slice(Math.max(0, match.index - 512), match.index);
496
+ if (
497
+ /(?:https?:\/\/[^\s]*[?&]|^[?&])[^\s]*$/i.test(prefix) ||
498
+ /[?&]$/.test(prefix)
499
+ ) {
500
+ return 'query';
501
+ }
502
+ if (/^=>?$/.test(match[2]) || /["']\s*[::]/.test(match[0])) {
503
+ return 'token';
504
+ }
505
+ return /[::]/.test(match[2]) ? 'line' : 'prose';
506
+ }
507
+
508
+ // `token: <script>alert(1)</script>`: markup is the value. A bare
509
+ // `<placeholder>` that ends the value stays a placeholder.
510
+ const angleEnd = {
511
+ line: /[\r\n"]|\\+[nrt"']/,
512
+ token: /[\s,;}\]"']|\\+[nrt"']/,
513
+ };
514
+ // Formatting tags around a value (`token=<em>…</em>`) are dropped from the
515
+ // detection view instead.
516
+ const formattingTag = new RegExp(`^${htmlTag}`, 'i');
517
+ function angleValue(text, start, mode) {
518
+ const tail = text.slice(start, start + 4096);
519
+ if (text[start] !== '<' || !angleEnd[mode] || formattingTag.test(tail)) {
520
+ return undefined;
521
+ }
522
+ const placeholder = /^<[^<>\r\n]{1,64}>/.exec(tail)?.[0];
523
+ if (
524
+ placeholder &&
525
+ credentialPlaceholder(placeholder) &&
526
+ angleEnd[mode].test(tail[placeholder.length] ?? '\n')
527
+ ) {
528
+ return { start, end: start + placeholder.length };
529
+ }
530
+ const stop = tail.search(angleEnd[mode]);
531
+ return { start, end: start + (stop < 0 ? tail.length : stop) };
532
+ }
533
+
534
+ function credentialRange(text, match, camel) {
535
+ const start = match.index + match[0].length;
536
+ if (
537
+ sourceReference(match[1], text.slice(start)) ||
538
+ (camel && codeValue(text, start))
539
+ ) {
540
+ return undefined;
541
+ }
542
+ if (/^(?:proxy-)?authorization$/i.test(match[1])) {
543
+ return authorizationRanges(text, start);
544
+ }
545
+ const mode = credentialMode(text, match);
546
+ const range = angleValue(text, start, mode) ?? valueRange(text, start, mode);
547
+ const value = range && mode === 'prose' ? generatedWord(text, range) : range;
548
+ if (value && /^(?:set-)?cookie$/i.test(match[1])) {
549
+ return cookieValues(text, value.start, value.end);
550
+ }
551
+ return [value].filter(Boolean);
552
+ }
553
+
554
+ // `<password>…</password>`, `<clientSecret attr="1">…</clientSecret>`:
555
+ // attributes in the opening tag stay readable.
556
+ const element = (name) =>
557
+ `<(${name})(?:[ \\t\\r\\n]+[^<>]{0,512})?>([^<]+)</\\1[ \\t]*>`;
558
+ const xmlElement = new RegExp(element(key), 'giu');
559
+ const camelElement = new RegExp(element(camelKey), 'gu');
560
+
362
561
  function detectCredentials(text, emit) {
363
562
  const details = { category: 'credential', type: 'SECRET', rule: 'context' };
364
- for (const match of text.matchAll(credentialPrefix)) {
365
- const start = match.index + match[0].length;
366
- const prefix = text.slice(Math.max(0, match.index - 512), match.index);
367
- const query =
368
- /(?:https?:\/\/[^\s]*[?&]|^[?&])[^\s]*$/i.test(prefix) ||
369
- /[?&]$/.test(prefix);
370
- const mode = query
371
- ? 'query'
372
- : match[2] === '=' || /["']\s*[::]/.test(match[0])
373
- ? 'token'
374
- : /[::]/.test(match[2])
375
- ? 'line'
376
- : 'prose';
377
- let range = /^(?:proxy-)?authorization$/i.test(match[1])
378
- ? authorizationRange(text, start)
379
- : valueRange(text, start, mode);
380
- if (range && mode === 'prose') {
381
- range = generatedWord(text, range);
382
- }
383
- if (sourceReference(match[1], text.slice(start))) {
563
+ const matches = [
564
+ ...[...text.matchAll(credentialPrefix)].map((match) => [match, false]),
565
+ ...[...text.matchAll(camelPrefix)].map((match) => [match, true]),
566
+ ];
567
+ for (const [match, camel] of matches) {
568
+ // `https://x-access-token:…@host` is URL user info (`url-userinfo`).
569
+ if (/:\/\/$/.test(text.slice(Math.max(0, match.index - 3), match.index))) {
384
570
  continue;
385
571
  }
386
- if (range) {
387
- emitValue(text, range.start, range.end, emit, details, match[1]);
572
+ for (const value of credentialRange(text, match, camel) ?? []) {
573
+ emitValue(text, value.start, value.end, emit, details, match[1]);
388
574
  }
389
575
  }
390
- const xml = new RegExp(`<(${key})[ \\t]*>([^<]+)</\\1[ \\t]*>`, 'giu');
391
- for (const match of text.matchAll(xml)) {
576
+ for (const match of [
577
+ ...text.matchAll(xmlElement),
578
+ ...text.matchAll(camelElement),
579
+ ]) {
392
580
  const start = match.index + match[0].indexOf('>') + 1;
393
581
  emitValue(text, start, start + match[2].length, emit, details, match[1]);
394
582
  }
@@ -417,12 +605,19 @@ const personalPrefixes = Object.entries(labels)
417
605
  ];
418
606
  });
419
607
 
608
+ // "passport number is 912803456": the number word and a following linking
609
+ // verb belong to the label, never to the identity value.
610
+ const identitySuffix = new RegExp(
611
+ `^(?:(?:number|no\\.?|номер)[ \\t]*(?:[:=:]|(?:${verbs})(?![\\p{L}\\p{N}_]))?[ \\t]*|[№#][ \\t]*)`,
612
+ 'iu'
613
+ );
614
+
420
615
  function detectPersonalLabels(text, emit) {
421
616
  for (const [type, prefix] of personalPrefixes) {
422
617
  for (const match of text.matchAll(prefix)) {
423
618
  const identity = type === 'ID';
424
619
  const suffix = identity
425
- ? (/^(?:(?:number|no\.?|номер)[ \t]*[:=:]?[ \t]*|[№#][ \t]*)/iu.exec(
620
+ ? (identitySuffix.exec(
426
621
  text.slice(match.index + match[0].length)
427
622
  )?.[0] ?? '')
428
623
  : '';
@@ -488,7 +683,7 @@ function detectCommandPasswords(text, emit) {
488
683
  }
489
684
  if (/(?:^|[\s/])curl(?:\s|$)/.test(command)) {
490
685
  for (const match of command.matchAll(
491
- /(?:^|\s)(?:-u|--user)(?:[ \t]+|=)/g
686
+ /(?:^|\s)(?:-u|--user|-U|--proxy-user)(?:[ \t]+|=)/g
492
687
  )) {
493
688
  const range = valueRange(
494
689
  text,
@@ -502,10 +697,62 @@ function detectCommandPasswords(text, emit) {
502
697
  }
503
698
  }
504
699
 
700
+ // Kubernetes/compose environment entries name the secret in one field and
701
+ // carry it in the next: `{"name":"DB_PASSWORD","value":"hunter2"}` or
702
+ // `- name: DB_PASSWORD` followed by `value: hunter2`.
703
+ const namedValue = new RegExp(
704
+ `(["']?)name\\1[ \\t]*:[ \\t]*(["']?)(${key})\\2[ \\t]*,?[ \\t]*(?:\\r?\\n[ \\t]*)?(["']?)value\\4[ \\t]*:[ \\t]*`,
705
+ 'giu'
706
+ );
707
+ // `smtp.login(user, "hunter2")`, `ftp.login('anna', 'pw')`: a literal second
708
+ // argument of a login call is the password.
709
+ const loginCall =
710
+ /(?<![\p{L}\p{N}_])(?:login|log_in|logIn|signin|sign_in|signIn|authenticate|auth_login|bind_s|simple_bind_s|connect_with_password)[ \t]*\([ \t]*[^,()\r\n]{1,64},[ \t]*(["'])([^"'\\\r\n]{4,128})\1[ \t]*[,)]/gu;
711
+ // Modular crypt and Argon2 password hashes (`$2y$10$…`, `$apr1$…`, `$6$…`)
712
+ // from htpasswd, shadow files and user tables crack offline.
713
+ const passwordHash =
714
+ /(?<![\p{L}\p{N}_$])\$(?:2[abxy]?\$\d{2}\$[./A-Za-z0-9]{53}|apr1\$[./A-Za-z0-9]{1,8}\$[./A-Za-z0-9]{22}|1\$[./A-Za-z0-9]{1,8}\$[./A-Za-z0-9]{22}|[56]\$(?:rounds=\d{1,9}\$)?[./A-Za-z0-9]{1,16}\$[./A-Za-z0-9]{43,86}|y\$[./A-Za-z0-9]+\$[./A-Za-z0-9]{1,64}\$[./A-Za-z0-9]{43}|argon2(?:id|i|d)\$v=\d+\$m=\d+,t=\d+,p=\d+\$[A-Za-z0-9+/]{11,}\$[A-Za-z0-9+/]{16,})(?![./A-Za-z0-9$])/gu;
715
+ function detectCredentialForms(text, emit) {
716
+ const details = { category: 'credential', type: 'SECRET' };
717
+ if (/name/i.test(text)) {
718
+ for (const match of text.matchAll(namedValue)) {
719
+ const range = valueRange(text, match.index + match[0].length);
720
+ if (range) {
721
+ emitValue(
722
+ text,
723
+ range.start,
724
+ range.end,
725
+ emit,
726
+ { ...details, rule: 'named-value' },
727
+ match[3]
728
+ );
729
+ }
730
+ }
731
+ }
732
+ for (const match of text.matchAll(loginCall)) {
733
+ const start = match.index + match[0].lastIndexOf(match[2]);
734
+ emitValue(text, start, start + match[2].length, emit, {
735
+ ...details,
736
+ rule: 'login-call',
737
+ confidence: 0.85,
738
+ });
739
+ }
740
+ if (text.includes('$')) {
741
+ for (const match of text.matchAll(passwordHash)) {
742
+ emitValue(text, match.index, match.index + match[0].length, emit, {
743
+ ...details,
744
+ rule: 'password-hash',
745
+ confidence: 0.95,
746
+ });
747
+ }
748
+ }
749
+ }
750
+
505
751
  export function detectContext(text, emit, credentialsOnly = false) {
506
752
  if (credentialHint.test(text)) {
507
753
  detectCredentials(text, emit);
508
754
  }
755
+ detectCredentialForms(text, emit);
509
756
  if (!credentialsOnly) {
510
757
  detectPersonalLabels(text, emit);
511
758
  }
@@ -0,0 +1,180 @@
1
+ import { maskedValue } from './credentials.js';
2
+
3
+ // Credential findings cover the secret, not the readable context around it:
4
+ // PEM markers, URL hosts and user names, cookie names and the rest of a
5
+ // sentence stay visible, so a masked log still shows what was masked.
6
+
7
+ // Context rules that read "to the end of the value" without knowing the
8
+ // value's format. A structured vendor token that starts the same value
9
+ // bounds them.
10
+ const openEnded = new Set([
11
+ 'context',
12
+ 'cookie',
13
+ 'named-value',
14
+ 'cli-password',
15
+ 'quoted-entropy',
16
+ 'entropy',
17
+ 'gitleaks/generic-api-key',
18
+ 'betterleaks/generic-api-key',
19
+ ]);
20
+ const notBounding = new Set([...openEnded, 'known-secret', 'authorization']);
21
+ const lineBreak = '(?:\\r?\\n|\\\\+[nr])';
22
+ const pemMarker = new RegExp(
23
+ `-----(BEGIN|END) [A-Z0-9 _-]{0,100}-----(?:[ \\t]*${lineBreak})?`,
24
+ 'g'
25
+ );
26
+
27
+ // Body spans of every BEGIN/END block inside [start, end). Text between two
28
+ // blocks never belongs to a key; an unterminated block runs to `end`.
29
+ export function pemBodies(text, start, end) {
30
+ const segment = text.slice(start, end);
31
+ if (!/^(?:\\*["'])?-----BEGIN /.test(segment)) {
32
+ return undefined;
33
+ }
34
+ const bodies = [];
35
+ let open;
36
+ for (const marker of segment.matchAll(pemMarker)) {
37
+ if (marker[1] === 'BEGIN') {
38
+ open = marker.index + marker[0].length;
39
+ } else if (open !== undefined) {
40
+ bodies.push(trimmed(text, start + open, start + marker.index));
41
+ open = undefined;
42
+ }
43
+ }
44
+ if (open !== undefined) {
45
+ bodies.push(trimmed(text, start + open, end));
46
+ }
47
+ return bodies.filter((body) => body.end > body.start);
48
+ }
49
+ function trimmed(text, start, end) {
50
+ const body = text.slice(start, end);
51
+ const lead = /^(?:\s|\\+[nrt])+/.exec(body)?.[0].length ?? 0;
52
+ const tail = /(?:\s|\\+[nrt])+$/.exec(body.slice(lead))?.[0].length ?? 0;
53
+ return { start: start + lead, end: end - tail };
54
+ }
55
+
56
+ // `scheme://user:password@host`: only the password is the credential.
57
+ const userinfo = /^[a-z][a-z0-9+.-]*:\/\/([^\s/@:]+):([^\s/@]+)@/i;
58
+ // A URL secret keeps its scheme, host and fixed path words
59
+ // (`https://hooks.slack.com/services/`), so a reader still sees which
60
+ // endpoint it was.
61
+ const serviceUser =
62
+ /^(?:x-access-token|x-oauth-basic|x-token-auth|oauth2?|git|gitlab-ci-token|token|pat|apikey|api)$/i;
63
+ const endpoint = /^(?:https?:\/\/)?[a-z0-9-]+(?:\.[a-z0-9-]+)+\//i;
64
+ const pathWord = /^(?:[a-z]{2,20}(?:\d{1,2})?\/)+/i;
65
+
66
+ function urlSecret(text, finding) {
67
+ const value = text.slice(finding.start, finding.end);
68
+ // `url-userinfo` already covers only `user:password`.
69
+ const own = finding.rule === 'url-userinfo';
70
+ const credentials = own
71
+ ? /^([^:]+):(.+)$/s.exec(value)
72
+ : userinfo.exec(value);
73
+ if (credentials) {
74
+ const end = finding.start + credentials[0].length - (own ? 0 : 1);
75
+ const start = end - credentials[2].length;
76
+ const user = start - 1 - credentials[1].length;
77
+ return [
78
+ { start, end },
79
+ // Outside credentialsOnly the user name is still personal data.
80
+ ...(serviceUser.test(credentials[1]) || maskedValue(credentials[1])
81
+ ? []
82
+ : [
83
+ {
84
+ start: user,
85
+ end: user + credentials[1].length,
86
+ type: 'USERNAME',
87
+ category: 'personal',
88
+ },
89
+ ]),
90
+ ];
91
+ }
92
+ const host = endpoint.exec(value);
93
+ if (!host || finding.rule === 'url-userinfo') {
94
+ return undefined;
95
+ }
96
+ const words = pathWord.exec(value.slice(host[0].length))?.[0] ?? '';
97
+ const start = finding.start + host[0].length + words.length;
98
+ return finding.end - start >= 8 ? [{ start, end: finding.end }] : undefined;
99
+ }
100
+
101
+ function narrowed(text, finding) {
102
+ if (finding.category !== 'credential') {
103
+ return [finding];
104
+ }
105
+ const parts =
106
+ pemBodies(text, finding.start, finding.end) ?? urlSecret(text, finding);
107
+ return parts ? parts.map((part) => ({ ...finding, ...part })) : [finding];
108
+ }
109
+
110
+ // `token: ghp_… . Next sentence` — a context value that a vendor rule ends
111
+ // earlier stops at that end when only punctuation or a space follows.
112
+ function bounded(text, finding, findings) {
113
+ if (!openEnded.has(finding.rule) || finding.category !== 'credential') {
114
+ return finding;
115
+ }
116
+ let end = finding.end;
117
+ for (const other of findings) {
118
+ if (
119
+ other.category === 'credential' &&
120
+ !notBounding.has(other.rule) &&
121
+ other.start === finding.start &&
122
+ other.end < end &&
123
+ other.end > other.start &&
124
+ /^[.,;:!?]*(?:\s|$)/.test(text.slice(other.end, finding.end))
125
+ ) {
126
+ end = other.end;
127
+ }
128
+ }
129
+ return end === finding.end ? finding : { ...finding, end };
130
+ }
131
+
132
+ // PEM armor (`-----BEGIN RSA PRIVATE KEY-----`) stays readable, and its
133
+ // upper-case words are not a person or an organization.
134
+ function pemMarkers(text, findings) {
135
+ const markers = [];
136
+ for (const finding of findings) {
137
+ const segment = text.slice(finding.start, finding.end);
138
+ if (finding.category === 'credential' && /-----BEGIN /.test(segment)) {
139
+ for (const marker of segment.matchAll(pemMarker)) {
140
+ const start = finding.start + marker.index;
141
+ markers.push({ start, end: start + marker[0].length });
142
+ }
143
+ }
144
+ }
145
+ return markers;
146
+ }
147
+
148
+ export function refineCredentialSpans(text, findings) {
149
+ const markers = pemMarkers(text, findings);
150
+ const narrow = findings
151
+ .filter(
152
+ (finding) =>
153
+ finding.category === 'credential' ||
154
+ !markers.some(
155
+ (marker) => finding.start < marker.end && marker.start < finding.end
156
+ )
157
+ )
158
+ .flatMap((finding) => narrowed(text, finding));
159
+ return narrow.map((finding) => bounded(text, finding, narrow));
160
+ }
161
+
162
+ // `Cookie: a=1; b=2` and `Set-Cookie: id=…; Path=/; HttpOnly`: each cookie
163
+ // value is a credential; names, separators and attributes stay readable.
164
+ const cookieAttribute =
165
+ /^(?:path|domain|expires|max-age|samesite|secure|httponly|priority|partitioned|version|comment)$/i;
166
+ export function cookieValues(text, start, end) {
167
+ const values = [];
168
+ for (const pair of text.slice(start, end).matchAll(/[^;]+/g)) {
169
+ const part = /^[ \t]*(?:([^=\s]+)[ \t]*=[ \t]*)?(.*?)[ \t]*$/s.exec(
170
+ pair[0]
171
+ );
172
+ const [, name, value] = part;
173
+ if (!value || cookieAttribute.test(name ?? value)) {
174
+ continue;
175
+ }
176
+ const valueEnd = start + pair.index + pair[0].replace(/[ \t]+$/, '').length;
177
+ values.push({ start: valueEnd - value.length, end: valueEnd });
178
+ }
179
+ return values;
180
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Credentials-only entry point. It has the same API as the main entry for the
3
+ * functions it exports, always runs with `credentialsOnly: true`, and rejects
4
+ * `credentialsOnly: false` and `mode: 'fake'` with `ERR_CONFIG`.
5
+ */
6
+ export type * from './index.js';
7
+ export {
8
+ inspect,
9
+ sanitize,
10
+ redact,
11
+ createSanitizer,
12
+ createSyncSanitizer,
13
+ REDACTED,
14
+ entropy,
15
+ builtInRules,
16
+ tokenChecksum,
17
+ tokenWithChecksum,
18
+ knownSecretsFromEnv,
19
+ knownSecretsFromGitHubAuth,
20
+ knownSecretsFromGitHubConfig,
21
+ reservedPlaceholderTypes,
22
+ isPlaceholderToken,
23
+ } from './index.js';