@link-foundation/sensitive-data-sanitizer 0.19.2 → 0.20.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +8 -1
  3. package/bin/sensitive-data-sanitizer.js +49 -28
  4. package/docs/API.md +193 -2
  5. package/docs/COVERAGE.md +22 -4
  6. package/docs/RULES.md +217 -0
  7. package/package.json +1 -1
  8. package/src/allow-list.js +93 -0
  9. package/src/confidence.js +24 -9
  10. package/src/context.js +121 -25
  11. package/src/credentials.js +44 -0
  12. package/src/crypto-addresses.js +74 -0
  13. package/src/detection.js +21 -6
  14. package/src/document-person-cues.js +1 -1
  15. package/src/encrypt.js +95 -0
  16. package/src/entities.js +4 -3
  17. package/src/fake.js +29 -1
  18. package/src/files.js +57 -12
  19. package/src/gitleaks.js +82 -27
  20. package/src/identifier-detector.js +36 -0
  21. package/src/identity.js +14 -0
  22. package/src/index.d.ts +117 -1
  23. package/src/index.js +7 -0
  24. package/src/national-checks.js +209 -0
  25. package/src/national.js +310 -92
  26. package/src/pattern-detector.js +219 -0
  27. package/src/personal-context.js +3 -2
  28. package/src/personal-evidence.js +233 -0
  29. package/src/personal.js +42 -10
  30. package/src/projection.js +85 -26
  31. package/src/rule-catalog.js +424 -0
  32. package/src/sanitizer.js +48 -3
  33. package/src/sarif.js +127 -0
  34. package/src/strict-attributes.js +71 -0
  35. package/src/strict.js +2 -12
  36. package/src/token-checksum.js +49 -0
  37. package/src/transforms.js +108 -25
  38. package/src/vendor/betterleaks/LICENSE +21 -0
  39. package/src/vendor/betterleaks/provenance.json +11 -0
  40. package/src/vendor/betterleaks/rules-0.json +463 -0
  41. package/src/vendor/betterleaks/rules-1.json +454 -0
  42. package/src/vendor/betterleaks/rules-2.json +461 -0
  43. package/src/vendor/betterleaks/rules-3.json +455 -0
  44. package/src/vendor/betterleaks/rules-4.json +483 -0
  45. package/src/vendor/betterleaks/rules-5.json +35 -0
  46. package/src/vendor/entities/aws.json +1 -1
  47. package/src/vendor/entities/azure.json +6 -2
  48. package/src/vendor/entities/google.json +2 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - befe673: Match the strongest features of competing secret and PII tools. Add national, tax, bank and device identifiers from Presidio, OpenRedaction, DataFog and Google; 203 vendored Betterleaks rules; offline CRC32 checks for GitHub and npm tokens (`tokenChecksum`, `tokenWithChecksum`); escape-run, HTML-tag and UTF-16 decoding; `allowList`, `inlineAllow` and `minConfidenceByType`; mask tuning and deterministic `encrypt` with `decrypt`; `createPatternDetector`, `createIdentifierDetector`, `identifierChecks` and the read-only `nationalRules` table; SARIF output (`toSarif`, `scan --format sarif`), multi-path `scan` and pre-commit hooks. Detect an IPv4 address followed by a full stop. Add a competitor comparison with measured results on 853 competitor test cases.
8
+
9
+ ## 0.19.3
10
+
11
+ ### Patch Changes
12
+
13
+ - 6bdaf11: Recognize additional inflected special-category attributes and unfamiliar values after explicit category cues in strict mode. Require personal context for ambiguous English attributes so operational logs and JSON statuses stay intact.
14
+
3
15
  ## 0.19.2
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Sensitive Data Sanitizer
2
2
 
3
- An offline JavaScript library, detector framework, and CLI for redacting credentials and personal data from logs, AI sessions, and text files, including logs with invalid UTF-8 bytes. It combines required Secretlint and 221 bundled Gitleaks text rules with contextual credentials, default multilingual name/PII recognition, known values and bounded encoding detection. Optional local Gitleaks, TruffleHog, and Presidio detectors extend the union.
3
+ An offline JavaScript library, detector framework, and CLI for redacting credentials and personal data from logs, AI sessions, and text files, including logs with invalid UTF-8 bytes. It combines required Secretlint, 221 bundled Gitleaks text rules and 203 further Betterleaks rules with contextual credentials, default multilingual name/PII recognition, known values and bounded encoding detection. Optional local Gitleaks, TruffleHog, and Presidio detectors extend the union.
4
4
 
5
5
  Library calls without a profile replace every actionable finding completely with `[REDACTED]`. Personal findings below the default confidence threshold of 0.5 remain inspectable without replacing ordinary log numbers; credentials always qualify. Publication methods verify full redaction and block output if a required detector fails or finds a residual. Optional typed transformations, compatible partial masks and encoding preservation are described in the API. Findings contain offsets and rule metadata; they omit matched values and source excerpts.
6
6
 
@@ -131,6 +131,7 @@ console.log(result.text); // password=[REDACTED] [REDACTED]
131
131
  printf 'password=short\n' | sensitive-data-sanitizer redact -
132
132
  sensitive-data-sanitizer redact input.txt --in-place
133
133
  sensitive-data-sanitizer scan logs --config /private/policy.json
134
+ sensitive-data-sanitizer scan src/a.js src/b.js --format sarif > report.sarif
134
135
  sensitive-data-sanitizer redact input.txt --gitleaks --trufflehog --output safe.txt
