@jini-ai/core 0.6.21 → 0.6.23

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/redact.js CHANGED
@@ -34,6 +34,10 @@
34
34
  // Named once and reused (by SECRET_SHAPE_PATTERNS here and by the guard's ALLOWLIST) rather than repeated
35
35
  // as a literal in multiple places.
36
36
  export const PEM_PATTERN_NAME = "PEM private key block";
37
+ const PRIVATE_KEY_HEADER = /-----BEGIN ([A-Z ]*)PRIVATE KEY-----/;
38
+ // Consume the body before any token/label rule can erase the delimiters. A missing footer
39
+ // masks through EOF; a different key type's footer cannot close this block.
40
+ const PRIVATE_KEY_BLOCK = /-----BEGIN ([A-Z ]*)PRIVATE KEY-----[\s\S]*?(?:-----END \1PRIVATE KEY-----|$)/;
37
41
  // Share data between static scans and runtime redactors without importing a CLI entry point:
38
42
  // top-level argv/path handling in a scanner can crash a daemon that imports it. Keeping the
39
43
  // vocabulary dependency-free also avoids making core redaction depend on feature code.
@@ -50,8 +54,43 @@ export const SECRET_SHAPE_PATTERNS = [
50
54
  { name: "Slack token", pattern: /(?<![A-Za-z0-9-])xox[baprs]-[A-Za-z0-9-]{12,}/ },
51
55
  { name: "AWS access key ID", pattern: /(?<![0-9A-Z])AKIA[0-9A-Z]{16}(?![0-9A-Z])/ },
52
56
  { name: "Fastmail app password (fm2_)", pattern: /(?<![A-Za-z0-9+/=])fm2_[A-Za-z0-9+/=]{20,}/ },
53
- { name: PEM_PATTERN_NAME, pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
57
+ { name: PEM_PATTERN_NAME, pattern: PRIVATE_KEY_BLOCK },
54
58
  ];
59
+ /**
60
+ * Suppress PEM bodies across complete, ordered text lines before other redactors run.
61
+ * @param _required No ambient state; create one instance per framed output stream.
62
+ * @param _options Reserved optional policy inputs.
63
+ * @returns A line redactor and an EOF reset. Call reset after flushing the final line so a
64
+ * replacement child cannot inherit an unterminated block. Does not frame raw chunks or do I/O.
65
+ * @complexity O(n) per line; O(h) retained state for the open header's key type of length h.
66
+ * @example const keys = createPrivateKeyLineRedactor({}, {}); keys.redact({ text: line }, {});
67
+ */
68
+ export function createPrivateKeyLineRedactor(_required, _options = {}) {
69
+ let footer;
70
+ return {
71
+ redact({ text }, _optional = {}) {
72
+ let remaining = text;
73
+ let out = '';
74
+ while (remaining) {
75
+ if (footer !== undefined) {
76
+ const end = remaining.indexOf(footer);
77
+ if (end === -1)
78
+ return out;
79
+ remaining = remaining.slice(end + footer.length);
80
+ footer = undefined;
81
+ }
82
+ const header = PRIVATE_KEY_HEADER.exec(remaining);
83
+ if (!header)
84
+ return out + remaining;
85
+ out += remaining.slice(0, header.index) + '[REDACTED:credential]';
86
+ footer = `-----END ${header[1]}PRIVATE KEY-----`;
87
+ remaining = remaining.slice(header.index + header[0].length);
88
+ }
89
+ return out;
90
+ },
91
+ reset(_input, _optional = {}) { footer = undefined; },
92
+ };
93
+ }
55
94
  /** Broad token shapes for agent-failure text; consumers retain their plain [REDACTED] pipeline. */
56
95
  const AGENT_FAILURE_TOKEN_SHAPES = [
57
96
  /AIza[\w-]+/, // Google API key
@@ -135,15 +174,20 @@ const PATTERNS = [
135
174
  // JSON Web Tokens. The "header.payload.signature" triple is distinctive
136
175
  // enough that false positives are rare (the literal "eyJ" prefix is the
137
176
  // base64 encoding of '{"' which is how every JOSE header starts).
138
- { name: 'jwt', regex: /\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g },
177
+ { name: 'jwt', regex: /(?<!\w)(?<!(?<!\w)eyJ[A-Za-z0-9_-]*?)eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g },
139
178
  // Bearer tokens in HTTP Authorization header copy/paste. We only match
140
179
  // the value, not the literal 'Bearer ', so the marker stays readable in
141
180
  // the redacted output ("Authorization: Bearer [REDACTED:bearer_token]").
142
181
  // The leading lookahead only lets the lookbehind run where a value can start, so a long
143
182
  // whitespace run is not rescanned from every position inside it.
144
183
  { name: 'bearer_token', regex: /(?=[A-Za-z0-9._~+/-])(?<=\bBearer\s+)[A-Za-z0-9._~+/-]{16,}={0,2}/g },
184
+ // Email addresses. Conservative enough to require a TLD-like trailer. The local part is
185
+ // bounded at the RFC 5321 limit of 64: an unbounded run rescanned the suffix from every word
186
+ // boundary, so `'a.'.repeat(20000)` blocked ~3 s on a large tool response. Matching starts at
187
+ // the `@` and reads the local part in a lookbehind (captured as `local`): starting at every word
188
+ // boundary still cost up to 64 steps per position, ~2.5 s on 10 MiB of `'eyJ-'`.
145
189
  // Alphabetic TLDs preserve versioned package paths in error stacks (e.g. pkg@1.2.3).
146
- { name: 'email', regex: /\b[\w.+-]+@(?:[\w-]+\.)+[A-Za-z]{2,}\b/g },
190
+ { name: 'email', regex: /@(?<=\b(?<local>[\w.+-]{1,64})@)(?:[\w-]+\.)+[A-Za-z]{2,}\b/g },
147
191
  // IPv4. Reject all-zero / 255.255.255.255-ish junk shapes by gating on
148
192
  // each octet being 0-255.
149
193
  {
@@ -161,13 +205,43 @@ const PATTERNS = [
161
205
  regex: new RegExp(`(?<![\\w-])${PHONE_COUNTRY_CODE.source}${PHONE_AREA_CODE.source}${PHONE_SEPARATOR}\\d{3}${PHONE_SEPARATOR}\\d{4}(?![\\w-])`, 'g'),
162
206
  },
163
207
  ];
208
+ // Categories the pipeline names directly; pattern rules carry their own names. Masked-value
209
+ // checks and exact-secret masking derive the set of markers redact can generate from these
210
+ // plus the pattern names.
211
+ const CATEGORY = {
212
+ exactSecret: 'exact_secret',
213
+ credential: 'credential',
214
+ urlCredentials: 'url_credentials',
215
+ signedQuery: 'signed_query',
216
+ apiKeyHeader: 'api_key_header',
217
+ apiKeyQuery: 'api_key_query',
218
+ bearerToken: 'bearer_token',
219
+ labeledSecret: 'labeled_secret',
220
+ urlSecretParam: 'url_secret_param',
221
+ opaqueToken: 'opaque_token',
222
+ creditCard: 'credit_card',
223
+ };
224
+ // Only markers redact itself generates count as masked: arbitrary `[REDACTED:<text>]` would let
225
+ // echoed input such as `[REDACTED:<secret>]` keep the real secret. A fixed category alternation
226
+ // also bounds each marker attempt; `[^\]]*` rescanned the suffix after every unterminated
227
+ // `[REDACTED:`, which was quadratic on large tool responses.
228
+ const GENERATED_CATEGORIES = [...PATTERNS.map(({ name }) => name), ...Object.values(CATEGORY)];
229
+ function escapeRegExp(text) {
230
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
231
+ }
232
+ function markerSource(categories) {
233
+ return `\\[REDACTED:(?:${[...new Set(categories)].map(escapeRegExp).join('|')})\\]`;
234
+ }
235
+ const GENERATED_MARKER = markerSource(GENERATED_CATEGORIES);
236
+ /** Every marker redact can generate from its built-in categories; consumers normalize only these. */
237
+ export const GENERATED_MARKER_RE = new RegExp(GENERATED_MARKER, 'g');
164
238
  // Credit card sweep is special: a naive 13-19 digit run matches a lot of
165
239
  // non-card numbers (timestamps, IDs, hashes). We isolate the candidate
166
240
  // then run a Luhn check before redacting.
167
241
  const CARD_CANDIDATE = /\b(?:\d[ -]?){12,18}\d\b/g;
168
242
  // Inspect/JSON credentials can contain spaces and escaped quotes; consume the entire value.
169
243
  const QUOTED_SECRET_VALUE = String.raw `"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'`;
170
- const API_KEY_HEADER = new RegExp(`(^|[^?&\\w-])(["']?)(x-api-key|api-key|x-goog-api-key)\\2(\\s*[:=]\\s*)(${QUOTED_SECRET_VALUE}|[^\\s,;"'#}]+)`, 'gi');
244
+ const API_KEY_HEADER = new RegExp(`(^|[^?&\\w])(["']?)(x-api-key|api-key|x-goog-api-key)\\2(\\s*[:=]\\s*)(${QUOTED_SECRET_VALUE}|[^\\s,;"'#}]+)`, 'gi');
171
245
  const API_KEY_QUERY = /([?&](?:key|api_key|api-key)=)[^&#\s,;"']+/gi;
172
246
  // The length guard and the per-digit `d < 0 || d > 9` guard from the origin
173
247
  // were both dropped here (a documented deviation from the otherwise-verbatim
@@ -214,40 +288,60 @@ export const NON_SECRET_TOKEN_RE = new RegExp(`^(?:${CORRELATION_ID}|delegated-t
214
288
  // its historical lowercase marker contract and uses the unmodified lexical vocabulary.
215
289
  // Reject a masked header/query value before matching, so a following field's credential
216
290
  // remains available to the global scan rather than being consumed beside the marker.
217
- // Quoted values end at their matching quote; opaque unquoted values retain punctuation.
218
- // An ampersand cannot start a value: an empty URL parameter must not eat its neighbor.
219
- const MASK_AWARE_LABELED_SECRET_RE = new RegExp(`(?<![\\w-])((["']?)(?:${SERIALIZED_SECRET_LABELS})\\2\\s*[:=]\\s*)(?!Bearer\\s+\\[REDACTED:[^\\]]+\\](?:\\s|$))(?!\\[REDACTED:[^\\]]+\\][;&])(${QUOTED_SECRET_VALUE}|(?!&)\\S+)`, 'gi');
291
+ // Only the value's first character is matched; forward-only finders supply its end (see
292
+ // labelValueExtents). Matching `\S+` and rewinding to skip a covered field rescanned the rest of
293
+ // the run per label: quadratic on `'api-key=x;'.repeat(n)`.
294
+ // Bare URL key/token parameters already have owners; leave their empty values and categories
295
+ // to those collectors instead of treating the following `&page=...` as a label's value.
296
+ const RUNTIME_SECRET_LABELS = `${SERIALIZED_SECRET_LABELS}|(?<![?&])(?:key)`;
297
+ const MASK_AWARE_LABELED_SECRET_RE = new RegExp(`(?<![\\w-])(["']?)(${RUNTIME_SECRET_LABELS})\\1\\s*[:=]\\s*`
298
+ + `(?!Bearer\\s+\\S)(?!${GENERATED_MARKER}[;&])((?!&)\\S)`, 'gi');
299
+ // A label can name another label; resume at it instead of consuming `token:` and leaking its value.
300
+ const CHAINED_LABEL_RE = new RegExp(`(?:${RUNTIME_SECRET_LABELS})\\b["']?\\s*[:=]\\s*(?=\\S)`, 'iy');
220
301
  // Provider failures also echo short bearer values. Exclude our own marker on subsequent passes.
221
- // The leading lookahead only lets the lookbehind run where a value can start (see bearer_token).
222
- const SHORT_BEARER_RE = /(?=[\w.+/=-])(?<=Bearer\s+)[\w.+/=-]+/gi;
223
- const PROVIDER_API_KEY_HEADER = /(x-api-key|api-key|x-goog-api-key)\s*[:=]\s*([^\s,;"']+)/gi;
224
- const PROVIDER_API_KEY_QUERY = /([?&]key=)([^&\s]+)/gi;
302
+ // The value is read in a lookahead so the scan resumes at it (`Bearer Bearer x` masks both),
303
+ // as the earlier lookbehind form did, without a lookbehind test at every position.
304
+ // Quotes and field delimiters end the value; punctuation such as `!` remains credential text.
305
+ const SHORT_BEARER_RE = new RegExp(`Bearer\\s+["']?(?!${GENERATED_MARKER}(?=$|[\\s"',;<>}]))(?=([^\\s"',;<>}]+))`, 'gi');
306
+ const BASIC_AUTH_RE = /\bauthorization["']?\s*[:=]\s*["']?(Basic\s+[^\s"',;}]+)/gi;
307
+ // Cookie pairs belong to one header value, including pairs after a semicolon and a space.
308
+ const COOKIE_HEADER_RE = /\bcookie\s*:\s*([^\r\n]+)/gi;
309
+ const PROVIDER_API_KEY_QUERY = /([?&]key=)([^&\s"']+)/gi;
225
310
  // URL userinfo and signed query values have explicit credential context; unlike opaque runs,
226
311
  // they are safe to recognize in the conservative policy. Keep scheme/host and query names
227
312
  // readable for debugging without exposing credentials that authorize access to the resource.
228
- const URL_CREDENTIALS_RE = /(\b[a-z][a-z0-9+.-]*:\/\/)([^/\s?#@]+)@/gi;
313
+ // Matching starts at `//` and reads the userinfo forward to `@` within the authority. No scheme
314
+ // scan is needed, so a scheme of any length (glued text included) cannot hide its userinfo:
315
+ // an unbounded scheme run rescanned the suffix from every start
316
+ // (`'a.'.repeat(20000)` blocked ~3.6 s), trying a bounded scheme at every position cost ~2 s on
317
+ // 10 MiB of `'eyJ-'`, and a 32-character scheme lookbehind missed longer schemes.
318
+ // The same authority syntax also appears in scheme-relative URLs (`//user:pw@host`).
319
+ const URL_CREDENTIALS_RE = /\/\/([^/\s?#@]+)@/g;
229
320
  const SIGNED_QUERY_RE = /([?&](?:signature|sig|x-amz-signature|x-goog-signature|access_token|refresh_token)=)([^&#\s,;"']+)/gi;
230
321
  // A secret word must END a name segment (split at . _ - or the name's end): api_key, apiKey,
231
322
  // authtoken, X-Amz-Signature and custom.Token-value are masked, while author, keyword, design,
232
323
  // signup and page stay readable. Names that merely end in a secret word (zipcode, error_code,
233
324
  // monkey) are masked too; over-masking a URL value is the cheaper failure. Empty values stay.
234
- // Match through a trailing fragment; only '&', quotes, parentheses or whitespace end the value.
325
+ // Match through a trailing fragment; field separators (&/;), quotes, parentheses or whitespace end it.
235
326
  // Both name runs are bounded so a long separator-free run costs linear time, not quadratic.
236
327
  const URL_SECRET_WORDS = 'token|key|secret|signature|sig|code|password|passwd|pwd|auth|authorization|credential';
237
- const URL_SECRET_PARAM_RE = new RegExp(`[?&#;]([\\w.-]{0,128}?(?:${URL_SECRET_WORDS})s?(?=[._=-])[\\w.-]{0,128})=[^&\\s"'<>()]+`, 'gi');
328
+ const URL_SECRET_PARAM_RE = new RegExp(`[?&#;]([\\w.-]{0,128}?(?:${URL_SECRET_WORDS})s?(?=[._=-])[\\w.-]{0,128})=[^&;\\s"'<>()]+`, 'gi');
238
329
  // A parameter the labeled rule already names (password, client_secret, ...) keeps that category.
239
330
  const LABELED_URL_PARAM_NAME_RE = new RegExp(`(?:^|[.-])(?:${SECRET_LABELS})$`, 'i');
240
- // Only an entire marker is already masked. A secret glued onto marker-like text still needs
241
- // masking; a substring check would preserve credential suffixes in echoed header/query values.
331
+ // Only an entire generated marker is already masked. A secret glued onto marker-like text still
332
+ // needs masking; a substring check would preserve credential suffixes in echoed header/query values.
333
+ const MASKED_VALUE_RE = new RegExp(`^["']?${GENERATED_MARKER}["']?$`);
242
334
  function isMaskedValue(value) {
243
- return /^["']?\[REDACTED:[^\]]+\]["']?$/.test(value);
335
+ return MASKED_VALUE_RE.test(value);
244
336
  }
245
337
  /**
246
338
  * Returns `input` with every recognised secret / PII pattern replaced by a
247
339
  * `[REDACTED:<kind>]` marker. Under the default policies, idempotent — re-running on already redacted
248
- * text only matches new tokens. Defaults to conservative; `{ policy: 'aggressive' }`
340
+ * text only matches new tokens. Exact occurrences wholly within credential labels or generated markers preserve that syntax;
341
+ * crossing occurrences still mask their complete range; all value rules still see the original context. Defaults to conservative; `{ policy: 'aggressive' }`
249
342
  * additionally masks bare 20+ character opaque runs. Exact secrets and extra patterns
250
- * are honored under either heuristic policy. Exact-only replaces only supplied secrets in one pass,
343
+ * are honored under either heuristic policy.
344
+ * Exact-only replaces only supplied secrets in one pass,
251
345
  * preserves unmatched characters, and inserts the optional literal marker without reprocessing it.
252
346
  *
253
347
  * Empty / non-string input passes through unchanged so the caller can use
@@ -314,41 +408,139 @@ function diagnosticJson({ error }, options = {}) {
314
408
  * caller can attach an audit summary to the trace metadata ('we stripped 2
315
409
  * keys + 1 email before send').
316
410
  *
317
- * Exact secrets run first, longest first: lexical rules or a shorter supplied secret must not
318
- * split a known credential and leave a suffix behind. Opt-in aggressive opaque rules precede
319
- * PII rules for the same reason (a numeric token can also contain phone/card candidates).
411
+ * Every rule matches the ORIGINAL input and contributes value spans; overlapping or adjacent
412
+ * spans merge, and one left-to-right pass writes one marker per merged span. Sequential
413
+ * substitution passes leaked in every order tried: each pass saw text an earlier pass had
414
+ * changed. Exact secrets run first split labels (`a` inside `password=hunter2` left `hunter2`) and
415
+ * lost context (`note password=` left `hunter2`); run last, a lexical rule that stopped partway
416
+ * (the label rule at whitespace, vendor/query rules at `.`, `;`, `&`, `!` or quotes) left the rest
417
+ * of a known secret visible; protecting spans exempted secrets overlapping a marker; and
418
+ * overlapping secrets left a tail behind. Opt-in aggressive opaque
419
+ * rules are collected before PII rules so a numeric token keeps the opaque category when tied.
420
+ * @complexity O(n·(r + e) + s + k) for n input chars, r rules, e exact-secret needles, s needle
421
+ * chars and k spans.
320
422
  */
321
423
  export function redactSecretsWithCounts({ input }, options = {}) {
322
424
  const counts = {};
323
425
  if (!input)
324
426
  return { redacted: input, counts };
325
- const marker = (name) => {
326
- counts[name] = (counts[name] ?? 0) + 1;
327
- if (options.policy === 'exact-only')
328
- return options.marker ?? '[REDACTED:exact_secret]';
329
- return `[REDACTED:${name}]`;
427
+ const spans = mergeSpans(collectSpans({ text: input }, options), { keepAdjacent: options.policy === 'exact-only' });
428
+ let redacted = '';
429
+ let cursor = 0;
430
+ for (const { start, end, category, suffix } of spans) {
431
+ counts[category] = (counts[category] ?? 0) + 1;
432
+ redacted += input.slice(cursor, start) + (options.policy === 'exact-only' ? options.marker ?? '[REDACTED:exact_secret]' : `[REDACTED:${category}]`) + (suffix ?? '');
433
+ cursor = end;
434
+ }
435
+ return { redacted: redacted + input.slice(cursor), counts };
436
+ }
437
+ /** Every rule's spans over the original text, in rule-precedence order (see {@link mergeSpans}). */
438
+ function collectSpans({ text }, options) {
439
+ const spans = [];
440
+ const add = (start, end, category, suffix) => {
441
+ if (end > start || (end === start && suffix !== undefined))
442
+ spans.push({ start, end, category, suffix });
330
443
  };
331
444
  if (options.policy === 'exact-only') {
332
- return {
333
- redacted: redactExactSecrets({ text: input, marker, secrets: options.exactSecrets ?? [] }, { singlePass: true }),
334
- counts,
335
- };
336
- }
337
- let out = redactExactSecrets({ text: input, marker, secrets: options.exactSecrets ?? [] });
338
- out = redactUrlCredentials({ text: out, marker });
339
- out = applyPatterns({ text: out, marker, patterns: credentialPatterns(options.extraPatterns ?? []) });
340
- out = redactKeyedCredentials({ text: out, marker });
341
- if (options.policy === 'aggressive') {
342
- const trustedIds = new Set(options.diagnosticToolIds);
343
- out = out.replace(/[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*/g, token => trustedIds.has(token) ? token
344
- : token.replace(OPAQUE_TOKEN_RE, match => NON_SECRET_TOKEN_RE.test(match) ? match : marker('opaque_token')));
445
+ collectExactSecrets({ text, add, secrets: options.exactSecrets ?? [] }, { literalOnly: true });
446
+ return spans;
345
447
  }
448
+ collectExactSecrets({ text, add, secrets: options.exactSecrets ?? [] });
449
+ // Whole PEM blocks use the existing shared vocabulary; every rule sees their original delimiters.
450
+ collectEmptyQuotedAssignments({ text, add });
451
+ collectUrlCredentials({ text, add });
452
+ collectPatterns({ text, add, patterns: credentialPatterns(options.extraPatterns ?? []) });
453
+ collectKeyedCredentials({ text, add });
454
+ collectLabeledSecrets({ text, add }, spans);
455
+ if (options.policy === 'aggressive')
456
+ collectOpaqueTokens({ text, add }, new Set(options.diagnosticToolIds));
346
457
  if (options.redactPii !== false)
347
- out = redactPii({ text: out, marker });
348
- return { redacted: out, counts };
458
+ collectPii({ text, add }, spans);
459
+ return spans;
460
+ }
461
+ /** Orders spans and merges overlapping or adjacent ones; masking whole merged runs means no rule's
462
+ * leftover can expose part of another rule's value.
463
+ * Category rule: the longest constituent span names the merged span; on a tie the earlier rule in
464
+ * {@link collectSpans} wins, so an exact rule labels a known secret it matches exactly
465
+ * (`t=<known-token>` stays `exact_secret`) and the exact-secret category marks what only it covers.
466
+ * @complexity O(k) time and space for k spans (string offsets fit in unsigned 32 bits). */
467
+ function mergeSpans(spans, { keepAdjacent = false } = {}) {
468
+ const merged = [];
469
+ for (const rank of orderedSpanRanks({ spans })) {
470
+ const span = spans[rank];
471
+ const length = span.end - span.start;
472
+ const last = merged.at(-1);
473
+ if (!last || startsSeparateSpan({ start: span.start, end: last.end, keepAdjacent })) {
474
+ merged.push({ start: span.start, end: span.end, category: span.category, suffix: span.suffix, rank, categoryLength: length });
475
+ continue;
476
+ }
477
+ if (span.end >= last.end)
478
+ last.suffix = span.suffix;
479
+ last.end = Math.max(last.end, span.end);
480
+ if (length > last.categoryLength || (length === last.categoryLength && rank < last.rank)) {
481
+ Object.assign(last, { category: span.category, rank, categoryLength: length });
482
+ }
483
+ }
484
+ return merged;
485
+ }
486
+ /** Literal-only replacements preserve adjacent occurrences as distinct markers and counts. */
487
+ function startsSeparateSpan({ start, end, keepAdjacent }, _options = {}) {
488
+ return keepAdjacent ? start >= end : start > end;
489
+ }
490
+ /** Stable byte-wise radix order of span starts; ranks retain the original rule precedence.
491
+ * Dense labels produce millions of spans: sorting cloned objects both cost k log k and amplified
492
+ * GC. Four counting passes over integer ranks avoid those clones and bound work to O(k), with
493
+ * O(k) scratch space. JavaScript string offsets fit in 32 bits. No caller-owned data is mutated. */
494
+ function orderedSpanRanks({ spans }) {
495
+ let ranks = Uint32Array.from(spans, (_span, rank) => rank);
496
+ let scratch = new Uint32Array(ranks.length);
497
+ const offsets = new Uint32Array(256);
498
+ for (let shift = 0; shift < 32; shift += 8) {
499
+ offsets.fill(0);
500
+ for (const rank of ranks) {
501
+ const bucket = (spans[rank].start >>> shift) & 255;
502
+ offsets[bucket] = (offsets[bucket] ?? 0) + 1;
503
+ }
504
+ let total = 0;
505
+ for (let bucket = 0; bucket < offsets.length; bucket++) {
506
+ const count = offsets[bucket] ?? 0;
507
+ offsets[bucket] = total;
508
+ total += count;
509
+ }
510
+ for (const rank of ranks) {
511
+ const bucket = (spans[rank].start >>> shift) & 255;
512
+ const position = offsets[bucket] ?? 0;
513
+ scratch[position] = rank;
514
+ offsets[bucket] = position + 1;
515
+ }
516
+ [ranks, scratch] = [scratch, ranks];
517
+ }
518
+ return ranks;
349
519
  }
350
- // PII rules run last (after credential and opt-in opaque rules) and only when PII redaction is on;
351
- // splitting once keeps each list in PATTERNS order.
520
+ /** Adds one span per match of `regex`; `spanOf` maps a match to its value range, or skips it. */
521
+ function collectMatches({ text, add }, regex, spanOf) {
522
+ // Clone so a caller-owned or shared regex never carries lastIndex state across requests;
523
+ // non-global extra patterns use the same every-occurrence contract as the built-ins.
524
+ const flags = regex.flags.includes('g') ? regex.flags : `${regex.flags}g`;
525
+ for (const match of text.matchAll(new RegExp(regex.source, flags))) {
526
+ const span = spanOf(match, match.index ?? 0);
527
+ if (span)
528
+ add(span.start, span.end, span.category);
529
+ }
530
+ }
531
+ /** Span of the match's trailing capture `group`, unless that value is already a generated marker
532
+ * (already masked values are not new credentials and should not inflate the audit count). */
533
+ function trailingValue(category, group) {
534
+ return (match, index) => {
535
+ const value = match[group] ?? '';
536
+ if (isMaskedValue(value))
537
+ return undefined;
538
+ const end = index + match[0].length;
539
+ return { start: end - value.length, end, category };
540
+ };
541
+ }
542
+ // PII rules are collected last (after credential and opt-in opaque rules) and only when PII
543
+ // redaction is on; splitting once keeps each list in PATTERNS order.
352
544
  const PII_PATTERN_NAMES = ['email', 'ipv4', 'phone'];
353
545
  const NON_PII_PATTERNS = PATTERNS.filter((pattern) => !PII_PATTERN_NAMES.includes(pattern.name));
354
546
  const PII_PATTERNS = PATTERNS.filter((pattern) => PII_PATTERN_NAMES.includes(pattern.name));
@@ -356,142 +548,360 @@ const PII_PATTERNS = PATTERNS.filter((pattern) => PII_PATTERN_NAMES.includes(pat
356
548
  function credentialPatterns(extraPatterns) {
357
549
  return [
358
550
  ...NON_PII_PATTERNS,
359
- ...SECRET_SHAPE_PATTERNS.map((shape) => ({ name: 'credential', regex: shape.pattern })),
551
+ ...SECRET_SHAPE_PATTERNS.map((shape) => ({ name: CATEGORY.credential, regex: shape.pattern })),
360
552
  ...extraPatterns,
361
553
  ];
362
554
  }
363
- function applyPatterns({ text, marker, patterns }) {
364
- let out = text;
555
+ function collectPatterns({ text, add, patterns }) {
365
556
  for (const { name, regex } of patterns) {
366
- // Clone host/global regexes so lastIndex state never leaks across requests. Named extra
367
- // patterns use the same global replacement contract as the built-in vocabulary.
368
- const flags = regex.flags.includes('g') ? regex.flags : `${regex.flags}g`;
369
- out = out.replace(new RegExp(regex.source, flags), () => marker(name));
557
+ collectMatches({ text, add }, regex, (match, index) => ({ start: index, end: index + match[0].length, category: name }));
558
+ }
559
+ }
560
+ /** URL userinfo and signed query values: credentials recognized by their URL context. */
561
+ function collectUrlCredentials(pass) {
562
+ collectMatches(pass, URL_CREDENTIALS_RE, (match, index) => {
563
+ const userinfo = match[1] ?? '';
564
+ if (isMaskedValue(userinfo))
565
+ return undefined;
566
+ const start = index + '//'.length;
567
+ return { start, end: start + userinfo.length, category: CATEGORY.urlCredentials };
568
+ });
569
+ collectMatches(pass, SIGNED_QUERY_RE, trailingValue(CATEGORY.signedQuery, 2));
570
+ }
571
+ /** Values named by a header, query parameter or bearer prefix. Each rule matches the original
572
+ * text, so overlapping rules (the provider query rule includes a fragment the core rule stops
573
+ * before) merge into one span instead of one leaving a credential suffix beside the other's marker. */
574
+ function collectKeyedCredentials(pass) {
575
+ collectApiKeyHeaders(pass);
576
+ collectMatches(pass, PROVIDER_API_KEY_QUERY, trailingValue(CATEGORY.apiKeyQuery, 2));
577
+ collectMatches(pass, API_KEY_QUERY, (match, index) => {
578
+ const start = index + (match[1] ?? '').length;
579
+ const end = index + match[0].length;
580
+ return isMaskedValue(match[0].slice(start - index)) ? undefined : { start, end, category: CATEGORY.apiKeyQuery };
581
+ });
582
+ collectMatches(pass, SHORT_BEARER_RE, (match, index) => {
583
+ const start = index + match[0].length;
584
+ return { start, end: start + (match[1] ?? '').length, category: CATEGORY.bearerToken };
585
+ });
586
+ collectMatches(pass, BASIC_AUTH_RE, trailingValue(CATEGORY.labeledSecret, 1));
587
+ collectMatches(pass, COOKIE_HEADER_RE, trailingValue(CATEGORY.labeledSecret, 1));
588
+ collectUrlSecretParams(pass);
589
+ }
590
+ /** Header values stop at their quotes; an empty quoted hint cannot borrow the next field.
591
+ * @complexity O(n) over input characters, no I/O. */
592
+ function collectApiKeyHeaders(pass) {
593
+ const hints = emptyAssignmentRanges(pass.text);
594
+ collectMatches(pass, API_KEY_HEADER, (match, index) => {
595
+ const labelStart = index + (match[1] ?? '').length;
596
+ if (hints.has(labelStart))
597
+ return undefined;
598
+ const value = match[5] ?? '';
599
+ if (isMaskedValue(value))
600
+ return undefined;
601
+ const end = index + match[0].length;
602
+ // A quoted value keeps its quotes around the marker.
603
+ const quoted = /^["']/.test(value) ? 1 : 0;
604
+ return { start: end - value.length + quoted, end: end - quoted, category: CATEGORY.apiKeyHeader };
605
+ });
606
+ }
607
+ /** Secret-named URL query/fragment values; values already masked keep their category. */
608
+ function collectUrlSecretParams(pass) {
609
+ collectMatches(pass, URL_SECRET_PARAM_RE, (match, index) => {
610
+ const separator = match[0].indexOf('=') + 1;
611
+ if (isMaskedValue(match[0].slice(separator)))
612
+ return undefined;
613
+ const category = LABELED_URL_PARAM_NAME_RE.test(match[1] ?? '') ? CATEGORY.labeledSecret : CATEGORY.urlSecretParam;
614
+ return { start: index + separator, end: index + match[0].length, category };
615
+ });
616
+ }
617
+ /** Label values (`password=...`): only the value is masked, so the label stays readable
618
+ * even when no whitespace separates it; a glued credential suffix fails the complete-marker check.
619
+ * A label whose value an earlier rule already masks (see {@link isCoveredField}) is skipped and the
620
+ * scan resumes at the value, so `api-key=x;password=y` and `?password=x&page=2` keep the following
621
+ * field instead of masking it beside the first value. A masked value resumes the scan past its end.
622
+ * @complexity O(n + k) for n chars and k earlier spans; each skip resumes past the label and value
623
+ * ends come from forward-only finders, so no run is rescanned. */
624
+ function collectLabeledSecrets({ text, add }, earlier) {
625
+ const covered = coveredLabelFields({ earlier });
626
+ const hints = emptyAssignmentRanges(text);
627
+ const extentOf = labelValueExtents(text);
628
+ const regex = new RegExp(MASK_AWARE_LABELED_SECRET_RE.source, MASK_AWARE_LABELED_SECRET_RE.flags);
629
+ const chained = new RegExp(CHAINED_LABEL_RE.source, CHAINED_LABEL_RE.flags);
630
+ const urlField = new RegExp(URL_SECRET_PARAM_RE.source, 'iy');
631
+ for (let match = regex.exec(text); match; match = regex.exec(text)) {
632
+ if (hints.has(match.index))
633
+ continue;
634
+ // The URL collector owns complete unquoted parameter values, including quote boundaries.
635
+ // A generic unquoted label would consume the URL's enclosing quote as a secret byte.
636
+ urlField.lastIndex = Math.max(0, match.index - 1);
637
+ if (urlField.exec(text)) {
638
+ regex.lastIndex = urlField.lastIndex;
639
+ continue;
640
+ }
641
+ const start = match.index + match[0].length - 1;
642
+ chained.lastIndex = start;
643
+ if (chained.test(text)) {
644
+ regex.lastIndex = start;
645
+ continue;
646
+ }
647
+ const value = extentOf(start);
648
+ if (covered(start, value) || isMaskedValue(text.slice(start, value.end))) {
649
+ regex.lastIndex = start;
650
+ continue;
651
+ }
652
+ addLabelValue({ text, add, start, value });
653
+ regex.lastIndex = value.end;
370
654
  }
371
- return out;
372
- }
373
- // Exact secrets match as substrings at any length (a secret glued into other text stays masked),
374
- // but never where they overlap a span the later rules depend on: a credential label in label
375
- // position, a secret-named URL parameter, a URL scheme, or a complete marker. As plain substrings
376
- // they split labels before the label rules ran (`a` inside `password=hunter2` broke the label
377
- // and left `hunter2` visible; `api_key` swallowed the whole `api_key=` label), and they split
378
- // earlier markers (`a` inside `exact_secret`, `email` inside `[REDACTED:email]`).
379
- const PROTECTED_SPAN_RE = new RegExp([
380
- `(?<![\\w-])["']?(?:${SERIALIZED_SECRET_LABELS})["']?\\s*[:=]`,
381
- '\\bbearer\\s+',
382
- URL_SECRET_PARAM_RE.source.slice(0, URL_SECRET_PARAM_RE.source.lastIndexOf('=') + 1),
383
- URL_CREDENTIALS_RE.source.slice(0, URL_CREDENTIALS_RE.source.indexOf(')') + 1),
384
- '\\[REDACTED:[^\\]]*\\]',
385
- '\\[redacted\\]',
386
- ].join('|'), 'gi');
387
- /** Masks caller-supplied secrets verbatim, longest first, ignoring empty and non-string entries;
388
- * an occurrence overlapping a protected span is left for the label rules. */
389
- function redactExactSecrets({ text, marker, secrets }, options = {}) {
390
- const usable = [...new Set(secrets.filter((secret) => typeof secret === 'string' && secret.length > 0))].sort((a, b) => b.length - a.length);
391
- if (options.singlePass) {
392
- // Match the original text once: a marker can contain a supplied secret or '$' literally.
393
- let out = '';
394
- let cursor = 0;
395
- for (const [start, end] of mergedSecretRanges({ text, secrets: usable })) {
396
- out += text.slice(cursor, start) + marker('exact_secret');
397
- cursor = end;
655
+ }
656
+ /** Preserve quoted diagnostic/JSON values, including the closing quote on truncated echoes. */
657
+ function addLabelValue({ text, add, start, value }) {
658
+ const quoted = value.quoted ? 1 : 0;
659
+ const closed = value.closingQuote === value.end - 1;
660
+ add(start + quoted, value.end - (quoted && closed ? 1 : 0), CATEGORY.labeledSecret, quoted ? (closed ? '' : text[start]) : undefined);
661
+ }
662
+ /** Index earlier spans once so each label's coverage check takes O(1), including dense headers.
663
+ * No I/O; O(k) time/space for k earlier spans. */
664
+ function coveredLabelFields({ earlier }) {
665
+ // Per earlier span start: the furthest end (scalars rather than per-start end lists:
666
+ // dense fields yield millions of spans). A separator proves coverage only at the full value end.
667
+ const furthest = new Map();
668
+ for (const { start, end } of earlier) {
669
+ furthest.set(start, Math.max(furthest.get(start) ?? end, end));
670
+ }
671
+ return (start, value) => {
672
+ const at = !value.quoted || furthest.has(start) ? start : start + 1;
673
+ return isCoveredField(furthest.get(at), value);
674
+ };
675
+ }
676
+ /** True when an earlier span starting at the label value (inside its opening quote, for a quoted
677
+ * value) masks it: `reach` is the furthest such span end.
678
+ * Inside quotes only the closing quote ends the value (`;` and `&` are value text:
679
+ * `password="sk-...;hunter2"` once left `hunter2`); unquoted values also need complete coverage
680
+ * so a provider-prefix rule ending at `;`/`&` cannot leave the rest of a secret visible. */
681
+ function isCoveredField(reach, { end, closingQuote }) {
682
+ const target = closingQuote === end - 1 ? closingQuote : end;
683
+ return (reach ?? -1) >= target;
684
+ }
685
+ /** Extent finder for label values whose starts increase between calls. An unquoted value runs to
686
+ * whitespace or a `;`/`&` followed by a new label (or end of text). Quotes inside an unquoted
687
+ * secret are secret bytes, not terminators. A quoted value runs through its closing quote (a backslash-escaped quote does not
688
+ * close it) and any text glued after it up to whitespace, `;`, `&` or `,` (`"abc def"hunter2` once
689
+ * left `hunter2`), or to the line end when the quote never closes, so spaces, `;` and `&` inside
690
+ * quotes stay in the value. Structured field delimiters after a closing quote stay readable.
691
+ * @complexity Amortized O(1) per call; all calls together scan the text once per finder. */
692
+ function labelValueExtents(text) {
693
+ const runEnd = forwardFinder(text, /\s|[;&](?=$|[\w.-]+\s*[:=])/);
694
+ const gluedEnd = forwardFinder(text, /[\s;&,}\]]/);
695
+ const lineEnd = forwardFinder(text, /[\r\n]/);
696
+ const closers = new Map([['"', unescapedQuoteFinder(text, /"/)], ["'", unescapedQuoteFinder(text, /'/)]]);
697
+ return (start) => {
698
+ const whitespace = runEnd(start);
699
+ const closer = closers.get(text[start] ?? '');
700
+ if (!closer)
701
+ return { end: whitespace, closingQuote: -1, quoted: false };
702
+ const closing = closer(start + 1);
703
+ const line = lineEnd(start);
704
+ return closing < text.length
705
+ ? { end: quotedValueEnd({ text, closing, gluedEnd: gluedEnd(closing + 1) }), closingQuote: closing, quoted: true }
706
+ : { end: line, closingQuote: -1, quoted: true };
707
+ };
708
+ }
709
+ /** `forwardFinder` for a quote that skips backslash-escaped ones: an odd run of backslashes before a
710
+ * quote escapes it (`\\"` is an escaped backslash, then a real quote). Same non-decreasing `from` rule.
711
+ * @complexity O(n) over all calls: each quote's backslash run is counted once. */
712
+ function unescapedQuoteFinder(text, quote) {
713
+ const next = forwardFinder(text, quote);
714
+ let found = -1;
715
+ return (from) => {
716
+ if (from > found) {
717
+ found = next(from);
718
+ while (found < text.length && isBackslashEscaped(text, found))
719
+ found = next(found + 1);
720
+ }
721
+ return found;
722
+ };
723
+ }
724
+ /** True when an odd run of backslashes ends right before `index`. */
725
+ function isBackslashEscaped(text, index) {
726
+ let backslashes = 0;
727
+ while (text[index - 1 - backslashes] === '\\')
728
+ backslashes++;
729
+ return backslashes % 2 === 1;
730
+ }
731
+ /** `find(from)`: the first index at or after `from` matching `pattern`, or `text.length`. `from` must
732
+ * not decrease between calls: one search answers every later `from` up to its result, since nothing
733
+ * matched between the earlier `from` and that result.
734
+ * @complexity O(n) over all calls for n chars. */
735
+ function forwardFinder(text, pattern) {
736
+ const regex = new RegExp(pattern.source, 'g');
737
+ let found = -1;
738
+ return (from) => {
739
+ if (from > found) {
740
+ regex.lastIndex = from;
741
+ found = regex.exec(text)?.index ?? text.length;
742
+ }
743
+ return found;
744
+ };
745
+ }
746
+ /** Opt-in bare opaque runs; whole trusted dotted IDs and non-secret diagnostic tokens stay readable. */
747
+ function collectOpaqueTokens({ text, add }, trustedIds) {
748
+ for (const token of text.matchAll(/[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*/g)) {
749
+ if (trustedIds.has(token[0]))
750
+ continue;
751
+ for (const run of token[0].matchAll(OPAQUE_TOKEN_RE)) {
752
+ if (NON_SECRET_TOKEN_RE.test(run[0]))
753
+ continue;
754
+ const start = (token.index ?? 0) + (run.index ?? 0);
755
+ add(start, start + run[0].length, CATEGORY.opaqueToken);
398
756
  }
399
- return out + text.slice(cursor);
400
757
  }
401
- let out = text;
402
- for (const secret of usable) {
403
- const escaped = secret.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
404
- // Recomputed per secret: a longer secret's marker is protected from the shorter ones.
405
- const spans = [...out.matchAll(PROTECTED_SPAN_RE)].map(({ index, 0: span }) => ({ start: index, end: index + span.length }));
406
- out = out.replace(new RegExp(escaped, 'g'), (match, offset) => spans.some(({ start, end }) => offset < end && offset + match.length > start) ? match : marker('exact_secret'));
758
+ }
759
+ /** Email/IPv4/phone rules, then Luhn-checked card candidates. The email rule retains alphabetic TLDs so versioned paths stay readable. An "email" whose `@` ends URL userinfo
760
+ * (`scheme://user:pw@host`) is skipped: its local part is already the credential span, and
761
+ * merging it would mask the host, which stays readable for debugging. */
762
+ function collectPii(pass, earlier) {
763
+ const userinfoEnds = new Set(earlier.filter(({ category }) => category === CATEGORY.urlCredentials).map(({ end }) => end));
764
+ for (const { name, regex } of PII_PATTERNS) {
765
+ collectMatches(pass, regex, (match, index) => name === 'email' && userinfoEnds.has(index) ? undefined
766
+ : { start: index - (match.groups?.local?.length ?? 0), end: index + match[0].length, category: name });
407
767
  }
408
- return out;
768
+ collectMatches(pass, CARD_CANDIDATE, (match, index) => isLuhnValid(match[0].replace(/\D/g, '')) ? { start: index, end: index + match[0].length, category: CATEGORY.creditCard } : undefined);
409
769
  }
410
- /** Every occurrence of every secret, overlapping ranges merged: with 'abc' and 'bcdef', a left-to-right
411
- * alternation would replace 'abc' and leave 'def' — part of the other secret — in the output.
412
- * @complexity O(s * n) indexOf scans for s secrets over n characters, plus O(r log r) for r matches. */
413
- function mergedSecretRanges({ text, secrets }) {
414
- const ranges = [];
770
+ /** Each usable secret plus its JSON-escaped spelling (`ab"cd` echoed inside JSON as `ab\"cd`). */
771
+ function exactNeedles(secrets, literalOnly = false) {
772
+ const needles = new Set();
415
773
  for (const secret of secrets) {
416
- for (let at = text.indexOf(secret); at !== -1; at = text.indexOf(secret, at + 1))
417
- ranges.push([at, at + secret.length]);
774
+ if (typeof secret !== 'string' || secret.length === 0)
775
+ continue;
776
+ needles.add(secret);
777
+ if (!literalOnly)
778
+ needles.add(JSON.stringify(secret).slice(1, -1));
418
779
  }
419
- ranges.sort((a, b) => a[0] - b[0]);
420
- const merged = [];
421
- for (const [start, end] of ranges) {
422
- const last = merged.at(-1);
423
- if (last && start < last[1])
424
- last[1] = Math.max(last[1], end);
425
- else
426
- merged.push([start, end]);
780
+ return needles;
781
+ }
782
+ /** Caller-supplied secrets as plain substrings at any length and every occurrence, overlapping
783
+ * ones included (a secret glued into other text stays masked). Matching the original text retains every value rule
784
+ * while wholly contained syntax occurrences preserve labels, markers and URL prefixes. Occurrences arrive
785
+ * in start order and each one either extends the current run or starts the next, so the runs are
786
+ * exactly the union of all occurrences.
787
+ * @complexity O(e·n + s) for e needles, n text chars and s needle chars; consecutive overlapping
788
+ * hits of one needle share a span. */
789
+ function collectExactSecrets({ text, add, secrets }, options = {}) {
790
+ const needles = exactNeedles(secrets, options.literalOnly);
791
+ if (needles.size === 0)
792
+ return;
793
+ const protectedSyntax = options.literalOnly ? [] : exactSyntaxRanges({ text });
794
+ for (const needle of needles) {
795
+ const insideSyntax = syntaxContainment({ ranges: protectedSyntax });
796
+ let runStart = -1;
797
+ let runEnd = -1;
798
+ forEachOccurrence(text, needle, (at) => {
799
+ if (insideSyntax(at, at + needle.length))
800
+ return;
801
+ if (beginsSecretRun({ at, runEnd, literalOnly: options.literalOnly === true })) {
802
+ add(runStart, runEnd, CATEGORY.exactSecret);
803
+ runStart = at;
804
+ }
805
+ runEnd = at + needle.length;
806
+ });
807
+ add(runStart, runEnd, CATEGORY.exactSecret);
427
808
  }
428
- return merged;
429
809
  }
430
- /** URL userinfo and signed query values: credentials recognized by their URL context. */
431
- function redactUrlCredentials({ text, marker }) {
432
- return text
433
- .replace(URL_CREDENTIALS_RE, (match, scheme, userinfo) => isMaskedValue(userinfo) ? match : `${scheme}${marker('url_credentials')}@`)
434
- .replace(SIGNED_QUERY_RE, (match, prefix, value) => isMaskedValue(value) ? match : `${prefix}${marker('signed_query')}`);
435
- }
436
- /** Mask quoted hints ending in an empty credential assignment before scanning labels. Otherwise
437
- * their closing quote is mistaken for a value's opening quote and consumes the next field's key,
438
- * leaving that field's secret unrecognized. Only the hint token is replaced; neighboring labels
439
- * remain available to the existing matchers, including inspect's unquoted keys.
440
- * @complexity O(n) scanning the diagnostic's quoted tokens; no I/O or input mutation.
441
- */
442
- function redactEmptyQuotedAssignments({ text, marker }, _options = {}) {
443
- return text.replace(new RegExp(QUOTED_SECRET_VALUE, 'g'), (quoted) => {
444
- if (!EMPTY_QUOTED_ASSIGNMENT_RE.test(quoted.slice(1, -1)))
445
- return quoted;
446
- const quote = quoted.charAt(0);
447
- return `${quote}${marker('labeled_secret')}${quote}`;
448
- });
810
+ /** Preserve diagnostic syntax only when an exact occurrence fits wholly inside it.
811
+ * Crossing a label/marker boundary still masks the complete secret, retaining span-union safety.
812
+ * @complexity O(n + k) time/space over n characters and k syntax ranges; no I/O. */
813
+ function exactSyntaxRanges({ text }, _options = {}) {
814
+ const ranges = [];
815
+ const add = (start, end) => { ranges.push({ start, end, category: 'syntax' }); };
816
+ const pass = { text, add };
817
+ collectMatches(pass, new RegExp(`${GENERATED_MARKER}|\\[redacted\\]`, 'gi'), (m, at) => ({ start: at, end: at + m[0].length, category: 'syntax' }));
818
+ collectMatches(pass, MASK_AWARE_LABELED_SECRET_RE, (m, at) => ({ start: at, end: at + m[0].length - 1, category: 'syntax' }));
819
+ collectMatches(pass, /\bBearer\s+|\bhttps?:\/\/|(?:x-api-key|api-key|x-goog-api-key)\s*[:=]\s*/gi, (m, at) => ({ start: at, end: at + m[0].length, category: 'syntax' }));
820
+ collectMatches(pass, URL_SECRET_PARAM_RE, (m, at) => ({ start: at, end: at + m[0].indexOf('=') + 1, category: 'syntax' }));
821
+ return mergeSpans(ranges);
449
822
  }
450
- /** Values named by a header, query parameter, bearer prefix or credential label. */
451
- function redactKeyedCredentials({ text, marker }) {
452
- let out = redactEmptyQuotedAssignments({ text, marker });
453
- out = out.replace(API_KEY_HEADER, (_match, prefix, quote, name, separator, value) => {
454
- // Already masked values are not new credentials and should not inflate the audit count.
455
- if (isMaskedValue(value))
456
- return `${prefix}${quote}${name}${quote}${separator}${value}`;
457
- marker('api_key_header');
458
- const valueQuote = /^["']/.test(value) ? value.charAt(0) : '';
459
- const redactedValue = `${valueQuote}[REDACTED:api_key_header]${valueQuote}`;
460
- return `${prefix}${quote}${name}${quote}${separator}${redactedValue}`;
461
- });
462
- // The provider rule includes a query fragment in an echoed key; mask that whole value
463
- // before the narrower core query rule can split it and leave a credential suffix behind.
464
- out = out.replace(PROVIDER_API_KEY_QUERY, (match, prefix, value) => isMaskedValue(value) ? match : `${prefix}${marker('api_key_query')}`);
465
- out = out.replace(API_KEY_QUERY, (match, prefix) => isMaskedValue(match.slice(prefix.length)) ? match : `${prefix}${marker('api_key_query')}`);
466
- out = out.replace(SHORT_BEARER_RE, () => marker('bearer_token'));
467
- out = out.replace(PROVIDER_API_KEY_HEADER, (match, name, value) => isMaskedValue(value) ? match : `${name}: ${marker('api_key_header')}`);
468
- // Sweep other URL parameters before the general labeled rule can consume harmless
469
- // neighboring parameters (`password=x&page=2` used to lose `&page=2`).
470
- out = redactUrlSecretParams({ text: out, marker });
471
- return out.replace(MASK_AWARE_LABELED_SECRET_RE, (match, prefix, _quote, value) => {
472
- // Replace only the captured value, retaining labels even when no whitespace separates
473
- // them. Glued credential suffixes fail the complete-marker check and remain maskable.
474
- if (isMaskedValue(value))
475
- return match;
476
- const valueQuote = /^["']/.test(value) ? value.charAt(0) : '';
477
- return `${prefix}${valueQuote}${marker('labeled_secret')}${valueQuote}`;
478
- });
823
+ /** Increasing occurrence starts let each needle visit every protected range at most once.
824
+ * @complexity O(k + hits) across calls, O(1) retained state; no I/O. */
825
+ function syntaxContainment({ ranges }, _options = {}) {
826
+ let cursor = 0;
827
+ return (start, end) => {
828
+ while (cursor < ranges.length && ranges[cursor].end <= start)
829
+ cursor++;
830
+ const range = ranges[cursor];
831
+ return range !== undefined && range.start <= start && range.end >= end;
832
+ };
479
833
  }
480
- /** Secret-named URL query/fragment values; values already masked above keep their category. */
481
- function redactUrlSecretParams({ text, marker }) {
482
- return text.replace(URL_SECRET_PARAM_RE, (match, name) => {
483
- const separator = match.indexOf('=') + 1;
484
- if (isMaskedValue(match.slice(separator)))
485
- return match;
486
- const category = LABELED_URL_PARAM_NAME_RE.test(name) ? 'labeled_secret' : 'url_secret_param';
487
- return `${match.slice(0, separator)}${marker(category)}`;
488
- });
834
+ /** Heuristic union joins adjacent hits; exact-only retains one marker for each adjacent occurrence. */
835
+ function beginsSecretRun({ at, runEnd, literalOnly }, _options = {}) {
836
+ return literalOnly ? at >= runEnd : at > runEnd;
489
837
  }
490
- /** Email/IPv4/phone rules, then Luhn-checked card candidates. */
491
- function redactPii({ text, marker }) {
492
- return applyPatterns({ text, marker, patterns: PII_PATTERNS }).replace(CARD_CANDIDATE, (match) => {
493
- const digits = match.replace(/\D/g, '');
494
- return isLuhnValid(digits) ? marker('credit_card') : match;
495
- });
838
+ /** Calls `visit` with the start of every occurrence of `needle` in order, overlapping ones included.
839
+ * Knuth-Morris-Pratt: after a hit the scan keeps the needle's longest border instead of retrying one
840
+ * character later (which cost Θ(n·m) on long overlapping needles), so no text character is read
841
+ * twice; while no partial match is pending, native indexOf skips to the next occurrence.
842
+ * @complexity O(n + m) time, O(m) space for n text and m needle chars. */
843
+ function forEachOccurrence(text, needle, visit) {
844
+ const border = borderTable(needle);
845
+ let matched = 0;
846
+ let at = text.indexOf(needle);
847
+ while (at !== -1) {
848
+ const char = text.charCodeAt(at);
849
+ while (matched > 0 && char !== needle.charCodeAt(matched))
850
+ matched = border[matched - 1] ?? 0;
851
+ if (char === needle.charCodeAt(matched))
852
+ matched += 1;
853
+ if (matched === needle.length) {
854
+ visit(at + 1 - needle.length);
855
+ matched = border[matched - 1] ?? 0;
856
+ }
857
+ at = nextSearchOffset({ text, needle, at, matched });
858
+ }
859
+ }
860
+ /** Keep partial KMP matches moving one character at a time; native indexOf skips unmatched runs. */
861
+ function nextSearchOffset({ text, needle, at, matched }, _options = {}) {
862
+ const next = at + 1;
863
+ if (next >= text.length)
864
+ return -1;
865
+ return matched === 0 ? text.indexOf(needle, next) : next;
866
+ }
867
+ /** KMP failure table: `border[i]` is the length of the longest proper prefix of `needle[0..i]` that
868
+ * is also its suffix. @complexity O(m) time and space. */
869
+ function borderTable(needle) {
870
+ const border = new Int32Array(needle.length);
871
+ let length = 0;
872
+ for (let i = 1; i < needle.length; i += 1) {
873
+ const char = needle.charCodeAt(i);
874
+ while (length > 0 && char !== needle.charCodeAt(length))
875
+ length = border[length - 1] ?? 0;
876
+ if (char === needle.charCodeAt(length))
877
+ length += 1;
878
+ border[i] = length;
879
+ }
880
+ return border;
881
+ }
882
+ /** Quoted empty credential hints must not borrow the next field's key as their value. */
883
+ function emptyAssignmentRanges(text) {
884
+ const starts = new Set();
885
+ for (const match of text.matchAll(new RegExp(QUOTED_SECRET_VALUE, 'g'))) {
886
+ if (!EMPTY_QUOTED_ASSIGNMENT_RE.test(match[0].slice(1, -1)))
887
+ continue;
888
+ // Only credential-label starts inside this quoted hint need skipping; no per-character index.
889
+ const labels = new RegExp(MASK_AWARE_LABELED_SECRET_RE.source, 'gi');
890
+ for (const label of match[0].matchAll(labels))
891
+ starts.add(match.index + label.index);
892
+ }
893
+ return starts;
894
+ }
895
+ /** Preserve the hint's quotes and neighboring fields; O(n) scan, no I/O. */
896
+ function collectEmptyQuotedAssignments(pass) {
897
+ for (const match of pass.text.matchAll(new RegExp(QUOTED_SECRET_VALUE, 'g'))) {
898
+ if (EMPTY_QUOTED_ASSIGNMENT_RE.test(match[0].slice(1, -1))) {
899
+ pass.add(match.index + 1, match.index + match[0].length - 1, CATEGORY.labeledSecret);
900
+ }
901
+ }
902
+ }
903
+ /** Standalone diagnostic punctuation stays readable; punctuation glued to credential text is masked. */
904
+ function quotedValueEnd({ text, closing, gluedEnd }, _options = {}) {
905
+ return /^[.!)]*$/.test(text.slice(closing + 1, gluedEnd)) ? closing + 1 : gluedEnd;
496
906
  }
497
907
  //# sourceMappingURL=redact.js.map