@link-foundation/sensitive-data-sanitizer 0.21.3 → 0.21.5

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.5
4
+
5
+ ### Patch Changes
6
+
7
+ - 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.
8
+
9
+ ## 0.21.4
10
+
11
+ ### Patch Changes
12
+
13
+ - 7454024: Preserve canonical short base64/base64url Hive masks and escaped JSON log structure through verified sanitization. Reinspect bounded escaped JSON views with the configured exemption policy and detectors, while retaining exact known-secret detection and fail-closed handling at inspection limits.
14
+
3
15
  ## 0.21.3
4
16
 
5
17
  ### Patch Changes
package/docs/API.md CHANGED
@@ -317,7 +317,7 @@ Generated replacements carry `faked: true`. A private bounded registry recognize
317
317
 
318
318
  Faking is pseudonymisation: a private key and retained ages, relations and structure can permit linking. Keep the key out of published data. See [the realistic fake example](../examples/realistic-fakes.mjs) and [the complete requirement/research record](case-studies/issue-24/REQUIREMENTS.md).
319
319
 
320
- `preserveEncoding: true` sanitizes decoded payloads with the same required engine union, re-encodes them and verifies decoding reproduces the sanitized content. Supported formats include base64/base64url, hex, percent, numeric HTML and UTF-8 byte escapes. Escaped JSON string content is sanitized and re-encoded even under default redaction so nested session JSON stays parseable. Wrapped base64 and hex keep their line width and line separator (`\n` or `\r\n`), and a wrapped run is reported as one finding. When the sanitized payload cannot be re-encoded in a form the decoder recognizes (for example hex shorter than 32 digits), the encoded text is masked as it stands rather than blocking the input. Known encoding candidates and nesting are bounded.
320
+ `preserveEncoding: true` sanitizes decoded payloads with the same required engine union, re-encodes them and verifies decoding reproduces the sanitized content. Supported formats include base64/base64url, hex, percent, numeric HTML and UTF-8 byte escapes. Escaped JSON string content is sanitized and re-encoded even under default redaction so nested session JSON stays parseable. Wrapped base64 and hex keep their line width and line separator (`\n` or `\r\n`), and a wrapped run is reported as one finding. When the sanitized payload cannot be re-encoded in a form the decoder recognizes (for example hex shorter than 32 digits), the encoded text is masked as it stands rather than blocking the input. Known encoding candidates and nesting are bounded. Canonical base64/base64url encodings of complete Hive masks (including short forms such as `Z2hv4oCmOTk5`, encoding `gho…999`) remain intact on repeated passes. Arbitrary decoded text, malformed encodings and literal text appended to a mask receive no mask exemption; exact known secrets still take priority.
321
321
 
322
322
  ## Identity policy and structured sessions
323
323
 
@@ -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
 
@@ -951,6 +951,13 @@ credential findings without `allowCredentialDisable`. Declared
951
951
  `knownSecrets`/`knownPersonal` values, confirmed personal values and structured
952
952
  `JSON_VALUE` denials are never offered to it.
953
953
 
954
+ For escaped JSON findings up to 8192 characters, the engine independently
955
+ inspects the decoded view with the same configured policy and detectors before
956
+ keeping a clean view. Hooks in that inspection receive decoded text and its
957
+ offsets. Additional inspection stops after three levels; malformed, oversized,
958
+ and deeper views retain normal fail-closed handling. The optional value-free
959
+ `exempt-decoded-view` debug event reports a clean view.
960
+
954
961
  `createSanitizer` awaits a hook that returns a `Promise`.
955
962
  `createSyncSanitizer` and `sanitize()` need a synchronous hook. A `Promise`
956
963
  there is `ERR_CONFIG`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-foundation/sensitive-data-sanitizer",