135
136
  sensitive-data-sanitizer redact input.txt --presidio /path/presidio-bridge.py --model en_core_web_sm --language en
136
137
  sensitive-data-sanitizer redact large-session.jsonl --jsonl --stream --output safe-session.jsonl
@@ -148,6 +149,8 @@ sensitive-data-sanitizer --help
148
149
 
149
150
  Defaults: 32 MiB whole-text input bytes, 100,000 findings and 10,000 files. `--stream` permits 1 GiB total with plain-text records up to the total input limit or 8 MiB JSONL records, bounded workers and atomic publication. `--worker-timeout-ms N` sets the worker inactivity/per-batch deadline (default 60000 ms); verified file progress resets the deadline, so large files can run longer overall. `--max-bytes` sets the byte limit; JSON config sets the other sanitizer limits. `--paranoid` adds entropy heuristics and increases false positives. `--native-only` explicitly disables Secretlint.
150
151
 
152
+ `scan` accepts several paths and `--format sarif` emits SARIF 2.1.0 for code-scanning viewers. The repository's `.pre-commit-hooks.yaml` provides `sensitive-data-sanitizer` (credentials) and `sensitive-data-sanitizer-pii` hooks for [pre-commit](https://pre-commit.com). UTF-16 files with a byte order mark are read and written back in their own encoding.
153
+
151
154
  ## Configuration
152
155
 
153
156
  For AI sessions, `--jsonl` preserves JSON records and declares `id`, `type`,
@@ -195,6 +198,8 @@ Keep policies containing real private values outside Git. Built-in exact public
195
198
  }
196
199
  ```
197
200
 
201
+ Reviewed false positives can be kept with `allowList` (whole-value strings or anchored regexes) or, opt-in, with `inlineAllow: true`, which honours `gitleaks:allow`, `pragma: allowlist secret` and similar comments; credentials stay redacted unless `allowCredentialDisable: true` is also set. `minConfidenceByType` sets per-type thresholds, the `mask` transformation accepts `char`, `ignore`, `ignoreClasses`, `count` and `fromEnd`, and `{mode: 'encrypt', key}` produces deterministic AES-256-GCM tokens that `decrypt(text, key)` restores. See [the API](docs/API.md#allow-lists-suppression-comments-and-template-values) and [the competitor comparison](docs/COMPARISON.md).
202
+
198
203
  Custom policies preserve exact reviewed `PERSON`, `ORGANIZATION` and `EMAIL` detections. Built-in public resolvers/documentation IPs are also exempt. Credential detections always win, including a public email used as a password or authorization value. Evidence is an explicit caller decision and does not make every occurrence harmless.
199
204
 
200
205
  ## Extend detection
@@ -218,6 +223,8 @@ const sanitizer = createSanitizer({
218
223
 
219
224
  Install external binaries and language models separately. TruffleHog runs with verification and updates disabled. The Presidio example checks for an installed model and never downloads it at analysis time. Each selected detector is required, including during residual verification. See [the API and adapter contract](docs/API.md), [combined scanners](examples/combined-scanners.mjs), and [the local NER bridge](examples/presidio-bridge.py).
220
225
 
226
+ `createPatternDetector([{type, patterns, denyList, context, validate}])` adds Presidio-style pattern recognizers or Google-style custom infoTypes with hotword boosts, and `createIdentifierDetector` adds national identifiers with checksum validators; see [declarative pattern detectors](docs/API.md#declarative-pattern-detectors).
227
+
221
228
  The framework also accepts Gitleaks, TruffleHog, detect-secrets, Presidio, or explicitly typed offset reports. Cloud reports can be converted locally; the package itself makes no cloud requests.
222
229
 
223
230
  For CLI name recognition, `--presidio PATH` selects a local Python JSON bridge. `--python COMMAND` selects its interpreter, and `--model` / `--language` select an already installed model. These options require the bridge and participate in final-output verification.
@@ -6,9 +6,11 @@ import { createSanitizer } from '../src/engines.js';
6
6
  import { failure } from '../src/detection.js';
7
7
  import {
8
8
  readTextFile,
9
+ readTextInput,
9
10
  atomicWrite,
10
11
  listTextCandidates,
11
- decodeText,
12
+ decodeTextInput,
13
+ encodeTextOutput,
12
14
  } from '../src/files.js';
13
15
  import { auditGitHistory } from '../src/history.js';
14
16
  import { rewriteGitHistory } from '../src/history-rewrite.js';
@@ -17,7 +19,7 @@ import {
17
19
  knownSecretsFromEnv,
18
20
  knownSecretsFromGitHubConfig,
19
21
  } from '../src/known.js';
20
- import { encodeLogText } from '../src/log-bytes.js';
22
+ import { toSarif } from '../src/sarif.js';
21
23
  import { createWikidataVerifier } from '../src/public-verifier.js';
22
24
  import { streamCommand } from './stream.js';
23
25
  import { sessionStructuralFields } from '../src/structured.js';
@@ -31,7 +33,8 @@ import {
31
33
  const help = `Usage: sensitive-data-sanitizer <redact|scan|restore|history> [path|-] [options]
32
34
 
33
35
  redact Sanitize one text log or stdin; publish only after verification
34
- scan Report metadata for a file, directory, or stdin (never source text)
36
+ scan Report metadata for files, directories, or stdin (never source text);
37
+ several paths may follow scan (for example staged files)
35
38
  restore Restore placeholders in a file using --map PATH (private output)
