@link-foundation/sensitive-data-sanitizer 0.21.5 → 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,11 @@
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
+
3
9
  ## 0.21.5
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-foundation/sensitive-data-sanitizer",
3
- "version": "0.21.5",
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&#"'<>]/
@@ -321,6 +328,9 @@ function quotedRange(text, start, opening, maxLength) {
321
328
  const depth = opening[1].length,
322
329
  quote = opening[2];
323
330
  const valueStart = start + opening[0].length;
331
+ if (quote[0] === '`') {
332
+ return markdownRange(text, valueStart, quote.length, maxLength);
333
+ }
324
334
  for (
325
335
  let i = valueStart;
326
336
  i < Math.min(text.length, valueStart + maxLength);
@@ -353,6 +363,64 @@ function quotedRange(text, start, opening, maxLength) {
353
363
  };
354
364
  }
355
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
+
356
424
  function plausibleLabel(value, type) {
357
425
  if (operationalValue(value, type)) {
358
426
  return false;
@@ -534,8 +602,11 @@ const angleEnd = {
534
602
  // detection view instead.
535
603
  const formattingTag = new RegExp(`^${htmlTag}`, 'i');
536
604
  function angleValue(text, start, mode) {
537
- const tail = text.slice(start, start + 4096);
538
- 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)) {
539
610
  return undefined;
540
611
  }
541
612
  const placeholder = /^<[^<>\r\n]{1,64}>/.exec(tail)?.[0];
@@ -550,6 +621,23 @@ function angleValue(text, start, mode) {
550
621
  return { start, end: start + (stop < 0 ? tail.length : stop) };
551
622
  }
552
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
+
553
641
  function credentialRange(text, match, camel) {
554
642
  const start = match.index + match[0].length;
555
643
  if (
@@ -562,7 +650,7 @@ function credentialRange(text, match, camel) {
562
650
  return authorizationRanges(text, start);
563
651
  }
564
652
  const mode = credentialMode(text, match);
565
- const range = angleValue(text, start, mode) ?? valueRange(text, start, mode);
653
+ const range = credentialValueRange(text, start, mode);
566
654
  const value = range && mode === 'prose' ? generatedWord(text, range) : range;
567
655
  if (value && /^(?:set-)?cookie$/i.test(match[1])) {
568
656
  return cookieValues(text, value.start, value.end);
@@ -601,7 +689,8 @@ function detectCredentials(text, emit) {
601
689
  }
602
690
  const flags = new RegExp(`--(${key})(?:[ \\t]+|=)`, 'giu');
603
691
  for (const match of text.matchAll(flags)) {
604
- const range = valueRange(text, match.index + match[0].length);
692
+ const start = match.index + match[0].length;
693
+ const range = credentialValueRange(text, start, 'token');
605
694
  if (range) {
606
695
  emitValue(text, range.start, range.end, emit, details, match[1]);
607
696
  }
@@ -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
  }