@socprime/logtotal-sanitizer 0.2.0-beta.3 → 0.2.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,37 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.1-beta.0] - 2026-10-14
9
+
10
+ ### Fixed
11
+
12
+ - JSON field redaction now applies to lines that are not a whole JSON document: pretty-printed
13
+ records, JSON embedded in a text line (`body="{\"password\":\"…\"}"`) and Python dict reprs.
14
+ Values of `"key": value` pairs whose key belongs to a rule are replaced by name, as in
15
+ single-line JSON. Previously, a pretty-printed record leaked `ssn`, `passport_number`, `iban`,
16
+ `cvv`, `diagnosis` and similar fields whose values fail the plain-text checksums.
17
+ - Numeric values of sensitive keys (`"latitude": 48.1486`, `"pan": 4111111111111111`) are replaced
18
+ with a string token instead of being left as is.
19
+ - Array items inherit the key of their array, so `"emails": [...]` and GeoJSON
20
+ `"coordinates": [...]` are redacted.
21
+ - `hosts` rule: the reserved `.test`, `.invalid` and `.localhost` TLDs are recognized.
22
+
23
+ ### Changed
24
+
25
+ - JSON key matching also ignores `.` and spaces, and tries a nested field together with its
26
+ parent's name (`user` + `name`, `client` + `ip`, `source` + `address`) before the field's own
27
+ name. Dotted flat keys (`"source.ip"`) resolve the same way.
28
+ - In a pretty-printed record, a field named in `jsonKeys` is now replaced as a whole, the same
29
+ way the single-line form already was. For example, `"User": "CORP\\jdoe"` becomes one `USER`
30
+ token instead of a `HOST` and a `USER` token.
31
+ - Key resolution is memoized per rule set, with a bounded cache.
32
+ - Expanded `jsonKeys` and `jsonKeyContains` for every built-in rule with field names used by ECS,
33
+ AWS CloudTrail, Azure sign-in logs, Windows events, CEF, Datadog and common application logs:
34
+ cloud and API credentials, session and CSRF cookies, card, bank and wallet fields, national
35
+ identifiers (PESEL, rodné číslo, CPF, Aadhaar, РНОКПП and others), clinical fields, phone and
36
+ telephony fields, source, destination and forwarded IPs, workstation, SNI and DNS query hosts,
37
+ mail and actor identities, and street addresses.
38
+
8
39
  ## [0.2.0-beta.3] - 2026-09-23
9
40
 
10
41
  ### Fixed
package/README.md CHANGED
@@ -98,7 +98,9 @@ Enabled in this order when you omit `rules`:
98
98
 
99
99
  `mask` mode (`<R:…>`) hides the kind of secret. `pseudo` mode keeps a type prefix so you can still read the timeline.
100
100
 
101
- JSON lines: if a line parses as a JSON object or array, fields listed in a rule's `jsonKeys` are redacted by name (case- and `-`/`_`-insensitive). Other string values still go through the regex pass.
101
+ JSON lines: if a line parses as a JSON object or array, fields listed in a rule's `jsonKeys` (or whose name contains one of its `jsonKeyContains` fragments) are redacted by name. Matching ignores case, `-`, `_`, `.` and spaces. A nested field is also tried with its parent's name, so `{"user": {"name": …}}` and a flat `"user.name"` both resolve as `userName`, and `source.address` resolves as an IP rather than a street address. String and numeric values are replaced (a number becomes a string token); booleans, `null`, `""` and `"-"` are kept. Array items inherit the key of their array. Other string values still go through the regex pass.
102
+
103
+ Lines that are not a whole JSON document (pretty-printed records, JSON embedded in a text line, JSON escaped inside another JSON string, Python dict reprs) get the same treatment for `"key": value`, `\"key\": \"value\"` and `'key': 'value'` pairs, without the parent-name lookup. A bare number replaced there is wrapped in quotes, so the line stays valid JSON. Set `json: false` to scan everything as plain text.
102
104
 
103
105
  ## Custom rules
104
106
 