36
39
  history Audit local reachable Git blobs, commit/tag metadata, and reflogs
37
40
  history rewrite SOURCE --output DIRECTORY [--apply]
@@ -67,6 +70,7 @@ history rewrite SOURCE --output DIRECTORY [--apply]
67
70
  --no-map Discard placeholder mapping after full anonymization
68
71
  --placeholder-format TEMPLATE Default {Type}-{n}; also [{TYPE}_{n}]
69
72
  --map-format FORMAT json or lino (otherwise inferred from .lino extension)
73
+ --format FORMAT scan report: json (default) or sarif (SARIF 2.1.0)
70
74
  --map-credentials Explicitly keep credentials in the private restore map
71
75
  --max-record-bytes N Maximum record bytes (text: total limit; JSONL: 8388608)
72
76
  --hive-mask Opt-in first/last 3 mask for values longer than 12 characters
@@ -129,8 +133,9 @@ function parseArgs(argv) {
129
133
  ['--map', 'map'],
130
134
  ['--map-format', 'mapFormat'],
131
135
  ['--placeholder-format', 'placeholderFormat'],
136
+ ['--format', 'format'],
132
137
  ]);
133
- let hasPath = false;
138
+ const paths = [];
134
139
  for (let i = rewrite ? 2 : 1; i < argv.length; i++) {
135
140
  const arg = argv[i];
136
141
  if (boolean.has(arg)) {
@@ -140,13 +145,18 @@ function parseArgs(argv) {
140
145
  throw failure('ERR_ARGUMENT');
141
146
  }
142
147
  config[valued.get(arg)] = argv[++i];
143
- } else if (!hasPath && !arg.startsWith('--')) {
144
- config.path = arg;
145
- hasPath = true;
148
+ } else if (!arg.startsWith('--')) {
149
+ paths.push(arg);
146
150
  } else {
147
151
  throw failure('ERR_ARGUMENT');
148
152
  }
149
153
  }
154
+ // Only scan accepts several paths (for example staged files from a hook).
155
+ if (paths.length > 1 && (command !== 'scan' || paths.includes('-'))) {
156
+ throw failure('ERR_ARGUMENT');
157
+ }
158
+ config.path = paths[0] ?? '-';
159
+ config.paths = paths.length ? paths : ['-'];
150
160
  validateArgs(config);
151
161
  return config;
152
162
  }
