@link-foundation/sensitive-data-sanitizer 0.21.4 → 0.21.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.21.6
4
+
5
+ ### Patch Changes
6
+
7
+ - cbd6b3b: Preserve complete credential placeholders and public configuration values before Markdown backticks, including the exact `HIVE_MIND_CLAUDE_AUTH_AUTHORITY=conventional` opt-out. Keep masking actual HTML/script and keyword-prefixed credentials, with exact known secrets taking priority.
8
+
9
+ ## 0.21.5
10
+
11
+ ### Patch Changes
12
+
13
+ - e61a218: Preserve Authorization and Proxy-Authorization scheme words when overlapping credential heuristics match exactly the scheme before a separate payload. Continue masking payloads, bare credentials, and exact known secrets.
14
+
3
15
  ## 0.21.4
4
16
 
5
17
  ### Patch Changes
package/docs/API.md CHANGED
@@ -579,7 +579,7 @@ permission values and literal credential references. `publicKnowledge: false`
579
579
  restores IP detection; explicit `knownSecrets` and `knownPersonal` retain
580
580
  priority. No shell command or workflow expression is executed.
581
581
 
582
- Authorization headers (including Proxy-Authorization) redact the scheme and one credential token. Whitespace, C0 controls (including NUL), DEL, quotes, backslashes and commas end the value, preserving subsequent log text in shell and escaped JSON prompts. Matching brackets opened within a token are included in its redaction; unmatched closing brackets remain part of the surrounding log. Already-redacted tokens and known credential references remain exempt after an optional scheme, including `<token>` placeholders. The debug callback receives value-free `gitleaks-rule` events with rule ID, input length and elapsed milliseconds; tracing is off by default.
582
+ Authorization headers (including Proxy-Authorization) preserve the scheme and redact the credential payload. Overlapping heuristic findings that cover exactly a scheme followed by a separate nonempty payload are ignored, including Gitleaks generic API-key findings on `SharedAccessSignature`. Bare credentials without a scheme, payload findings and exact `knownSecrets` matches remain subject to redaction. Whitespace, C0 controls (including NUL), DEL, quotes, backslashes and commas end the value, preserving subsequent log text in shell and escaped JSON prompts. Matching brackets opened within a token are included in its redaction; unmatched closing brackets remain part of the surrounding log. Already-redacted tokens and known credential references remain exempt after an optional scheme, including `<token>` placeholders. The debug callback receives value-free `gitleaks-rule` events with rule ID, input length and elapsed milliseconds; tracing is off by default.
583
583
 
584
584
  ## Caller-declared sensitive fields
585
585
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-foundation/sensitive-data-sanitizer",
3
- "version": "0.21.4",
3
+ "version": "0.21.6",
4
4
  "description": "Offline sensitive-text sanitization with credential, multilingual personal-data and scanner union detection",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/context.js CHANGED