3
- "version": "0.21.3",
3
+ "version": "0.21.5",
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
@@ -198,8 +198,8 @@ export function authorizationRanges(text, start) {
198
198
  if (reference) {
199
199
  return [{ start, end: start + reference[0].length }];
200
200
  }
201
- const scheme = /^[a-z][a-z0-9_-]*[ \t]+/i.exec(text.slice(start));
202
- const tokenStart = start + (scheme?.[0].length ?? 0);
201
+ const scheme = authorizationScheme(text, start);
202
+ const tokenStart = scheme?.payloadStart ?? start;
203
203
  const params = scheme ? authParams(text, tokenStart) : undefined;
204
204
  if (params) {
205
205
  return params;
@@ -208,6 +208,25 @@ export function authorizationRanges(text, start) {
208
208
  const token = text.slice(tokenStart, end);
209
209
  return usableAuthorization(token) ? [{ start: tokenStart, end }] : [];
210
210
  }
211
+
212
+ // A scheme is structural only when a separate, nonempty payload follows it.
213
+ // Share this boundary with span refinement so overlapping vendor detections
214
+ // cannot mask the scheme that the Authorization parser deliberately preserves.
215
+ export function authorizationScheme(text, start) {
216
+ const opening = /^(\\*)(["'])/.exec(text.slice(start));
217
+ start += opening?.[0].length ?? 0;
218
+ const scheme = /^([a-z][a-z0-9_-]*)[ \t]+/i.exec(text.slice(start));
219
+ if (!scheme) {
220
+ return undefined;
221
+ }
222
+ const payloadStart = start + scheme[0].length;
223
+ const char = text[payloadStart];
224
+ return char &&
225
+ !authorizationDelimiter(char, text.charCodeAt(payloadStart)) &&
226
+ !/[}\]>]/.test(char)
227
+ ? { start, end: start + scheme[1].length, payloadStart }
228
+ : undefined;
229
+ }
211
230
  function authorizationEnd(text, tokenStart) {
212
231
  const brackets = [];
213
232
  let end = tokenStart;
@@ -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' ||
@@ -1,4 +1,26 @@
1
1
  import { collectMatches } from './detection.js';
2
+ import { Buffer } from 'node:buffer';
3
+
4
+ // Re-encoded Hive output can be shorter than the general decoder threshold.
5
+ // Recognize only a whole canonical base64/base64url spelling of a complete
6
+ // mask; arbitrary decodable text and masks glued to literal text stay sensitive.
7
+ function encodedMask(value) {
8
+ if (
9
+ value.length > 64 ||
10
+ !/^(?:[A-Za-z0-9+/]+|[A-Za-z0-9_-]+)={0,2}$/.test(value)
11
+ ) {
12
+ return false;
13
+ }
14
+ const bytes = Buffer.from(value, 'base64url');
15
+ const canonical = bytes.toString('base64');
16
+ const standard = value.replaceAll('-', '+').replaceAll('_', '/');
17
+ const decoded = bytes.toString('utf8');
18
+ return (
19
+ (standard === canonical || standard === canonical.replace(/=+$/, '')) &&
20
+ Buffer.from(decoded).equals(bytes) &&
21
+ /^(?:\[REDACTED\]|[^\s…]{0,3}…[^\s…]{0,3})$/u.test(decoded)
22
+ );
23
+ }
2
24
 
3
25
  export function credentialPlaceholder(value) {
4
26
  return (
@@ -59,7 +81,10 @@ export function templatedCredential(text, finding) {
59
81
  const sentenceEnd = /^[.,;:!?]+(?:\s|$)/.test(
60
82
  text.slice(finding.start + value.length)
61
83
  );
62
- return (!/[\w$%+/=.-]/.test(after) || sentenceEnd) && templatedValue(value);
84
+ return (
85
+ (!/[\w$%+/=.-]/.test(after) || sentenceEnd) &&
86
+ (templatedValue(value) || encodedMask(value))
87
+ );
63
88
  }
64
89
  // Only variable references and the documented gh lookup are exempt. Arbitrary
65
90
  // shell commands and Actions expressions can contain literal credentials.
package/src/engines.js CHANGED
@@ -18,7 +18,7 @@ import { projectText } from './projection.js';
18
18
  import { decodedRuns } from './encoded.js';
19
19
  import { hasCustomTransforms } from './transforms.js';
20
20
  import { builtInEnabled } from './policy.js';
21
- import { exemptFindingsAsync } from './exempt.js';
21
+ import { exemptFindingsAsync, escapedExemptionView } from './exempt.js';
22
22
  import {
23
23
  initializePlaceholders,
24
24
  placeholderKnown,
@@ -235,6 +235,59 @@ function configuredEngines(options) {
235
235
  return engines;
236
236
  }
237
237
 
238
+ async function screenDecodedFindings(text, findings, options, depth, screened) {
239
+ const retained = [];
240
+ for (const finding of findings) {
241
+ const view = escapedExemptionView(text, finding, depth);
242
+ const clean =
243
+ view !== undefined &&
244
+ !(
245
+ await screened(
246
+ view,
247
+ [],
248
+ true,
249
+ { ...options, findings: [], structured: undefined },
250
+ undefined,
251
+ depth + 1
252
+ )
253
+ ).length;
254
+ if (clean) {
255
+ options.debug?.({ event: 'exempt-decoded-view', rule: finding.rule });
256
+ } else {
257
+ retained.push(finding);
258
+ }
259
+ }
260
+ return retained;
261
+ }
262
+
263
+ function createScreened(analyze, options) {
264
+ // The caller's `exempt` hook screens the findings of the text it sees,
265
+ // including the verification passes over the published output.
266
+ return async function screened(
267
+ text,
268
+ initial,
269
+ plain,
270
+ scopedOptions = options,
271
+ nativeFindings,
272
+ exemptionDepth = 0
273
+ ) {
274
+ const findings = await exemptFindingsAsync(
275
+ text,
276
+ await analyze(text, initial, plain, scopedOptions, nativeFindings),
277
+ scopedOptions.exempt
278
+ );
279
+ return scopedOptions.exempt
280
+ ? screenDecodedFindings(
281
+ text,
282
+ findings,
283
+ scopedOptions,
284
+ exemptionDepth,
285
+ screened
286
+ )
287
+ : findings;
288
+ };
289
+ }
290
+
238
291
  export function createSanitizer(options = {}) {
239
292
  options = applyProfile(options);
240
293
  validateOptions(options);
@@ -281,21 +334,7 @@ export function createSanitizer(options = {}) {
281
334
  : detected;
282
335
  }
283
336
 
284
- // The caller's `exempt` hook screens the findings of the text it sees,
285
- // including the verification passes over the published output.
286
- async function screened(
287
- text,
288
- initial,
289
- plain,
290
- scopedOptions = options,
291
- nativeFindings
292
- ) {
293
- return exemptFindingsAsync(
294
- text,
295
- await analyze(text, initial, plain, scopedOptions, nativeFindings),
296
- scopedOptions.exempt
297
- );
298
- }
337
+ const screened = createScreened(analyze, options);
299
338
 
300
339
  const sanitizer = {
301
340
  inspect: guarded(screened),
package/src/exempt.js CHANGED
@@ -16,6 +16,30 @@ const declared = new Set([
16
16
  const offered = (finding) =>
17
17
  !declared.has(finding.rule) && finding.type !== 'JSON_VALUE';
18
18
 
19
+ // Only bounded escaped JSON views qualify for an independent policy check.
20
+ // Keep the decoder's 8192-character bound and cap recursive checks at three;
21
+ // malformed, oversized and other encoded findings retain normal handling.
22
+ export function escapedExemptionView(text, finding, depth) {
23
+ if (
24
+ depth >= 3 ||
25
+ finding.type !== 'ENCODED_SENSITIVE' ||
26
+ !['encoded', 'encoded-engine'].includes(finding.rule) ||
27
+ finding.end - finding.start > 8192
28
+ ) {
29
+ return undefined;
30
+ }
31
+ const raw = text.slice(finding.start, finding.end);
32
+ if (!raw.includes('\\')) {
33
+ return undefined;
34
+ }
35
+ try {
36
+ const decoded = JSON.parse(`"${raw}"`);
37
+ return decoded !== raw ? decoded : undefined;
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ }
42
+
19
43
  function view(text, finding) {
20
44
  return Object.freeze({
21
45
  ...finding,
package/src/index.d.ts CHANGED
@@ -125,6 +125,8 @@ export interface SanitizerOptions {
125
125
  * Verification of the output consults it again per span, so the same value
126
126
  * elsewhere is still caught. Declared knownSecrets/knownPersonal/
127
127
  * confirmedPersonal values and JSON_VALUE denials are never offered.
128
+ * Bounded escaped JSON findings are independently inspected with the same
129
+ * policy (up to three levels); hooks there receive decoded text/offsets.
128
130
  * createSyncSanitizer and sanitize() require a synchronous hook.
129
131
  */
130
132
  exempt?: (
@@ -154,6 +156,7 @@ export type DebugEvent =
154
156
  | { event: 'document-experience' | 'proper-name-prose'; count: number }
155
157
  | { event: 'residual-redaction'; round: number }
156
158
  | { event: 'template-value'; rule: string }
159
+ | { event: 'exempt-decoded-view'; rule: string }
157
160
  | { event: 'two-pass-confirmation'; type: string }
158
161
  | { event: 'propagation-skipped'; reason: 'length'; type: string }
159
162
  | { event: 'gitleaks-rule'; rule: string; length: number; ms: number };
package/src/sanitizer.js CHANGED
@@ -28,7 +28,11 @@ import { isIdentityType } from './identity-types.js';
28
28
  import { rejectRestoreMap } from './restore-map.js';
29
29
  import { allowListed, validateAllowList } from './allow-list.js';
30
30
  import { templatedCredential } from './credentials.js';
31
- import { exemptFindings, validateExempt } from './exempt.js';
31
+ import {
32
+ exemptFindings,
33
+ escapedExemptionView,
34
+ validateExempt,
35
+ } from './exempt.js';
32
36
  import { builtInRules } from './rule-catalog.js';
33
37
  import {
34
38
  initializePlaceholders,
@@ -455,16 +459,31 @@ function locations(text, findings) {
455
459
  });
456
460
  }
457
461
 
458
- export function inspect(text, options = {}) {
462
+ export function inspect(text, options = {}, exemptionDepth = 0) {
459
463
  try {
460
464
  const exempt = options?.exempt;
461
465
  if (!exempt) {
462
466
  return inspectText(text, options);
463
467
  }
464
- // Nested views (structured values, decoded runs) are screened once, at
465
- // the offsets of the text the caller sees.
468
+ // Screen source offsets first, then independently inspect escaped JSON
469
+ // with the same policy. A clean decoded view must not become a stale
470
+ // whole-view finding that rendering would replace with [REDACTED].
466
471
  const findings = inspectText(text, { ...options, exempt: undefined });
467
- return exemptFindings(text, findings, exempt);
472
+ return exemptFindings(text, findings, exempt).filter((finding) => {
473
+ const view = escapedExemptionView(text, finding, exemptionDepth);
474
+ if (view === undefined) {
475
+ return true;
476
+ }
477
+ const clean = !inspect(
478
+ view,
479
+ { ...options, findings: [], structured: undefined },
480
+ exemptionDepth + 1
481
+ ).length;
482
+ if (clean) {
483
+ options.debug?.({ event: 'exempt-decoded-view', rule: finding.rule });
484
+ }
485
+ return !clean;
486
+ });
468
487
  } catch (error) {
469
488
  throw internalFailure(error);
470
489
  }
@@ -542,6 +561,14 @@ function inspectText(text, options = {}) {
542
561
  if (projection.changed) {
543
562
  nativeDetect(text, options, emit);
544
563
  detectSync(text, options, emit);
564
+ } else if (
565
+ (options.knownSecrets ?? []).some(
566
+ (value, index) => value !== nativeOptions.knownSecrets[index]
567
+ )
568
+ ) {
569
+ // Encoded exact bytes must remain detectable even when the declared
570
+ // secret normalizes (for example the ellipsis in a known Hive mask).
571
+ nativeDetect(text, { ...options, knownOnly: true }, emit);
545
572
  }
546
573
  nativeDetect(projection.text, nativeOptions, mapped);
547
574
  detectSync(projection.text, nativeOptions, mapped);