@@ -194,7 +204,11 @@ function validateFeatureArgs(config) {
194
204
 
195
205
  function validateStructuredArgs(config) {
196
206
  validateStreamArgs(config);
197
- if (config.twoPass && !config.stream) {
207
+ if (
208
+ (config.twoPass && !config.stream) ||
209
+ (config.format !== undefined &&
210
+ (config.command !== 'scan' || !['json', 'sarif'].includes(config.format)))
211
+ ) {
198
212
  throw failure('ERR_ARGUMENT');
199
213
  }
200
214
  if ((config.json && config.jsonl) || (config.stream && config.json)) {
@@ -249,7 +263,7 @@ async function stdinText(stream, maxBytes, options) {
249
263
  }
250
264
  chunks.push(buffer);
251
265
  }
252
- return decodeText(Buffer.concat(chunks), options);
266
+ return decodeTextInput(Buffer.concat(chunks), options);
253
267
  }
254
268
 
255
269
  export async function runCli(
@@ -266,9 +280,7 @@ export async function runCli(
266
280
  return 0;
267
281
  }
268
282
  if (['--version', '-v'].includes(argv[0])) {
269
- stdout.write(
270
- `${JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version}\n`
271
- );
283
+ stdout.write(`${packageVersion()}\n`);
272
284
  return 0;
273
285
  }
274
286
  const config = parseArgs(argv);
@@ -422,24 +434,20 @@ function inputText(path, config, stdin) {
422
434
  };
423
435
  return path === '-'
424
436
  ? stdinText(stdin, config.maxBytes, options)
425
- : readTextFile(path, config.maxBytes, options);
437
+ : readTextInput(path, config.maxBytes, options);
426
438
  }
427
439
 
428
440
  async function redactCommand(config, engine, { stdin, stdout }) {
429
- const result = await engine.sanitize(
430
- await inputText(config.path, config, stdin)
431
- );
441
+ const input = await inputText(config.path, config, stdin);
442
+ const result = await engine.sanitize(input.text);
432
443
  await saveMap(config);
444
+ const output = encodeTextOutput(result.text, input.encoding);
433
445
  if (config.output || config.inPlace) {
434
- await atomicWrite(
435
- config.output ?? config.path,
436
- encodeLogText(result.text),
437
- {
438
- replace: Boolean(config.inPlace),
439
- }
440
- );
446
+ await atomicWrite(config.output ?? config.path, output, {
447
+ replace: Boolean(config.inPlace),
448
+ });
441
449
  } else {
442
- stdout.write(encodeLogText(result.text));
450
+ stdout.write(output);
443
451
  }
444
452
  return 0;
445
453
  }
@@ -464,8 +472,10 @@ async function historyCommand(config, engine, { stdout }) {
464
472
  }
465
473
 
466
474
  async function scanCommand(config, engine, { stdin, stdout }) {
467
- const files =
468
- config.path === '-' ? ['-'] : await listTextCandidates(config.path);
475
+ const files = [];
476
+ for (const path of config.paths) {
477
+ files.push(...(path === '-' ? ['-'] : await listTextCandidates(path)));
478
+ }
469
479
  const results = [],
470
480
  skipped = [];
471
481
  for (const file of files) {
@@ -473,7 +483,9 @@ async function scanCommand(config, engine, { stdin, stdout }) {
473
483
  try {
474
484
  results.push({
475
485
  path,
476
- findings: await engine.inspect(await inputText(file, config, stdin)),
486
+ findings: await engine.inspect(
487
+ (await inputText(file, config, stdin)).text
488
+ ),
477
489
  });
478
490
  } catch (error) {
479
491
  if (['ERR_BINARY', 'ERR_ENCODING', 'ERR_LIMIT'].includes(error.code)) {
@@ -484,7 +496,10 @@ async function scanCommand(config, engine, { stdin, stdout }) {
484
496
  }
485
497
  }
486
498
  const complete = skipped.length === 0;
487
- stdout.write(`${JSON.stringify({ complete, results, skipped }, null, 2)}\n`);
499
+ const report = { complete, results, skipped };
500
+ stdout.write(
501
+ `${JSON.stringify(config.format === 'sarif' ? toSarif(report, { version: packageVersion() }) : report, null, 2)}\n`
502
+ );
488
503
  return !complete ? 2 : results.some((r) => r.findings.length) ? 1 : 0;
489
504
  }
490
505
 
@@ -495,6 +510,12 @@ if (
495
510
  process.exitCode = await runCli(process.argv.slice(2));
496
511
  }
497
512
 
513
+ function packageVersion() {
514
+ return JSON.parse(
515
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8')
516
+ ).version;
517
+ }
518
+
498
519
  function defaultByteLimit(config) {
499
520
  return config.stream ? 1073741824 : 33554432;
500
521
  }
package/docs/API.md CHANGED
@@ -16,8 +16,12 @@ Import the ESM root entry; complete declarations are in `src/index.d.ts`. Inputs
16
16
  | `createSyncSanitizer(options)` | Reusable synchronous `inspect` / `sanitize` with the same policies |
17
17
  | `compileKnown(...lists)` | Copy, freeze, merge and precompile caller-known personal values |
18
18
  | `auditGitHistory(repository, options)` | Read-only audit of local reachable text objects |
19
+ | `decrypt(text, key, options)` | Restore values from `[ENCRYPTED:TYPE:…]` tokens written by the `encrypt` transformation |
20
+ | `createPatternDetector(specs, opts)` | Declarative regex, deny-list, hotword and validator detector |
21
+ | `createIdentifierDetector(rules)` | Detector for caller-supplied national identifier rules (`nationalRules` shape) |
22
+ | `toSarif(report, options)` | Convert a scan report or findings to SARIF 2.1.0 without values |
19
23
 
20
- `redactions` counts merged replacement spans. `findings` can contain several detector hits for one replacement. Findings have `{start, end, type, category, rule, line, column}`; no text, quote, match, entropy score, provider details, or raw diagnostic is copied. Debugging is off by default; `debug(event)` receives value-free counts, rule timings, residual rounds and registry event metadata (see `DebugEvent` in the TypeScript declarations).
24
+ `redactions` counts merged replacement spans. `findings` can contain several detector hits for one replacement. Findings have `{start, end, type, category, rule, line, column}`; no text, quote, match, entropy score, provider details, or raw diagnostic is copied. The optional `strict-attributes` debug event reports only detected and suppressed match counts. Debugging is off by default; `debug(event)` receives value-free counts, rule timings, residual rounds and registry event metadata (see `DebugEvent` in the TypeScript declarations).
21
25
 
22
26
  ## Options
23
27
 
@@ -242,6 +246,7 @@ Without a profile, full redaction is the library default. `transformation` selec
242
246
  | `fake` | Keyed realistic names, valid dates/MRZ/checksummed numbers and reserved contact destinations |
243
247
  | `date-shift` | Personal ISO dates; finite integer `days`, UTC calendar arithmetic; invalid dates become opaque |
244
248
  | `bucket` | Personal safe integers; positive integer `size`, inclusive numeric interval |
249
+ | `encrypt` | Personal data only; deterministic AES-256-GCM token reversible with `decrypt` and the key (see below) |
245
250
 
246
251
  All keyed modes require a key of at least 16 UTF-8 bytes. `format-preserving` is a non-reversible shape pseudonym, not NIST FF1 encryption; it does not preserve checksum validity, country-specific alphabets or guarantee uniqueness. Partial masks, date shifts and buckets intentionally retain information. Keep pseudonym keys outside logs. Residual verification checks the corresponding fully redacted representation; opted-in disclosures can remain recognizable in the transformed output. Use default redaction for publication that requires opaque output.
247
252
 
@@ -368,7 +373,7 @@ The optional ESLint plugin exports `require-sanitized-output` from the package's
368
373
 
369
374
  ## Native documented entity catalogs
370
375
 
371
- `entityCatalogs` exposes source URLs, source-page SHA256 and documented names for Google (213), Azure (176) and AWS (36). Their canonical uppercase underscore labels are recognized natively, with complete value spans, independent of cloud services. The package also implements selected automatic formats and checksum algorithms. Catalog vocabulary support does not reproduce proprietary NER, image/document inspection, regional identity validators, cloud risk analysis or their accuracy. See the coverage table for that distinction.
376
+ `entityCatalogs` exposes source URLs, source-page SHA256 and documented names for Google (214), Azure (180) and AWS (36). Their canonical uppercase underscore labels are recognized natively, with complete value spans, independent of cloud services. The package also implements selected automatic formats and checksum algorithms. Catalog vocabulary support does not reproduce proprietary NER, image/document inspection, regional identity validators, cloud risk analysis or their accuracy. See the coverage table for that distinction.
372
377
 
373
378
  ## Two-pass session publication
374
379
 
@@ -742,3 +747,189 @@ one or both languages. Each invocation is limited to 1 Mi UTF-16 units; larger
742
747
  inputs throw `ERR_LIMIT`. `debug` is off by default and emits counts only. Core
743
748
  imports never load the optional entry or NLP.js; leaving it off adds no optional
744
749
  runtime/module cost.
750
+
751
+ ## Allow lists, suppression comments and template values
752
+
753
+ `allowList` keeps detected values that the caller has reviewed as safe, as in
754
+ Presidio's `allow_list`, Google's exclusion rules with
755
+ `MATCHING_TYPE_FULL_MATCH` and DataFog's `allowlist_patterns`. A string must
756
+ equal the whole detected value; a `RegExp` must match the whole value (it is
757
+ anchored internally, `g`/`y` flags are ignored). A partial match never shortens
758
+ a finding. At most 10 000 entries; anything else is `ERR_CONFIG`.
759
+
760
+ ```js
761
+ sanitize('Contact support@example.org or alice@private.test', {
762
+ allowList: ['support@example.org', /.+@example\.org/],
763
+ });
764
+ // → 'Contact support@example.org or [REDACTED]'
765
+ ```
766
+
767
+ `inlineAllow: true` honours the suppression comments of other scanners on the
768
+ same line: `gitleaks:allow`, `betterleaks:allow`, `kingfisher:allow`/`ignore`,
769
+ `trufflehog:ignore`, `sanitizer:allow`, detect-secrets/ripsecrets
770
+ `pragma: allowlist secret` and SonarQube `NOSONAR`. A line containing
771
+ `pragma: allowlist nextline secret` suppresses findings on the next line. It is
772
+ off by default because anyone who can write to a log could otherwise disable
773
+ redaction.
774
+
775
+ Both controls follow the same safety rules:
776
+
777
+ - declared `knownPersonal`/`knownSecrets` values, confirmed personal values and
778
+ structured `JSON_VALUE` denials are never exempted;
779
+ - credentials are exempted only together with `allowCredentialDisable: true`;
780
+ - they apply to text, structured and asynchronous paths alike.
781
+
782
+ Built-in credential rules also skip values that consist only of a template or
783
+ mask: `${PASSWORD}`, `$${PYPI_PASSWORD}`, `$DB_PASS`, `$1`, `<password>`,
784
+ `<Openverse API token>`, `[PASSWORD]`, `****`, `XXXXXXXX`, `...`, optionally
785
+ after `Bearer`/`Basic`/`token` and on either side of a `user:password` colon.
786
+ A mixed value such as `alice:${DB_PASSWORD}` or `<x>abc123…` is still redacted.
787
+ Findings from custom detectors are never dropped this way. The value-free debug
788
+ event `{event: 'template-value', rule}` reports each skipped match.
789
+
790
+ ## Per-type thresholds
791
+
792
+ `minConfidenceByType` maps finding types to a threshold that overrides
793
+ `minConfidence` for those types only, as in Presidio score thresholds or
794
+ Google's per-infoType likelihood:
795
+
796
+ ```js
797
+ sanitize(text, { minConfidence: 0.5, minConfidenceByType: { PERSON: 0.8 } });
798
+ ```
799
+
800
+ Values must be numbers from 0 to 1. Credentials are never filtered by a
801
+ threshold.
802
+
803
+ ## Character masks
804
+
805
+ The `mask` transformation accepts Presidio/Google-style tuning in addition to
806
+ `keepStart`, `keepEnd`, `minLength` and `marker`:
807
+
808
+ | Option | Meaning |
809
+ | --------------- | -------------------------------------------------------------- |
810
+ | `char` | One masking character (default `*`) |
811
+ | `ignore` | Characters that are never masked, e.g. `'-@.'` |
812
+ | `ignoreClasses` | Any of `digits`, `upper`, `lower`, `punctuation`, `whitespace` |
813
+ | `count` | Mask at most this many characters |
814
+ | `fromEnd` | Mask the last `count` characters instead of the first |
815
+
816
+ ```js
817
+ sanitize('Card 4111-1111-1111-1111', {
818
+ transformation: { mode: 'mask', char: '#', ignore: '-', count: 12 },
819
+ });
820
+ // → 'Card ####-####-####-1111'
821
+ ```
822
+
823
+ `marker` cannot be combined with the tuning options. A value with nothing left
824
+ to mask is fully redacted. Masks apply to personal data only.
825
+
826
+ ## Deterministic encryption
827
+
828
+ `{mode: 'encrypt', key}` replaces each personal value with
829
+ `[ENCRYPTED:TYPE:…]`, comparable to Google's `CryptoDeterministicConfig` and
830
+ Presidio's `encrypt` operator. The token is AES-256-GCM with a synthetic IV
831
+ (HMAC of type and value), so equal values of one type produce equal tokens and
832
+ joins or counts still work. Keys are at least 16 UTF-8 bytes and are expanded
833
+ with HKDF-SHA256.
834
+
835
+ ```js
836
+ import { sanitize, decrypt } from '@link-foundation/sensitive-data-sanitizer';
837
+ const key = process.env.SANITIZER_KEY;
838
+ const { text } = sanitize('Mail alice@private.test', {
839
+ transformation: { mode: 'encrypt', key },
840
+ });
841
+ decrypt(text, key); // → 'Mail alice@private.test'
842
+ ```
843
+
844
+ `decrypt(text, key, {json: true})` escapes restored values inside JSON string
845
+ literals. A token that fails authentication throws `ERR_MAP`; a non-string text
846
+ throws `ERR_INPUT`. Credentials are still fully redacted and never encrypted.
847
+ Anyone holding the key can recover the values, so treat encrypted output as
848
+ pseudonymised, not anonymous.
849
+
850
+ ## Declarative pattern detectors
851
+
852
+ `createPatternDetector(specs, {id})` builds a synchronous Detector from
853
+ Presidio-style pattern recognizers or Google custom infoTypes without writing
854
+ code:
855
+
856
+ ```js
857
+ import {
858
+ createPatternDetector,
859
+ sanitize,
860
+ } from '@link-foundation/sensitive-data-sanitizer';
861
+ const detector = createPatternDetector([
862
+ {
863
+ type: 'MEDICAL_RECORD',
864
+ category: 'personal',
865
+ patterns: [{ regex: /\bMRN-\d{6}\b/g, confidence: 0.4 }],
866
+ denyList: ['Mr. Bean'],
867
+ context: ['mrn', 'medical record'],
868
+ contextWindow: { before: 64, after: 16 },
869
+ contextBoost: 0.35,
870
+ validate: (value) => value !== 'MRN-000000',
871
+ },
872
+ ]);
873
+ sanitize('medical record MRN-123456', { detectors: [detector] });
874
+ ```
875
+
876
+ | Field | Meaning |
877
+ | ------------------ | ---------------------------------------------------------------------------------------------- |
878
+ | `type`, `category` | Uppercase type; `personal` or `credential` |
879
+ | `rule` | Lowercase rule id (default `custom-pattern`) |
880
+ | `confidence` | Base confidence (default 0.6) |
881
+ | `patterns` | Up to 64 `RegExp`, strings or `{regex, confidence, group}` (a capture group narrows the span) |
882
+ | `denyList` | Whole-word values, case-insensitive unless `caseSensitive: true`; `denyListConfidence` (1) |
883
+ | `context` | Hotwords (strings or RegExp) searched in `contextWindow` (number or `{before, after}`, ≤ 4096) |
884
+ | `contextBoost` | Added when a hotword is present (default 0.35, floor 0.4); a negative value lowers confidence |
885
+ | `validate` | `false` drops a match; a number sets its confidence |
886
+
887
+ Malformed specifications throw `ERR_CONFIG`.
888
+
889
+ `createIdentifierDetector(rules, {id})` adapts rules with the shape of the
890
+ exported `nationalRules` table (`{type, pattern, context, checksum, plausible?,
891
+ valid?}`) so callers can add or replace national identifiers. `identifierChecks`
892
+ exposes the checksum and format validators (Luhn, mod-11, mod-97, Verhoeff and
893
+ the national algorithms) for reuse; a valid checksum never proves issuance.
894
+
895
+ ## Offline token checksums
896
+
897
+ GitHub (`ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_`) and npm (`npm_`) tokens end in
898
+ six base62 characters holding the CRC32 of the 30 characters after the first
899
+ `_`. As in Kingfisher and osv-scalibr, the native `github`/`npm` rules and the
900
+ vendored Gitleaks/Betterleaks rules give a checksum-valid token confidence
901
+ 0.99. A token whose checksum does not match keeps its ordinary confidence and
902
+ is still redacted, because truncated or retyped tokens remain sensitive. No
903
+ network request is made. The exported `tokenChecksum(value)` returns `true`,
904
+ `false` or `undefined` (not a checksummed format), and
905
+ `tokenWithChecksum(prefix, body)` builds checksum-valid test fixtures at
906
+ runtime so repositories need not store token literals.
907
+
908
+ ## SARIF reports
909
+
910
+ `toSarif(report, {version, path})` converts a CLI `scan` report, an array of
911
+ `{path, findings}` or a findings array into SARIF 2.1.0 for GitHub code
912
+ scanning and other CI viewers. Rules are keyed by `finding.rule`; credential
913
+ findings are `error` and personal findings `warning`. Regions carry
914
+ `charOffset`, `charLength`, `startLine` and `startColumn` in UTF-16 code units.
915
+ Skipped files become tool execution notifications. Values are never included.
916
+
917
+ ```sh
918
+ sensitive-data-sanitizer scan --format sarif src > results.sarif
919
+ ```
920
+
921
+ `--format` accepts `json` (default) or `sarif` and is only valid for `scan`.
922
+
923
+ CLI `scan` accepts several paths, so a CI step or a
924
+ [pre-commit](https://pre-commit.com) hook can pass the staged files directly.
925
+ The repository ships `.pre-commit-hooks.yaml` with two hooks:
926
+ `sensitive-data-sanitizer` (credentials only) and
927
+ `sensitive-data-sanitizer-pii` (credentials and personal data).
928
+
929
+ ```yaml
930
+ repos:
931
+ - repo: https://github.com/link-foundation/sensitive-data-sanitizer
932
+ rev: v0.20.0
933
+ hooks:
934
+ - id: sensitive-data-sanitizer
935
+ ```
package/docs/COVERAGE.md CHANGED
@@ -6,7 +6,7 @@ Coverage is a union of maintained detectors, explicit known values, context and
6
6
 
7
7
  The native provider supplements in `src/rules.js` cover GitHub, GitLab, OpenAI, Anthropic, AWS access IDs, Google API/OAuth, Slack, Stripe, SendGrid, Twilio, npm, PyPI, Telegram, Discord, Hugging Face, Shopify, Databricks, Square, Mailchimp, DigitalOcean, Vault, Grafana, Linear, Notion, Vercel, and JWT-shaped values. Prefix matches also protect selected truncated tokens; they do not validate a token with a provider.
8
8
 
9
- The default native engine bundles 221 text rules from Gitleaks 8.30.1 (222 upstream rules; the path-only PKCS12 rule is outside text detection), compiled unchanged with RE2/WASM or RE2JS for inputs above 4 MiB. Dense rule matches switch to RE2JS after at most eight WASM matches per view, bounding repeated whole-input copies without truncating secrets or changing anchors. The MIT license, commit and sync script are retained in `src/vendor/gitleaks`. Keyword prefilters and configured entropy thresholds run natively; upstream comment/path allowlists cannot suppress a sanitizer finding. Quoted hex/base64 entropy detection supplements those rules. The default asynchronous engine additionally uses the pinned Secretlint recommended scanners, excluding its comment-filter rule. Embedded disable directives in untrusted text cannot suppress findings. Optional Gitleaks and TruffleHog invoke their installed detector sets, and custom detectors can add formats. Service counts are not a measure of comparative recall.
9
+ The default native engine bundles 221 text rules from Gitleaks 8.30.1 (222 upstream rules; the path-only PKCS12 rule is outside text detection), compiled unchanged with RE2/WASM or RE2JS for inputs above 1 MiB (re2-wasm has a fixed 16 MiB heap shared by every compiled rule and copied subject). Dense rule matches switch to RE2JS after at most eight WASM matches per view, bounding repeated whole-input copies without truncating secrets or changing anchors. The MIT license, commit and sync script are retained in `src/vendor/gitleaks`. Because Gitleaks is no longer updated, 203 further rules from its maintained successor Betterleaks 1.10.0 (ids absent from the Gitleaks set, such as Groq, Cerebras, xAI, Vercel, Supabase and Cloudflare v2 tokens) run the same way as `betterleaks/<id>` findings, with their MIT license, provenance and `scripts/sync-betterleaks.py` in `src/vendor/betterleaks`. Their entropy floors and secret-only `matchesAny`/`containsAny` filters are honoured; BPE token-ratio and nearby-line filters, live validation, validation-only components and the context-dependent `generic-*` rules are not, so these rules can only report more than Betterleaks, not less. Keyword prefilters and configured entropy thresholds run natively; upstream comment/path allowlists cannot suppress a sanitizer finding. Quoted hex/base64 entropy detection supplements those rules. The default asynchronous engine additionally uses the pinned Secretlint recommended scanners, excluding its comment-filter rule. Embedded disable directives in untrusted text cannot suppress findings. Optional Gitleaks and TruffleHog invoke their installed detector sets, and custom detectors can add formats. Service counts are not a measure of comparative recall.
10
10
 
11
11
  Context rules recognize credential labels with bounded key affixes, JSON and nested escaped JSON, YAML/env/INI assignments, shell flags, XML values, URL userinfo, authorization headers, cookies, webhook paths, and complete or truncated private-key PEM blocks. Low-entropy contextual values such as `password=1234` are protected. Generic token counters, booleans/null/language literals and ordinary Git hashes are excluded to retain useful logs. Supply literal known values for ambiguous credentials or unusual key names.
12
12
 
@@ -185,7 +185,7 @@ records standards, alternatives and regression coverage for each feature.
185
185
  | Local subprocess | Default 30 seconds and 10 MiB report/stdout cap; failures block output |
186
186
  | Git objects | 10,000 objects, 10 MiB per object and 100 MiB total by default |
187
187
 
188
- Supported decoding includes canonical base64/base64url, line-wrapped base64, hex, percent, HTML entities, UTF-8 byte escapes and JSON string escapes, with fatal UTF-8 checks for decoded payloads. Plain log files and streams preserve invalid UTF-8 bytes reversibly while scanning surrounding valid text; structured JSON/JSONL and configuration remain strict. Opt-in encoding preservation round-trips sanitized payloads; nested escaped JSON retains its structure by default. Arbitrarily split fragments, unlimited nesting, compressed/encrypted payloads, malformed encodings, archives, PDFs and OCR/images are outside this text profile. Whole-input/candidate limits fail; oversized textual percent/base64 runs receive audited complete-run redaction. Unsupported representations may remain undetected. `decode: false` intentionally reduces coverage. A caller needing another format should decode it through a bounded preprocessing adapter and retain correct original spans.
188
+ Supported decoding includes canonical base64/base64url, line-wrapped base64, hex, percent, HTML entities, UTF-8 byte escapes and JSON string escapes, with fatal UTF-8 checks for decoded payloads. The detection view additionally decodes `\uXXXX`, `\u{X}`, `\x{X}`, `\UXXXXXXXX` and `%uXXXX` escapes, runs of four or more `U+XXXX` code points, CSS escape runs (`\74 \6f \6b`), drops NUL bytes of UTF-16 text read as single bytes, zero-width and bidirectional format characters and ANSI colour sequences, removes inline HTML formatting tags (`<em>`, `<span class=…>`) and turns block tags into line breaks, while findings keep their original offsets. A finding inside a `U+` or CSS escape run redacts the whole run, so no undecodable fragment of it remains. The CLI `redact` and `scan` commands also read UTF-16LE/BE files that start with a byte order mark and write redacted output back in the same encoding; BOM-less UTF-16 and stream input must be UTF-8. Plain log files and streams preserve invalid UTF-8 bytes reversibly while scanning surrounding valid text; structured JSON/JSONL and configuration remain strict. Opt-in encoding preservation round-trips sanitized payloads; nested escaped JSON retains its structure by default. Arbitrarily split fragments, unlimited nesting, compressed/encrypted payloads, malformed encodings, archives, PDFs and OCR/images are outside this text profile. Whole-input/candidate limits fail; oversized textual percent/base64 runs receive audited complete-run redaction. Unsupported representations may remain undetected. `decode: false` intentionally reduces coverage. A caller needing another format should decode it through a bounded preprocessing adapter and retain correct original spans.
189
189
 
190
190
  Default text mode can replace JSON numeric values with string markers and change syntax/types. Opt-in structured JSON/JSONL mode scans string keys/values separately, preserves primitive types, and rejects malformed output or key collisions. Numeric personal values need a separate caller schema policy. Existing quoted JSON strings and escaped-string fixtures retain their delimiters. Review sanitized artifacts for their intended consumer.
191
191
 
@@ -193,7 +193,7 @@ An unchanged result is not proof that text contains no sensitive information. Th
193
193
 
194
194
  ## Entity catalogs and optional modes
195
195
 
196
- Documented Google/Azure/AWS catalog names (213/176/36) are recognized as explicit native labels, including canonical underscore and original camel-case forms. Generic DATE is excluded so ordinary headers/calendar dates remain intact; personal birth-date context still qualifies. This vocabulary is distinct from automatic vendor NLP/image/cloud-service features. Typed transformations and confidence are described in [API.md](API.md); full redaction remains the default. Public Wikidata names, known role/domain emails and public resolver/documentation IPs use explicit offline knowledge and heuristics. Opt-in network lookup does not establish identity; private overrides and credential priority remain authoritative.
196
+ Documented Google/Azure/AWS catalog names (214/180/36) are recognized as explicit native labels, including canonical underscore and original camel-case forms. Generic DATE is excluded so ordinary headers/calendar dates remain intact; personal birth-date context still qualifies. This vocabulary is distinct from automatic vendor NLP/image/cloud-service features. Typed transformations and confidence are described in [API.md](API.md); full redaction remains the default. Public Wikidata names, known role/domain emails and public resolver/documentation IPs use explicit offline knowledge and heuristics. Opt-in network lookup does not establish identity; private overrides and credential priority remain authoritative.
197
197
 
198
198
  Large text logs use bounded record streaming and worker file publication. Plain-text records inherit the configurable total limit (1 GiB by default), so memory grows with the largest held record. The whole-string default is 32 Mi UTF-16 units. `credentialsOnly: true` preserves personal/account details across all finding sources while retaining credential detection and verification. The review corpus benchmark is a finite, synthetic regression comparison; [method, settings and results](../experiments/benchmark/README.md) explain its scope. It does not demonstrate general market-wide superiority.
199
199
 
@@ -476,7 +476,25 @@ proper-name mode. Romashka Inc keeps its existing legal-form treatment.
476
476
  `strict: true` independently opts into personal attributes (gender, family,
477
477
  ethnicity, religion, health, criminal/political status) and bare dates, ages,
478
478
  passport/mobile/postal shapes. It is intended for stored personal prose and can
479
- remove ordinary uses of these words. It preserves ISO timestamps, work durations
479
+ remove ordinary uses of unambiguous category words. Bounded Russian case/stem
480
+ rules cover disability, ethnicity, religion, diagnoses, intimate life and criminal
481
+ status, including `инвалидом`, `армянин`, `еврейка`, `казах`, `христианин`, `иудей`,
482
+ `эпилепсией`, `астма`, `онкологию`, `туберкулез`, `психиатра`, `депрессия`,
483
+ `гепатит`, `гей` and `отбывал наказание`. English coverage includes `epilepsy`,
484
+ `gay` and `Armenian`. Explicit category cues such as `по национальности X`,
485
+ `исповедует X`, `болеет X`, `на учете у X`, `diagnosed with X` and `ethnicity: X`
486
+ protect one following word (including hyphenated words) up to 64 characters,
487
+ including unfamiliar values outside the lexicon. These cues stay on one line.
488
+ Ambiguous English `disabled`, `single`, `man`, `men`, `russian`
489
+ and `asian` require an adjacent personal cue (pronoun/person role, personal
490
+ label, or following person role). Thus `he is disabled`, `candidate is single`
491
+ and `gender: male` redact, while `the module is disabled`, `single run finished`,
492
+ `single-flight lock acquired`, `man page missing`, `locale russian selected`
493
+ and `asian region endpoint` remain intact. Bare operational JSON statuses
494
+ `disabled`, `single` and `Russian` also remain intact; explicit personal labels
495
+ and caller-declared known personal values still redact. A cue in another clause
496
+ or string value does not make an ambiguous word personal.
497
+ It preserves ISO timestamps, work durations
480
498
  (including `Стаж: 5 лет`, `работал там 5 лет`, and duration cues from issue 87),
481
499
  and parenthesized durations after known technologies such as `Kubernetes (5 лет)`
482
500
  and `Power BI (5 лет)`.