@@ -143,6 +143,10 @@ export function configurationSetting(keyName, value) {
143
143
  function credentialMetadata(keyName, value) {
144
144
  return (
145
145
  configurationSetting(keyName, value) ||
146
+ // This documented opt-out enum is public; other authority values and
147
+ // names still carry credential evidence.
148
+ (keyName === 'HIVE_MIND_CLAUDE_AUTH_AUTHORITY' &&
149
+ value === 'conventional') ||
146
150
  (/(?:^|[_.-])auth[_.-](?:mode|method|type)$/i.test(keyName) &&
147
151
  /^(?:chatgpt|oauth2?|apikey|api_key|bearer|basic|none)$/i.test(value)) ||
148
152
  (keyName === 'id-token' && /^(?:write|read|none)$/.test(value)) ||
@@ -160,11 +164,13 @@ function credentialMetadata(keyName, value) {
160
164
  // Only a quote at the same escape depth closes it; embedded quotes belong to
161
165
  // the value. Offsets cover escape bytes as well as the decoded characters.
162
166
  export function valueRange(text, start, mode = 'token', maxLength = Infinity) {
163
- const reference = referencePrefix(text.slice(start, start + 512));
167
+ const reference = referencePrefix(
168
+ text.slice(start, markdownEnd(text, start, 512))
169
+ );
164
170
  if (reference) {
165
171
  return { start, end: start + reference[0].length };
166
172
  }
167
- const opening = text.slice(start).match(/^(\\*)(["'])/);
173
+ const opening = text.slice(start).match(/^(\\*)(["']|`+)/);
168
174
  if (opening) {
169
175
  const range = quotedRange(text, start, opening, maxLength);
170
176
  const outer = outerQuoteEnd(text, range.start, range.end);
@@ -173,7 +179,8 @@ export function valueRange(text, start, mode = 'token', maxLength = Infinity) {
173
179
  if (/^[{[]/.test(text.slice(start, start + 1))) {
174
180
  return undefined;
175
181
  }
176
- const tail = text.slice(start, start + maxLength);
182
+ const tail = text.slice(start, markdownEnd(text, start, maxLength));
183
+ // Only a matching surrounding Markdown delimiter ends an unquoted value.
177
184
  const terminator =
178
185
  mode === 'query'
179
186
  ? /[\s&#"'<>]/
@@ -198,8 +205,8 @@ export function authorizationRanges(text, start) {
198
205
  if (reference) {
199
206
  return [{ start, end: start + reference[0].length }];
200
207
  }
201
- const scheme = /^[a-z][a-z0-9_-]*[ \t]+/i.exec(text.slice(start));
202
- const tokenStart = start + (scheme?.[0].length ?? 0);
208
+ const scheme = authorizationScheme(text, start);
209
+ const tokenStart = scheme?.payloadStart ?? start;
203
210
  const params = scheme ? authParams(text, tokenStart) : undefined;
204
211
  if (params) {
205
212
  return params;
@@ -208,6 +215,25 @@ export function authorizationRanges(text, start) {
208
215
  const token = text.slice(tokenStart, end);
209
216
  return usableAuthorization(token) ? [{ start: tokenStart, end }] : [];
210
217
  }
218
+
219
+ // A scheme is structural only when a separate, nonempty payload follows it.
220
+ // Share this boundary with span refinement so overlapping vendor detections
221
+ // cannot mask the scheme that the Authorization parser deliberately preserves.
222
+ export function authorizationScheme(text, start) {
223
+ const opening = /^(\\*)(["'])/.exec(text.slice(start));
224
+ start += opening?.[0].length ?? 0;
225
+ const scheme = /^([a-z][a-z0-9_-]*)[ \t]+/i.exec(text.slice(start));
226
+ if (!scheme) {
227
+ return undefined;
228
+ }
229
+ const payloadStart = start + scheme[0].length;
230
+ const char = text[payloadStart];
231
+ return char &&
232
+ !authorizationDelimiter(char, text.charCodeAt(payloadStart)) &&
233
+ !/[}\]>]/.test(char)
234
+ ? { start, end: start + scheme[1].length, payloadStart }
235
+ : undefined;
236
+ }
211
237
  function authorizationEnd(text, tokenStart) {
212
238
  const brackets = [];
213
239
  let end = tokenStart;
@@ -302,6 +328,9 @@ function quotedRange(text, start, opening, maxLength) {
302
328
  const depth = opening[1].length,
303
329
  quote = opening[2];
304
330
  const valueStart = start + opening[0].length;
331
+ if (quote[0] === '`') {
332
+ return markdownRange(text, valueStart, quote.length, maxLength);
333
+ }
305
334
  for (
306
335
  let i = valueStart;
307
336
  i < Math.min(text.length, valueStart + maxLength);
@@ -334,6 +363,64 @@ function quotedRange(text, start, opening, maxLength) {
334
363
  };
335
364
  }
336
365
 
366
+ // Markdown closes only at a run of the same length. Scan each run once so
367
+ // long unmatched backtick runs cannot cause repeated prefix comparisons.
368
+ function markdownRange(text, start, length, maxLength) {
369
+ const limit = Math.min(text.length, start + maxLength);
370
+ for (let i = start; i < limit; i++) {
371
+ if (text[i] !== '`') {
372
+ continue;
373
+ }
374
+ let end = i + 1;
375
+ while (end < limit && text[end] === '`') {
376
+ end++;
377
+ }
378
+ if (end - i === length && text[end] !== '`') {
379
+ return { start, end: i };
380
+ }
381
+ i = end - 1;
382
+ }
383
+ return { start, end: limit };
384
+ }
385
+
386
+ // Recognize surrounding inline Markdown within a bounded logical line. When
387
+ // its opening or closing delimiter cannot be established, keep scanning the
388
+ // full credential, including any literal material following a placeholder.
389
+ function markdownEnd(text, start, maxLength) {
390
+ const end = Math.min(text.length, start + maxLength);
391
+ const from = Math.max(0, start - 4096);
392
+ const prefix = text.slice(from, start);
393
+ const lineStart =
394
+ Math.max(prefix.lastIndexOf('\n'), prefix.lastIndexOf('\r')) + 1;
395
+ if (from && !lineStart) {
396
+ return end;
397
+ }
398
+ let length = 0;
399
+ for (const run of prefix.slice(lineStart).matchAll(/`+/g)) {
400
+ if (!length) {
401
+ let slashes = 0;
402
+ for (
403
+ let i = lineStart + run.index - 1;
404
+ i >= 0 && prefix[i] === '\\';
405
+ i--
406
+ ) {
407
+ slashes++;
408
+ }
409
+ if (slashes % 2 === 0) {
410
+ length = run[0].length;
411
+ }
412
+ } else if (length === run[0].length) {
413
+ length = 0;
414
+ }
415
+ }
416
+ if (!length) {
417
+ return end;
418
+ }
419
+ const limit = Math.min(end, start + 4096);
420
+ const close = markdownRange(text, start, length, limit - start).end;
421
+ return close < limit ? close : end;
422
+ }
423
+
337
424
  function plausibleLabel(value, type) {
338
425
  if (operationalValue(value, type)) {
339
426
  return false;
@@ -515,8 +602,11 @@ const angleEnd = {
515
602
  // detection view instead.
516
603
  const formattingTag = new RegExp(`^${htmlTag}`, 'i');
517
604
  function angleValue(text, start, mode) {
518
- const tail = text.slice(start, start + 4096);
519
- if (text[start] !== '<' || !angleEnd[mode] || formattingTag.test(tail)) {
605
+ if (text[start] !== '<' || !angleEnd[mode]) {
606
+ return undefined;
607
+ }
608
+ const tail = text.slice(start, markdownEnd(text, start, 4096));
609
+ if (formattingTag.test(tail)) {
520
610
  return undefined;
521
611
  }
522
612
  const placeholder = /^<[^<>\r\n]{1,64}>/.exec(tail)?.[0];
@@ -531,6 +621,23 @@ function angleValue(text, start, mode) {
531
621
  return { start, end: start + (stop < 0 ? tail.length : stop) };
532
622
  }
533
623
 
624
+ // Scheme-prefixed values carry the credential after the keyword, just as
625
+ // Authorization headers do. Never mask only `token` and leave its value.
626
+ function credentialValueRange(text, start, mode) {
627
+ const valueStart = start;
628
+ if (mode === 'token') {
629
+ start +=
630
+ /^(?:token|Bearer|Basic)[ \t]+(?=\S)/i.exec(text.slice(start))?.[0]
631
+ .length ?? 0;
632
+ }
633
+ const range = angleValue(text, start, mode) ?? valueRange(text, start, mode);
634
+ // Keep the prefix in the value used for metadata checks: `token 0` is not
635
+ // the exact public setting `0`, even under a configuration name.
636
+ return range && start !== valueStart
637
+ ? { ...range, start: valueStart }
638
+ : range;
639
+ }
640
+
534
641
  function credentialRange(text, match, camel) {
535
642
  const start = match.index + match[0].length;
536
643
  if (
@@ -543,7 +650,7 @@ function credentialRange(text, match, camel) {
543
650
  return authorizationRanges(text, start);
544
651
  }
545
652
  const mode = credentialMode(text, match);
546
- const range = angleValue(text, start, mode) ?? valueRange(text, start, mode);
653
+ const range = credentialValueRange(text, start, mode);
547
654
  const value = range && mode === 'prose' ? generatedWord(text, range) : range;
548
655
  if (value && /^(?:set-)?cookie$/i.test(match[1])) {
549
656
  return cookieValues(text, value.start, value.end);
@@ -582,7 +689,8 @@ function detectCredentials(text, emit) {
582
689
  }
583
690
  const flags = new RegExp(`--(${key})(?:[ \\t]+|=)`, 'giu');
584
691
  for (const match of text.matchAll(flags)) {
585
- const range = valueRange(text, match.index + match[0].length);
692
+ const start = match.index + match[0].length;
693
+ const range = credentialValueRange(text, start, 'token');
586
694
  if (range) {
587
695
  emitValue(text, range.start, range.end, emit, details, match[1]);
588
696
  }
@@ -1,4 +1,5 @@
1
1
  import { maskedValue } from './credentials.js';
2
+ import { authorizationScheme } from './context.js';
2
3
 
3
4
  // Credential findings cover the secret, not the readable context around it:
4
5
  // PEM markers, URL hosts and user names, cookie names and the rest of a
@@ -159,7 +160,22 @@ function pemMarkers(text, findings) {
159
160
 
160
161
  export function refineCredentialSpans(text, findings) {
161
162
  const markers = pemMarkers(text, findings);
163
+ const schemes = new Map();
164
+ for (const header of text.matchAll(
165
+ /(?<![\w-])(?:Proxy-)?Authorization(?:\\*["'])?[ \t]*:[ \t]*/gi
166
+ )) {
167
+ const scheme = authorizationScheme(text, header.index + header[0].length);
168
+ if (scheme) {
169
+ schemes.set(scheme.start, scheme.end);
170
+ }
171
+ }
162
172
  const narrow = findings
173
+ .filter(
174
+ (finding) =>
175
+ finding.category !== 'credential' ||
176
+ finding.rule === 'known-secret' ||
177
+ schemes.get(finding.start) !== finding.end
178
+ )
163
179
  .filter(
164
180
  (finding) =>
165
181
  finding.category === 'credential' ||
@@ -89,7 +89,7 @@ export function templatedCredential(text, finding) {
89
89
  // Only variable references and the documented gh lookup are exempt. Arbitrary
90
90
  // shell commands and Actions expressions can contain literal credentials.
91
91
  export function referencePrefix(value) {
92
- return /^(?:(?:token|Bearer|Basic)\s+)?(?:\$\{\{\s*(?:secrets|env|vars|github|inputs)\.[A-Za-z_][A-Za-z0-9_.]*\s*\}\}|\$\(gh\s+auth\s+token(?:\s+--hostname\s+[a-z0-9.-]+)?\))(?=$|[\s"',;\\])/i.exec(
92
+ return /^(?:(?:token|Bearer|Basic)\s+)?(?:\$\{[A-Za-z_][A-Za-z0-9_]*\}|\$[A-Za-z_][A-Za-z0-9_]*|\$\{\{\s*(?:secrets|env|vars|github|inputs)\.[A-Za-z_][A-Za-z0-9_.]*\s*\}\}|\$\(gh\s+auth\s+token(?:\s+--hostname\s+[a-z0-9.-]+)?\))(?=$|[\s"',;\\])/i.exec(
93
93
  value
94
94
  );
95
95
  }