@@ -1,4 +1,4 @@
1
- import { InvalidKeyError, InvalidOptionError, builtinRuleIds, SanitizationAbortedError, getBuiltinRule, UnknownRuleError, validateRule, countCapturingGroups, InvalidRuleError, deriveGateSource, lineHasAnyChar } from './chunk-VMPXBCSL.js';
1
+ import { InvalidKeyError, InvalidOptionError, builtinRuleIds, SanitizationAbortedError, getBuiltinRule, UnknownRuleError, validateRule, countCapturingGroups, InvalidRuleError, deriveGateSource, lineHasAnyChar } from './chunk-X2QYRBY6.js';
2
2
 
3
3
  // src/core/constants.ts
4
4
  var DEFAULT_PREVIEW_BYTES = 256 * 1024;
@@ -459,6 +459,181 @@ function createLineSplitter(options = {}) {
459
459
  return { push, flush };
460
460
  }
461
461
 
462
+ // src/core/jsonKeyMatcher.ts
463
+ var RESOLVE_CACHE_LIMIT = 1e4;
464
+ function normalizeKey2(key) {
465
+ return key.toLowerCase().replace(/[-_.\s]/g, "");
466
+ }
467
+ function shouldPreserveJsonValue(value) {
468
+ return value.length === 0 || value === "-";
469
+ }
470
+ function buildTables(rules) {
471
+ const exact = /* @__PURE__ */ new Map();
472
+ const contains = [];
473
+ for (const rule of rules) {
474
+ for (const key of rule.jsonKeys ?? []) {
475
+ const normalized = normalizeKey2(key);
476
+ if (!exact.has(normalized)) {
477
+ exact.set(normalized, rule);
478
+ }
479
+ }
480
+ for (const fragment of rule.jsonKeyContains ?? []) {
481
+ contains.push({ fragment: normalizeKey2(fragment), rule });
482
+ }
483
+ }
484
+ return { exact, contains };
485
+ }
486
+ function lastSegment(key) {
487
+ const dot = key.lastIndexOf(".");
488
+ return dot === -1 ? key : key.slice(dot + 1);
489
+ }
490
+ function resolveUncached(tables, key, parent) {
491
+ const dot = key.lastIndexOf(".");
492
+ const leaf = normalizeKey2(dot === -1 ? key : key.slice(dot + 1));
493
+ const parentRaw = dot === -1 ? parent : key.slice(0, dot);
494
+ if (leaf.length === 0) {
495
+ return null;
496
+ }
497
+ if (parentRaw !== void 0) {
498
+ const scoped = tables.exact.get(normalizeKey2(lastSegment(parentRaw)) + leaf);
499
+ if (scoped) {
500
+ return scoped;
501
+ }
502
+ }
503
+ const exact = tables.exact.get(leaf);
504
+ if (exact) {
505
+ return exact;
506
+ }
507
+ for (const entry of tables.contains) {
508
+ if (leaf.includes(entry.fragment)) {
509
+ return entry.rule;
510
+ }
511
+ }
512
+ return null;
513
+ }
514
+ function buildMatcher(rules) {
515
+ const tables = buildTables(rules);
516
+ const cache = /* @__PURE__ */ new Map();
517
+ return {
518
+ resolve(key, parent) {
519
+ const cacheKey = parent === void 0 ? key : `${parent}\0${key}`;
520
+ let rule = cache.get(cacheKey);
521
+ if (rule === void 0) {
522
+ rule = resolveUncached(tables, key, parent);
523
+ if (cache.size >= RESOLVE_CACHE_LIMIT) {
524
+ cache.clear();
525
+ }
526
+ cache.set(cacheKey, rule);
527
+ }
528
+ return rule ?? void 0;
529
+ }
530
+ };
531
+ }
532
+ var matcherCache = /* @__PURE__ */ new WeakMap();
533
+ function jsonKeyMatcher(rules) {
534
+ const cached = matcherCache.get(rules);
535
+ if (cached) {
536
+ return cached;
537
+ }
538
+ const matcher = buildMatcher(rules);
539
+ matcherCache.set(rules, matcher);
540
+ return matcher;
541
+ }
542
+
543
+ // src/core/redactKeyValuePairs.ts
544
+ var NUMBER = "(-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)(?![\\w.:+-])";
545
+ var DOUBLE_QUOTED = new RegExp(
546
+ `(?<![\\\\\\w])"((?:[^"\\\\\\r\\n]|\\\\.){1,128})"\\s*:\\s*(?:"((?:[^"\\\\]|\\\\.)*)"|${NUMBER})`,
547
+ "g"
548
+ );
549
+ var ESCAPED = new RegExp(
550
+ `\\\\"((?:[^"\\\\\\r\\n]|\\\\\\\\){1,128})\\\\"\\s*:\\s*(?:\\\\"((?:\\\\\\\\\\\\"|\\\\\\\\|\\\\[^"\\\\]|[^"\\\\])*)\\\\"|${NUMBER})`,
551
+ "g"
552
+ );
553
+ var SINGLE_QUOTED = new RegExp(
554
+ `(?<![\\\\\\w])'((?:[^'\\\\\\r\\n]|\\\\.){1,128})'\\s*:\\s*(?:'((?:[^'\\\\]|\\\\.)*)'|"((?:[^"\\\\]|\\\\.)*)"|${NUMBER})`,
555
+ "g"
556
+ );
557
+ function decodeJsonString(raw) {
558
+ if (!raw.includes("\\")) {
559
+ return raw;
560
+ }
561
+ try {
562
+ return JSON.parse(`"${raw}"`);
563
+ } catch {
564
+ return raw;
565
+ }
566
+ }
567
+ function decodeEscapedJsonString(raw) {
568
+ return decodeJsonString(decodeJsonString(raw));
569
+ }
570
+ function decodePythonString(raw) {
571
+ return raw.includes("\\") ? raw.replace(/\\(['"\\])/g, "$1") : raw;
572
+ }
573
+ function collect(line, regex, rules, decode, numberQuote, out) {
574
+ const matcher = jsonKeyMatcher(rules);
575
+ regex.lastIndex = 0;
576
+ for (let found = regex.exec(line); found !== null; found = regex.exec(line)) {
577
+ const rawKey = found[1];
578
+ let rawValue;
579
+ let quote = "";
580
+ for (let group = 2; group < found.length; group += 1) {
581
+ if (found[group] !== void 0) {
582
+ rawValue = found[group];
583
+ quote = group === found.length - 1 ? numberQuote : "";
584
+ break;
585
+ }
586
+ }
587
+ if (rawValue === void 0) {
588
+ continue;
589
+ }
590
+ const value = quote === "" ? decode(rawValue) : rawValue;
591
+ if (shouldPreserveJsonValue(value)) {
592
+ continue;
593
+ }
594
+ const rule = matcher.resolve(decode(rawKey));
595
+ if (!rule) {
596
+ continue;
597
+ }
598
+ const end = found.index + found[0].length - (quote === "" ? 1 : 0);
599
+ const valueEnd = quote === "" && regex === ESCAPED ? end - 1 : end;
600
+ out.push({
601
+ keyStart: found.index,
602
+ match: { start: valueEnd - rawValue.length, end: valueEnd, value, rule, quote }
603
+ });
604
+ }
605
+ }
606
+ function findKeyValueMatches(line, rules) {
607
+ const doubleQuoted = line.includes('":') || line.includes('" :');
608
+ const singleQuoted = line.includes("':") || line.includes("' :");
609
+ if (!doubleQuoted && !singleQuoted) {
610
+ return [];
611
+ }
612
+ const candidates = [];
613
+ if (doubleQuoted) {
614
+ collect(line, DOUBLE_QUOTED, rules, decodeJsonString, '"', candidates);
615
+ if (line.includes('\\"')) {
616
+ collect(line, ESCAPED, rules, decodeEscapedJsonString, '\\"', candidates);
617
+ }
618
+ }
619
+ if (singleQuoted) {
620
+ collect(line, SINGLE_QUOTED, rules, decodePythonString, "'", candidates);
621
+ }
622
+ if (candidates.length === 0) {
623
+ return [];
624
+ }
625
+ candidates.sort((left, right) => left.keyStart - right.keyStart);
626
+ const matches = [];
627
+ let floor = 0;
628
+ for (const { keyStart, match } of candidates) {
629
+ if (keyStart >= floor) {
630
+ matches.push(match);
631
+ floor = match.end;
632
+ }
633
+ }
634
+ return matches;
635
+ }
636
+
462
637
  // src/core/redactLine.ts
463
638
  function sliceContext(text, index, length, chars) {
464
639
  return {
@@ -538,11 +713,21 @@ function pickNext(cursors) {
538
713
  }
539
714
  return best;
540
715
  }
716
+ function blankOut(line, spans) {
717
+ let out = "";
718
+ let cursor = 0;
719
+ for (const span of spans) {
720
+ out += line.slice(cursor, span.start) + " ".repeat(span.end - span.start);
721
+ cursor = span.end;
722
+ }
723
+ return out + line.slice(cursor);
724
+ }
541
725
  function redactLine(line, ctx, options = {}) {
542
726
  const { compiled, rulesById, allow, contextChars } = ctx;
543
727
  const withSegments = options.withSegments ?? false;
544
728
  const withMatches = options.withMatches ?? false;
545
- if (compiled.length === 0) {
729
+ const keyed = ctx.json ? findKeyValueMatches(line, ctx.rules) : [];
730
+ if (compiled.length === 0 && keyed.length === 0) {
546
731
  return {
547
732
  output: line,
548
733
  counts: {},
@@ -550,18 +735,65 @@ function redactLine(line, ctx, options = {}) {
550
735
  segments: withSegments ? { before: [{ text: line, changed: false }], after: [{ text: line, changed: false }] } : void 0
551
736
  };
552
737
  }
553
- const cursors = openCursors(line, compiled);
738
+ const scan = keyed.length === 0 ? line : blankOut(line, keyed);
739
+ const cursors = openCursors(scan, compiled);
554
740
  const counts = {};
555
741
  const matches = [];
556
742
  const before = [];
557
743
  const after = [];
558
744
  let output = "";
559
745
  let cursor = 0;
746
+ let keyedIndex = 0;
560
747
  for (; ; ) {
561
748
  const next = pickNext(cursors);
749
+ const pending = keyed[keyedIndex];
750
+ if (pending !== void 0 && (next === null || pending.start <= next.start)) {
751
+ keyedIndex += 1;
752
+ if (!isAllowed(allow, pending.rule.id, pending.value)) {
753
+ const token = buildReplacement(ctx, pending.rule.id, pending.value);
754
+ const raw = line.slice(pending.start, pending.end);
755
+ if (withSegments) {
756
+ if (pending.start > cursor) {
757
+ const gap = line.slice(cursor, pending.start);
758
+ before.push({ text: gap, changed: false });
759
+ after.push({ text: gap, changed: false });
760
+ }
761
+ before.push({ text: raw, changed: true });
762
+ if (pending.quote) {
763
+ after.push({ text: pending.quote, changed: false });
764
+ }
765
+ after.push({ text: token, changed: true });
766
+ if (pending.quote) {
767
+ after.push({ text: pending.quote, changed: false });
768
+ }
769
+ }
770
+ output += line.slice(cursor, pending.start) + pending.quote + token + pending.quote;
771
+ cursor = pending.end;
772
+ counts[pending.rule.id] = (counts[pending.rule.id] ?? 0) + 1;
773
+ if (withMatches) {
774
+ matches.push({
775
+ ruleId: pending.rule.id,
776
+ original: pending.value,
777
+ replacement: token,
778
+ index: pending.start,
779
+ ...contextChars > 0 ? sliceContext(line, pending.start, raw.length, contextChars) : {}
780
+ });
781
+ }
782
+ }
783
+ for (const candidate of cursors) {
784
+ if (candidate.active && candidate.start < pending.end) {
785
+ advance(candidate, scan, pending.end);
786
+ }
787
+ }
788
+ continue;
789
+ }
562
790
  if (next === null) {
563
791
  break;
564
792
  }
793
+ if (pending !== void 0 && next.start + next.original.length > pending.start) {
794
+ advance(next, scan, next.start + 1);
795
+ continue;
796
+ }
565
797
  const { start, original, ruleId } = next;
566
798
  const spanEnd = start + Math.max(original.length, 1);
567
799
  const rule = rulesById.get(ruleId);
@@ -592,7 +824,7 @@ function redactLine(line, ctx, options = {}) {
592
824
  }
593
825
  for (const candidate of cursors) {
594
826
  if (candidate.active && candidate.start < spanEnd) {
595
- advance(candidate, line, spanEnd);
827
+ advance(candidate, scan, spanEnd);
596
828
  }
597
829
  }
598
830
  }
@@ -616,51 +848,6 @@ function looksLikeJson(line) {
616
848
  const trimmed = line.trim();
617
849
  return trimmed.length > 0 && (trimmed.startsWith("{") || trimmed.startsWith("["));
618
850
  }
619
- function normalizeKey2(key) {
620
- return key.toLowerCase().replace(/[-_]/g, "");
621
- }
622
- function shouldPreserveJsonValue(value) {
623
- return value.length === 0 || value === "-";
624
- }
625
- function buildJsonKeyMatcher(rules) {
626
- const exact = /* @__PURE__ */ new Map();
627
- const contains = [];
628
- for (const rule of rules) {
629
- for (const key of rule.jsonKeys ?? []) {
630
- const normalized = normalizeKey2(key);
631
- if (!exact.has(normalized)) {
632
- exact.set(normalized, rule);
633
- }
634
- }
635
- for (const fragment of rule.jsonKeyContains ?? []) {
636
- contains.push({ fragment: normalizeKey2(fragment), rule });
637
- }
638
- }
639
- return { exact, contains };
640
- }
641
- function resolveJsonKeyRule(matcher, key) {
642
- const normalized = normalizeKey2(key);
643
- const exact = matcher.exact.get(normalized);
644
- if (exact) {
645
- return exact;
646
- }
647
- for (const entry of matcher.contains) {
648
- if (normalized.includes(entry.fragment)) {
649
- return entry.rule;
650
- }
651
- }
652
- return void 0;
653
- }
654
- var jsonKeyMatcherCache = /* @__PURE__ */ new WeakMap();
655
- function jsonKeyMatcher(rules) {
656
- const cached = jsonKeyMatcherCache.get(rules);
657
- if (cached) {
658
- return cached;
659
- }
660
- const matcher = buildJsonKeyMatcher(rules);
661
- jsonKeyMatcherCache.set(rules, matcher);
662
- return matcher;
663
- }
664
851
  function mergeCounts(target, source) {
665
852
  for (const [id, count] of Object.entries(source)) {
666
853
  target[id] = (target[id] ?? 0) + count;
@@ -687,6 +874,15 @@ function highlightReplacements(text) {
687
874
  function jsonStringContent(value) {
688
875
  return JSON.stringify(value).slice(1, -1);
689
876
  }
877
+ function locateJsonNumber(text, value, from = 0) {
878
+ const re = new RegExp(`:\\s*(${escapeRegExp(value)})(?![\\d.eE])`, "g");
879
+ re.lastIndex = from;
880
+ const match = re.exec(text);
881
+ if (match === null) {
882
+ return null;
883
+ }
884
+ return { index: match.index + match[0].length - value.length, length: value.length };
885
+ }
690
886
  function locateQuotedJsonValue(text, value, from = 0) {
691
887
  const encoded = jsonStringContent(value);
692
888
  const quotedAt = text.indexOf(`"${encoded}"`, from);
@@ -696,6 +892,9 @@ function locateQuotedJsonValue(text, value, from = 0) {
696
892
  return { index: quotedAt + 1, length: encoded.length };
697
893
  }
698
894
  function findOriginal(text, original, from) {
895
+ if (original.number === true) {
896
+ return locateJsonNumber(text, original.value, from);
897
+ }
699
898
  const encoded = jsonStringContent(original.value);
700
899
  if (original.wholeValue) {
701
900
  const quoted = `"${encoded}"`;
@@ -778,32 +977,39 @@ function redactJsonLine(line, ctx, options = {}) {
778
977
  }
779
978
  return { contextBefore: "", contextAfter: "" };
780
979
  };
781
- const redactValue = (value, key) => {
980
+ const redactField = (rule, value, number) => {
981
+ if (isAllowed(ctx.allow, rule.id, value)) {
982
+ return null;
983
+ }
984
+ const replacement = buildReplacement(ctx, rule.id, value);
985
+ counts[rule.id] = (counts[rule.id] ?? 0) + 1;
986
+ if (withSegments) {
987
+ originals.push({ value, wholeValue: true, number });
988
+ }
989
+ if (withMatches) {
990
+ const located = number ? locateJsonNumber(content, value) : locateQuotedJsonValue(content, value);
991
+ matches.push({
992
+ ruleId: rule.id,
993
+ original: value,
994
+ replacement,
995
+ index: located?.index ?? 0,
996
+ ...located && ctx.contextChars > 0 ? sliceContext(content, located.index, located.length, ctx.contextChars) : ctx.contextChars > 0 ? { contextBefore: "", contextAfter: "" } : {}
997
+ });
998
+ }
999
+ return replacement;
1000
+ };
1001
+ const redactValue = (value, key, parent) => {
1002
+ if (typeof value === "number") {
1003
+ const fieldRule = key === void 0 || !Number.isFinite(value) ? void 0 : keyMatcher.resolve(key, parent);
1004
+ return fieldRule ? redactField(fieldRule, String(value), true) ?? value : value;
1005
+ }
782
1006
  if (typeof value === "string") {
783
1007
  if (key !== void 0 && shouldPreserveJsonValue(value)) {
784
1008
  return value;
785
1009
  }
786
- const fieldRule = key === void 0 ? void 0 : resolveJsonKeyRule(keyMatcher, key);
1010
+ const fieldRule = key === void 0 ? void 0 : keyMatcher.resolve(key, parent);
787
1011
  if (fieldRule) {
788
- if (isAllowed(ctx.allow, fieldRule.id, value)) {
789
- return value;
790
- }
791
- const replacement = buildReplacement(ctx, fieldRule.id, value);
792
- counts[fieldRule.id] = (counts[fieldRule.id] ?? 0) + 1;
793
- if (withSegments) {
794
- originals.push({ value, wholeValue: true });
795
- }
796
- if (withMatches) {
797
- const located = locateQuotedJsonValue(content, value);
798
- matches.push({
799
- ruleId: fieldRule.id,
800
- original: value,
801
- replacement,
802
- index: located?.index ?? 0,
803
- ...located && ctx.contextChars > 0 ? sliceContext(content, located.index, located.length, ctx.contextChars) : ctx.contextChars > 0 ? { contextBefore: "", contextAfter: "" } : {}
804
- });
805
- }
806
- return replacement;
1012
+ return redactField(fieldRule, value, false) ?? value;
807
1013
  }
808
1014
  const result = redactLine(value, ctx, { withMatches: collectOriginals });
809
1015
  mergeCounts(counts, result.counts);
@@ -820,12 +1026,12 @@ function redactJsonLine(line, ctx, options = {}) {
820
1026
  return result.output;
821
1027
  }
822
1028
  if (Array.isArray(value)) {
823
- return value.map((item) => redactValue(item));
1029
+ return value.map((item) => redactValue(item, key, parent));
824
1030
  }
825
1031
  if (value !== null && typeof value === "object") {
826
1032
  const out = {};
827
1033
  for (const [entryKey, entryValue] of Object.entries(value)) {
828
- out[entryKey] = redactValue(entryValue, entryKey);
1034
+ out[entryKey] = redactValue(entryValue, entryKey, key);
829
1035
  }
830
1036
  return out;
831
1037
  }
@@ -1304,5 +1510,5 @@ function toNullSink() {
1304
1510
  }
1305
1511
 
1306
1512
  export { createSanitizer, fromBlob, fromString, fromWebStream, generateKey, mergeReports, planLineAlignedRanges, sanitizeStream, sanitizeText, toCallbackSink, toNullSink, toStringSink };
1307
- //# sourceMappingURL=chunk-SGF4CYUV.js.map
1308
- //# sourceMappingURL=chunk-SGF4CYUV.js.map
1513
+ //# sourceMappingURL=chunk-5UDGYWP3.js.map
1514
+ //# sourceMappingURL=chunk-5UDGYWP3.js.map