euredact 0.7.0 → 0.7.1

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/cjs/sdk.js CHANGED
@@ -38,6 +38,7 @@ exports.applyReplacements = applyReplacements;
38
38
  exports.maskForCloud = maskForCloud;
39
39
  exports.ontoOriginal = ontoOriginal;
40
40
  exports.restore = restore;
41
+ exports.normalizeExcludeTypes = normalizeExcludeTypes;
41
42
  const platform_js_1 = require("./platform.js");
42
43
  const cache_js_1 = require("./cache.js");
43
44
  const normalizer_js_1 = require("./normalizer.js");
@@ -394,6 +395,70 @@ function applyAllowlist(text, detections, allowed, domains) {
394
395
  }
395
396
  return [kept, exempted];
396
397
  }
398
+ const KNOWN_TYPES = new Set(Object.values(types_js_1.EntityType));
399
+ /**
400
+ * The entity types a caller asked to leave in the text, by canonical name.
401
+ *
402
+ * Names are matched case-insensitively and legacy names resolve (`IBAN` is
403
+ * `BANK_ACCOUNT`, `CREDENTIAL` is `SECRET`). A custom pattern's name counts
404
+ * once it is registered. Anything else throws: a typo in a type meant to be
405
+ * switched off would otherwise switch nothing off (rules-engine#125).
406
+ */
407
+ function normalizeExcludeTypes(values, customNames = new Set(), param = "excludeTypes") {
408
+ const out = new Set();
409
+ if (values === null || values === undefined)
410
+ return out;
411
+ checkAllowlistArg(values, param);
412
+ for (const v of values) {
413
+ if (typeof v !== "string") {
414
+ throw new TypeError(`${param} entries must be type names, not ${typeof v}`);
415
+ }
416
+ if (customNames.has(v)) {
417
+ out.add(v);
418
+ continue;
419
+ }
420
+ const name = (0, types_js_1.canonicalType)(v);
421
+ if (!KNOWN_TYPES.has(name)) {
422
+ throw new Error(`unknown entity type ${JSON.stringify(v)} in ${param}. Known types: ` +
423
+ [...KNOWN_TYPES].sort().join(", ") +
424
+ (customNames.size ? `; custom patterns: ${[...customNames].sort().join(", ")}` : ""));
425
+ }
426
+ out.add(name);
427
+ }
428
+ return out;
429
+ }
430
+ /**
431
+ * Split off the detections of the types the caller excluded. Applied to each
432
+ * span's final type, after ranking: dropping an excluded type's candidates
433
+ * earlier would let the next-best type claim the same span.
434
+ */
435
+ function applyTypeExclusion(text, detections, excluded) {
436
+ if (excluded.size === 0)
437
+ return [detections, []];
438
+ const kept = [];
439
+ const exempted = [];
440
+ for (const d of detections) {
441
+ const name = String(d.entityType);
442
+ if (excluded.has(name)) {
443
+ exempted.push({
444
+ entityType: d.entityType, start: d.start, end: d.end,
445
+ text: text.slice(d.start, d.end), rule: name, ruleKind: "type",
446
+ });
447
+ }
448
+ else
449
+ kept.push(d);
450
+ }
451
+ return [kept, exempted];
452
+ }
453
+ /** Allowlist first, then excluded types; every exemption in text order. */
454
+ function exempt(text, detections, allowed, domains, excluded) {
455
+ const [afterValues, byValue] = applyAllowlist(text, detections, allowed, domains);
456
+ const [kept, byType] = applyTypeExclusion(text, afterValues, excluded);
457
+ const exempted = [...byValue, ...byType];
458
+ if (byValue.length && byType.length)
459
+ exempted.sort((a, b) => a.start - b.start || b.end - a.end);
460
+ return [kept, exempted];
461
+ }
397
462
  /** Two label schemes for the same spans cannot both apply. */
398
463
  function checkLabelOptions(options) {
399
464
  if (options.tokenize && options.referentialIntegrity) {
@@ -474,19 +539,26 @@ class EuRedact {
474
539
  maxInputLength;
475
540
  allowlist;
476
541
  allowlistDomains;
542
+ customNames = new Set();
543
+ excludeTypes;
477
544
  /**
478
545
  * @param options.maxInputLength Longest document `redact` accepts, in characters.
479
546
  * @param options.allowlist Values never to redact, for every call on this
480
547
  * instance — an organisation's own name, its own addresses. Merged with
481
548
  * the per-call `allowlist`. Matched whole, case-insensitively.
549
+ * @param options.excludeTypes Entity types never reported or masked, for
550
+ * every call on this instance, e.g. `["JOB_TITLE", "FINANCIAL_AMOUNT"]`.
551
+ * Merged with the per-call value. Unknown names throw.
482
552
  */
483
553
  constructor(options) {
484
554
  this.maxInputLength = options?.maxInputLength ?? DEFAULT_MAX_INPUT_LENGTH;
485
555
  this.allowlist = normalizeAllowlist(options?.allowlist);
486
556
  this.allowlistDomains = normalizeDomains(options?.allowlistDomains);
557
+ this.excludeTypes = normalizeExcludeTypes(options?.excludeTypes);
487
558
  }
488
559
  addCustomPattern(name, pattern) {
489
560
  this.engine.addCustomPattern(name, pattern);
561
+ this.customNames.add(name);
490
562
  this.cache.clear();
491
563
  }
492
564
  /** Clear the result cache and referential integrity mappings.
@@ -502,6 +574,13 @@ class EuRedact {
502
574
  allowed.set(k, v);
503
575
  return allowed;
504
576
  }
577
+ /** The instance's excluded types merged with a call's. */
578
+ excludedFor(options) {
579
+ const excluded = new Set(this.excludeTypes);
580
+ for (const t of normalizeExcludeTypes(options.excludeTypes, this.customNames))
581
+ excluded.add(t);
582
+ return excluded;
583
+ }
505
584
  /** The instance domain list merged with a call's. */
506
585
  domainsFor(options) {
507
586
  const domains = new Map(this.allowlistDomains);
@@ -558,14 +637,16 @@ class EuRedact {
558
637
  }
559
638
  const allowed = this.allowedFor(options);
560
639
  const domains = this.domainsFor(options);
640
+ const excluded = this.excludedFor(options);
561
641
  const client = new CloudClient();
562
642
  // Dates are always on, whatever detectDates says: that is the rules output
563
643
  // the model was trained against, and a date of birth the rules can place
564
644
  // has no reason to travel. It is the one ignored option that is safe to
565
645
  // ignore — it can only cause MORE to be detected, never less.
566
646
  //
567
- // No allowlist and no tokens on this pass. An exemption says what the
568
- // caller wants back, not what may leave; both are applied below, after the
647
+ // No allowlist, no excluded types and no tokens on this pass. An exemption
648
+ // says what the caller wants back, not what may leave: an excluded EMAIL is
649
+ // still masked in the request. All three are applied below, after the
569
650
  // response, to the merged spans.
570
651
  const local = this.redactRules(text, { countries, detectDates: true, cache: options.cache }, new Map(), new Map());
571
652
  const [masked, labels] = maskForCloud(text, local.detections);
@@ -575,7 +656,7 @@ class EuRedact {
575
656
  country: countries[0], idempotencyKey: options.idempotencyKey,
576
657
  });
577
658
  // A new array throughout: `local` may be the cached rules result.
578
- const [detections, exempted] = applyAllowlist(text, [...local.detections, ...ontoOriginal(remote.detections, masked, text, labels, CloudError)], allowed, domains);
659
+ const [detections, exempted] = exempt(text, [...local.detections, ...ontoOriginal(remote.detections, masked, text, labels, CloudError)], allowed, domains, excluded);
579
660
  detections.sort((a, b) => a.start - b.start || b.end - a.end);
580
661
  const tokenMapper = options.tokenize ? new TokenMapper(text, detections) : null;
581
662
  return {
@@ -594,6 +675,7 @@ class EuRedact {
594
675
  checkLabelOptions(options);
595
676
  const allowed = this.allowedFor(options);
596
677
  const domains = this.domainsFor(options);
678
+ const excluded = this.excludedFor(options);
597
679
  const requestedMode = options.mode ?? "rules";
598
680
  if (requestedMode === "cloud") {
599
681
  // Cannot be served synchronously: the cloud tier is a network call, and
@@ -610,10 +692,10 @@ class EuRedact {
610
692
  if (options.idempotencyKey !== undefined) {
611
693
  throw new Error('idempotencyKey applies to mode: "cloud" only: a rules-only redaction makes no request to deduplicate');
612
694
  }
613
- return this.redactRules(text, options, allowed, domains);
695
+ return this.redactRules(text, options, allowed, domains, excluded);
614
696
  }
615
697
  /** The local pipeline: normalise, detect, exempt, mask. */
616
- redactRules(text, options, allowed, domains) {
698
+ redactRules(text, options, allowed, domains, excluded = new Set()) {
617
699
  if (text.length > this.maxInputLength) {
618
700
  throw new Error(`Input text length (${text.length.toLocaleString()} chars) exceeds the maximum ` +
619
701
  `(${this.maxInputLength.toLocaleString()} chars). Split the input or ` +
@@ -636,7 +718,8 @@ class EuRedact {
636
718
  // JSON rather than a joined string: an entry may itself contain the
637
719
  // separator, and two different lists must never share a key.
638
720
  const allowKey = (allowed.size ? JSON.stringify([...allowed.keys()].sort()) : "")
639
- + (domains.size ? "|dom=" + JSON.stringify([...domains.keys()].sort()) : "");
721
+ + (domains.size ? "|dom=" + JSON.stringify([...domains.keys()].sort()) : "")
722
+ + (excluded.size ? "|excl=" + JSON.stringify([...excluded].sort()) : "");
640
723
  const cacheMode = `rules|dates=${detectDates}|hint=${hintKey}|ri=${referentialIntegrity}|tok=${tokenize}|allow=${allowKey}`;
641
724
  let cacheKey;
642
725
  if (cache) {
@@ -660,7 +743,7 @@ class EuRedact {
660
743
  detections = detections.filter(d => !DATE_TYPES.has(d.entityType));
661
744
  }
662
745
  let exempted;
663
- [detections, exempted] = applyAllowlist(text, detections, allowed, domains);
746
+ [detections, exempted] = exempt(text, detections, allowed, domains, excluded);
664
747
  detections.sort((a, b) => a.start - b.start || b.end - a.end);
665
748
  const tokenMapper = tokenize ? new TokenMapper(text, detections) : null;
666
749
  const redacted = applyReplacements(text, detections, this.labelFor(referentialIntegrity, tokenMapper));
@@ -1,6 +1,11 @@
1
1
  import { EntityType } from "../../types.js";
2
2
  import { IBAN_LENGTHS } from "../validators.js";
3
3
  import { MONTH_NAMES } from "../suppressors.js";
4
+ /** An assigned value: not opened by a quote or bracket, eight characters or
5
+ * more, not ended by punctuation or a closer (rules-engine#35, #126). */
6
+ const ASSIGNED = String.raw `[^\s"'\x60\[\]{}()<>„“”«»‚‘’]\S{6,}[^\s.,;:!?)'"\]}\x60“”»‘’]`;
7
+ /** A quoted assigned value: exactly what the quotes hold (rules-engine#126). */
8
+ const QUOTED_ASSIGNED = ['"', "'", "\x60"].flatMap((q) => [" ", ""].map((gap) => String.raw `(?<=[:=]` + gap + q + String.raw `)[^\s` + q + String.raw `]{8,}(?=` + q + ")")).join("|");
4
9
  /** Day, month as a word, year ("4. März 1948"), behind a date label (rules-engine#115). */
5
10
  function writtenDatePattern() {
6
11
  const names = new Set(MONTH_NAMES.split("|").filter(Boolean));
@@ -143,6 +148,7 @@ const DOB_CONTEXT = [
143
148
  "γεννηθείς", "γεννήθηκε", "ημερομηνία γέννησης",
144
149
  // West Slavic.
145
150
  "urodzony", "urodzona", "data urodzenia",
151
+ "ur.",
146
152
  "narozen", "datum narození", "narodený", "dátum narodenia",
147
153
  // Hungarian, Romanian, Bulgarian.
148
154
  "született", "születési", "născut", "născută", "data nașterii",
@@ -265,14 +271,20 @@ const SHARED = {
265
271
  // AWS Secret Access Key (40 chars, base64 with +/ after an AKIA line)
266
272
  p(EntityType.SECRET, String.raw `(?<=aws_secret_access_key\s{0,4}[:=]\s{0,4})[A-Za-z0-9+/]{40}`, null, "AWS Secret Access Key"),
267
273
  // --- Secret / API Key (connection strings with embedded credentials) ---
268
- p(EntityType.SECRET, String.raw `(?:mongodb|mysql|postgres(?:ql)?|redis|amqp|rabbitmq):\/\/[^\s:]{1,256}:[^\s@]{1,256}@[^\s]+`, null, "Connection string with credentials"),
274
+ // The tail stops at a quote (rules-engine#126).
275
+ p(EntityType.SECRET, String.raw `(?:mongodb|mysql|postgres(?:ql)?|redis|amqp|rabbitmq):\/\/[^\s:]{1,256}:[^\s@]{1,256}@[^\s"'\x60]+`, null, "Connection string with credentials"),
269
276
  // --- Secret / API Key (assignment-based: KEY=value or KEY: value) ---
270
277
  // The final character may not be sentence punctuation. `\S` does not stop
271
278
  // at a full stop, so "Reisepass: CA1234567." was claimed as the
272
279
  // ten-character span "CA1234567." -- a passport number plus the sentence's
273
280
  // period, masked as a credential because a "credentials" word sat within
274
281
  // the context window (rules-engine#35).
275
- p(EntityType.SECRET, String.raw `(?<=[:=] )\S{7,}[^\s.,;:!?)'"]|(?<=[:=])\S{7,}[^\s.,;:!?)'"]`, "high_entropy", "Assigned secret value", SECRET_CONTEXT, true),
282
+ // The value starts after an opening quote or bracket and ends before its
283
+ // closer: `user = "svc_terraform"` was the span `"svc_terraform`
284
+ // (rules-engine#126).
285
+ // A quoted value is what the quotes hold, closing punctuation included.
286
+ p(EntityType.SECRET, QUOTED_ASSIGNED + "|" + [String.raw `(?<=[:=] )`, String.raw `(?<=[:=])`, String.raw `(?<=[:=] ["'\x60\[{(])`, String.raw `(?<=[:=]["'\x60\[{(])`]
287
+ .map((lb) => lb + ASSIGNED).join("|"), "high_entropy", "Assigned secret value", SECRET_CONTEXT, true),
276
288
  // --- Secret / API Key (entropy-based fallback for longer tokens) ---
277
289
  // Only the *trailing* \b is replaced. It could not match when the token run
278
290
  // ended on "-", "+" or "/", so the engine backtracked across two
@@ -552,7 +564,10 @@ const PT = {
552
564
  p(EntityType.PHONE, String.raw `\b[29]\d{2}[\s\-]?\d{3}[\s\-]?\d{3}\b`),
553
565
  p(EntityType.PHONE, String.raw `\b[29]\d{8}\b`),
554
566
  p(EntityType.PHONE, String.raw `\+351\s?[29]\d{2}[\s\-]?\d{3}[\s\-]?\d{3}`),
555
- p(EntityType.LICENSE_PLATE, String.raw `\b[A-Z0-9]{2}-[A-Z0-9]{2}-[A-Z0-9]{2}\b`),
567
+ // The four formats Portugal has issued (rules-engine#126).
568
+ p(EntityType.LICENSE_PLATE, String.raw `\b(?:\d{2}-\d{2}-[A-Z]{2}|\d{2}-[A-Z]{2}-\d{2}|[A-Z]{2}-\d{2}-\d{2}|[A-Z]{2}-\d{2}-[A-Z]{2})\b`),
569
+ // Any three pairs, behind a plate label (rules-engine#126).
570
+ p(EntityType.LICENSE_PLATE, String.raw `\b[A-Z0-9]{2}-[A-Z0-9]{2}-[A-Z0-9]{2}\b`, null, "Portuguese license plate — AA-XX-XX, behind its label", ["matrícula", "matricula", "placa", "license plate", "number plate", "plate", "Kennzeichen", "kenteken", "immatriculation", "targa"], true),
556
571
  p(EntityType.POSTAL_CODE, String.raw `\b\d{4}-\d{3}\b`),
557
572
  ],
558
573
  };
@@ -627,6 +642,8 @@ const UK = {
627
642
  code: "UK",
628
643
  name: "United Kingdom",
629
644
  patterns: [
645
+ // A sort code behind its label; no longer found through the Portuguese plate shape (rules-engine#126).
646
+ p(EntityType.BANK_ACCOUNT, String.raw `\b\d{2}-\d{2}-\d{2}\b`, null, "UK sort code — NN-NN-NN, behind its label", ["sort code", "sortcode", "sort-code"], true),
630
647
  // Healthcare-provider numbers, behind their label: no shape of their own, so without a pattern nothing claimed them, or a phone or a foreign checksum did (canon HEALTHCARE_PROVIDER; rules-engine#111).
631
648
  p(EntityType.HEALTHCARE_PROVIDER, String.raw `\b\d{7}\b`, null, "UK GMC number — 7 digits", ["GMC number", "GMC no", "GMC reference", "GMC reg", "GMC:", "GMC-Nr", "General Medical Council"], true),
632
649
  // UK NINO: a social-security number, so SSN, as the canon defaults it (rules-engine#47).
@@ -199,6 +199,27 @@ function suppressSecretOverStructured(text, match) {
199
199
  * natural word. A random all-letter token would be mixed case and is left alone
200
200
  * — losing a real secret is the expensive direction.
201
201
  */
202
+ /**
203
+ * Values an assigned-secret rule finds after a "=" or ":" that are names, not
204
+ * secrets (rules-engine#126): `${VAR}` references, identifiers, hyphenated
205
+ * compounds, image tags, product versions, code calls. Not paths, host names
206
+ * or reference numbers: those carry personal data ("mac-mhartmann88").
207
+ * Kept in sync with the Python SDK's _SECRET_NAME_SHAPES.
208
+ */
209
+ const SECRET_NAME_SHAPES = new RegExp([
210
+ String.raw `^\$\{[A-Za-z_][A-Za-z0-9_]*(?::?[-=?+][^}]*)?\}?$`,
211
+ String.raw `^[a-z]{2,}(?:_[a-z]{2,})+$`, String.raw `^[A-Z]{2,}(?:_[A-Z]{2,})+$`,
212
+ String.raw `^(?:[A-Z]+[a-z]{2,}){3,}(?:V?\d{1,2})?$`,
213
+ String.raw `^[a-z][a-z0-9._/-]*:(?:\d+(?:\.\d+)*(?:-[a-z0-9]+)*|latest|stable|alpine|slim)$`,
214
+ String.raw `^[A-Za-z][\w.-]*\/\d+(?:\.\d+)+$`,
215
+ String.raw `^[\w.]+\(["'\x60]`,
216
+ ].join("|"));
217
+ /** A hyphenated compound of words and acronyms; two capitalised words alone
218
+ * ("Ann-Sophie", "Caron-Cordier") are names and are left alone. */
219
+ const COMPOUND_PART = String.raw `(?:[A-ZÀ-Þ]{2,6}\d{0,3}|[A-Za-zÀ-ÿ][a-zß-ÿ]{2,})`;
220
+ const COMPOUND = new RegExp(String.raw `^` + COMPOUND_PART + String.raw `(?:-` + COMPOUND_PART + String.raw `)+$`);
221
+ const NAME_PAIR = /^[A-ZÀ-Þ][a-zß-ÿ]{2,}-[A-ZÀ-Þ][a-zß-ÿ]{2,}$/;
222
+ const CONNECTION_WITH_REFERENCE = /^[a-z]+:\/\/[^\s:@]+:\$\{[^}]*\}@/;
202
223
  function suppressSecretNotASecret(_text, match) {
203
224
  if (match.patternDef.entityType !== EntityType.SECRET)
204
225
  return false;
@@ -213,6 +234,10 @@ function suppressSecretNotASecret(_text, match) {
213
234
  return true;
214
235
  if (HYPHENATED_WORDS.test(bare))
215
236
  return true;
237
+ if (SECRET_NAME_SHAPES.test(bare) || CONNECTION_WITH_REFERENCE.test(bare))
238
+ return true;
239
+ if (COMPOUND.test(bare) && !NAME_PAIR.test(bare))
240
+ return true;
216
241
  const alpha = /^[A-Za-zÀ-ÿ]+$/.test(bare);
217
242
  const oneWord = bare === bare.toLowerCase() ||
218
243
  (bare.slice(0, 1) === bare.slice(0, 1).toUpperCase() &&
@@ -354,7 +379,20 @@ function suppressSequential(_text, match) {
354
379
  // the "]". The opening bracket is still required.
355
380
  const PLACEHOLDER = /^(?:\[([A-Z][A-Z0-9_]*)\]?|([A-Z][A-Z0-9_]*?)_(?:\d+|[A-HJ-NP-Z2-9]{4}))$/;
356
381
  const KNOWN_TYPE_NAMES = new Set(Object.values(EntityType));
357
- function suppressRedactionPlaceholder(_text, match) {
382
+ function suppressRedactionPlaceholder(text, match) {
383
+ // The name between the brackets: an assigned value now starts after an
384
+ // opening bracket, so "credentials: [POSTAL_CODE]" offers "POSTAL_CODE" alone
385
+ // (rules-engine#33, #126).
386
+ if (text[match.start - 1] === "[" && text[match.end] === "]" && KNOWN_TYPE_NAMES.has(match.text))
387
+ return true;
388
+ // A placeholder inside the span: on text masked once, a value running across
389
+ // "[IP_ADDRESS]" is two things joined (rules-engine#126).
390
+ // One character either side: a span may start just inside the bracket.
391
+ const around = text.slice(Math.max(0, match.start - 1), match.end + 1);
392
+ for (const m of around.matchAll(/\[([A-Z][A-Z0-9_]*)\]/g)) {
393
+ if (KNOWN_TYPE_NAMES.has(m[1]))
394
+ return true;
395
+ }
358
396
  const found = PLACEHOLDER.exec(match.text);
359
397
  if (found === null)
360
398
  return false;
@@ -502,6 +540,33 @@ const EXTENSION_BEFORE = /(?<![A-Za-z0-9_])(?:toest(?:el)?|ext|extension|poste|d
502
540
  */
503
541
  const COUNT_LABEL_BEFORE = /(?<![A-Za-z0-9_])(?:employees|headcount|staff|mitarbeiter(?:innen)?|beschäftigte|werknemers|medewerkers|employés|salariés|effectifs?|dipendenti|empleados|pracowników|zaměstnanců)\s*[:=]?\s*$/i;
504
542
  const NOT_POSTAL_BEFORE = /(?<![A-Za-z0-9_])(?:(?:ISO(?:\/IEC)?|IEC|EN|DIN(?:\s+EN)?|NEN(?:-EN)?|NF\s+EN|BS(?:\s+EN)?|PN-EN|ÖNORM)[\s\-]*|[A-Za-z]*[a-z][A-Z][A-Za-z]*\s+)$/;
543
+ /**
544
+ * What else a number is, by what touches it (rules-engine#126): a build,
545
+ * version, event, lease, policy or ticket number; a port; an invoice, reference
546
+ * or study-subject number; a process id; a JSON id or port field; an HTTP
547
+ * response size; the hour after a date; a lab reference range; or a quantity
548
+ * with its unit after it. Kept in sync with the Python SDK.
549
+ */
550
+ const TECH_WORD_BEFORE = /(?<![A-Za-z0-9_])(?:build|version|policy\s+id|ticket(?:\s+[A-Za-z]{2,10})?|event|lease|pid|job|port|rfc|facture|invoice|r[ée]f[ée]rence|n[°º]|tn|sae|uczestnik|subject|narratief|participant)\.?\s*[:#=]?\s*["(«[]?\s*$/i;
551
+ const TECH_SHAPE_BEFORE = new RegExp("(?:" + [
552
+ String.raw `"[A-Za-z_]*(?:id|port)"\s*:\s*"?`, // "parent_id": 4400
553
+ String.raw `(?<![A-Za-z0-9])[A-Z_]*PORT\s*[:=]\s*"?`, // DB_PORT: "5432"
554
+ String.raw `(?:localhost|[a-z0-9\-]+\.[a-z0-9.\-]+):`, // localhost:3000
555
+ String.raw `(?<![\d.])\d{2,5}:`, // "8080:8080"
556
+ String.raw `HTTP\/\d(?:\.\d)?"?\s+\d{3}\s+`, // HTTP/1.1" 200 2048
557
+ String.raw `\d+\.\d+(?:\.\d+)+\s*\(`, // build 2.4.0 (4012)
558
+ String.raw `(?<!\d)\d{1,2}[./]\d{1,2}[./]`, // 03.10.2026 23:50
559
+ String.raw `\((?:N|norm(?:al)?|ref)\s*:?\s*`, // (N : 30-400)
560
+ String.raw `\|`,
561
+ ].join("|") + ")$", "i");
562
+ const POSTAL_UNIT_AFTER = /^(?:\s?(?:ms|s|sec|secs|seconds?|secondes?|sekunden|sek|min|bits?|bytes?|kb|mb|gb|tb|kbps|mbps|px|%|records|rows|entries|datensätze|einträge|micrograms|µg|ug|mg|mmol|fl)(?![A-Za-zÀ-ÿ])|\s*→\s*\d+\s*(?:bits?|bytes?)(?![A-Za-z])|:\d|\|)/i;
563
+ const PID_BEFORE = /[A-Za-z0-9_\-]\[$/;
564
+ /** A standard's name as the letters of a lettered postcode: "RFC 5737". Not
565
+ * BS or EN: those are Bristol and Enfield. */
566
+ const STANDARD_LETTERS = /^(?:RFC|ISO|IEC|DIN)\s?\d/;
567
+ /** A year closing a range: "1998–2011". En and em dashes only. */
568
+ const YEAR_RANGE_BEFORE = /(?:19|20)\d\d\s?[–—]\s?$/;
569
+ const YEAR_RANGE_AFTER = /^\s?[–—]\s?(?:19|20)\d\d(?!\d)/;
505
570
  /** A thousands group of an amount: the "750" of "£28,750,000". */
506
571
  const GROUP_BEFORE = /\d[,.]$/;
507
572
  const GROUP_AFTER = /^[,.]\d{3}(?!\d)/;
@@ -515,10 +580,90 @@ function suppressPostalAfterDisqualifyingWord(text, match) {
515
580
  return true;
516
581
  if (GROUP_BEFORE.test(before) && GROUP_AFTER.test(text.slice(match.end, match.end + 5)))
517
582
  return true;
583
+ const after = text.slice(match.end, match.end + 24);
584
+ if (TECH_WORD_BEFORE.test(before) || TECH_SHAPE_BEFORE.test(before) || POSTAL_UNIT_AFTER.test(after)
585
+ || STANDARD_LETTERS.test(match.text))
586
+ return true;
587
+ if (PID_BEFORE.test(before) && after.startsWith("]"))
588
+ return true;
589
+ if (DATE_YEAR.test(match.text.trim()) && (YEAR_RANGE_BEFORE.test(before) || YEAR_RANGE_AFTER.test(after)))
590
+ return true;
518
591
  return RECENT_YEAR.test(match.text.trim())
519
592
  && (YEAR_WORD_BEFORE.test(before) || YEAR_VERB_BEFORE.test(before))
520
593
  && !TOWN_AFTER.test(text.slice(match.end, match.end + 3));
521
594
  }
595
+ /** A group with a leading zero: "2.00.079.01" is a version, not an address. */
596
+ const IP_LEADING_ZERO = /(?:^|\.)0\d/;
597
+ const CIDR_PREFIX_AFTER = /^\/(\d{1,2})(?!\d)/;
598
+ /** RFC 5737's documentation ranges: reserved, never routed, used in examples. */
599
+ const DOCUMENTATION_NETS = ["192.0.2.", "198.51.100.", "203.0.113."];
600
+ /**
601
+ * An IPv4 shape that names no host (rules-engine#126): a network address
602
+ * ("10.40.0.0/16"), a documentation address, or a version with leading zeros.
603
+ * An interface address with its prefix ("192.168.1.23/24") stays.
604
+ */
605
+ function suppressIpNotAHost(text, match) {
606
+ if (match.patternDef.entityType !== EntityType.IP_ADDRESS)
607
+ return false;
608
+ const ip = match.text;
609
+ if (IP_LEADING_ZERO.test(ip) || DOCUMENTATION_NETS.some((n) => ip.startsWith(n)))
610
+ return true;
611
+ // Part of a longer dotted run: "urn:oid:1.2.250.1.71.4.2.1" is an OID.
612
+ if (/\d\.$/.test(text.slice(Math.max(0, match.start - 2), match.start))
613
+ || /^\.\d/.test(text.slice(match.end, match.end + 2)))
614
+ return true;
615
+ const prefix = CIDR_PREFIX_AFTER.exec(text.slice(match.end, match.end + 4));
616
+ if (prefix !== null && Number(prefix[1]) < 32) {
617
+ const value = ip.split(".").reduce((acc, o) => acc * 256 + Number(o), 0);
618
+ return value % 2 ** (32 - Number(prefix[1])) === 0;
619
+ }
620
+ return false;
621
+ }
622
+ /**
623
+ * The specimen of a form: "das Muster „C01X00T47“". Only "Muster": "z. B." and
624
+ * "e.g." introduce real values in prose as often as samples (rules-engine#126).
625
+ */
626
+ const EXAMPLE_BEFORE = /(?<![A-Za-z])muster\s*[:,]?\s*[„"«']?$/i;
627
+ /** A dummy value in placeholder glyphs ("XX0000000", "999 999 9999") or MAC
628
+ * groups of one repeated letter. Not "111222333", the standard test BSN. */
629
+ const DUMMY_VALUE = /^(?=(?:[\s:\-./]*[X09]){5})(?:[X\s:\-./]*[X][X0\s:\-./]*|[X9\s:\-./]*X[X9\s:\-./]*|[0\s:\-./]+|[9\s:\-./]+)$|^(?:([a-fA-F])\1[:\-]){2,}([a-fA-F])\2$/;
630
+ /** Algorithm names in a key or signature line: "ED25519", "SHA256". */
631
+ const ALGORITHM = /^(?:ED25519|ED448|SHA-?(?:1|224|256|384|512)|MD5|AES-?\d{3}|RSA-?\d{3,4}|[HRE]S(?:256|384|512))$/i;
632
+ /** A date exactly, which no national number is written as. */
633
+ const EXACT_DOTTED_DATE = /^(?:0[1-9]|[12]\d|3[01])\.(?:0[1-9]|1[0-2])\.(?:19|20)\d\d$/;
634
+ /** The identifier types an example marker or a dummy value rules out. */
635
+ const EXAMPLE_TYPES = new Set([
636
+ EntityType.NATIONAL_ID, EntityType.PASSPORT, EntityType.LICENSE_PLATE,
637
+ EntityType.HEALTH_INSURANCE, EntityType.MAC_ADDRESS,
638
+ EntityType.DRIVERS_LICENSE, EntityType.SSN, EntityType.TAX_ID,
639
+ ]);
640
+ /** A placeholder in the shape of a value, for any type (#126). */
641
+ function suppressDummyValue(_text, match) {
642
+ return DUMMY_VALUE.test(match.text.trim());
643
+ }
644
+ /** An identifier given as a form's specimen, not anyone's (#126). */
645
+ function suppressExampleValue(text, match) {
646
+ if (!EXAMPLE_TYPES.has(match.patternDef.entityType))
647
+ return false;
648
+ return EXAMPLE_BEFORE.test(text.slice(Math.max(0, match.start - 24), match.start));
649
+ }
650
+ /**
651
+ * Shapes that fit an identifier pattern and are something else (#126): a
652
+ * passport without a digit, an algorithm or a law citation read as a plate,
653
+ * a date read as a dotted national number.
654
+ */
655
+ function suppressNotAnIdentifier(text, match) {
656
+ const type = match.patternDef.entityType;
657
+ const value = match.text.trim();
658
+ if (type === EntityType.PASSPORT)
659
+ return !/\d/.test(value);
660
+ if (type === EntityType.LICENSE_PLATE) {
661
+ return ALGORITHM.test(value) || /§\s*$/.test(text.slice(Math.max(0, match.start - 4), match.start));
662
+ }
663
+ if (type === EntityType.NATIONAL_ID)
664
+ return EXACT_DOTTED_DATE.test(value);
665
+ return false;
666
+ }
522
667
  function suppressPhoneServiceNumber(_text, match) {
523
668
  if (match.patternDef.entityType !== EntityType.PHONE)
524
669
  return false;
@@ -630,7 +775,11 @@ function suppressDePlateUnknownDistrict(text, match) {
630
775
  const parts = match.text.trim().split(/[\s\-]+/);
631
776
  if (parts.length === 0 || !parts[0])
632
777
  return false;
633
- if (DE_DISTRICT_CODES.has(parts[0].toUpperCase()))
778
+ // A German plate number never starts with 0: without a plate cue,
779
+ // "GR-NB-0412" is an asset tag and "HR-IT-03" a form (rules-engine#126).
780
+ const digits = /\d+/.exec(match.text);
781
+ const leadingZero = digits !== null && digits[0].startsWith("0");
782
+ if (DE_DISTRICT_CODES.has(parts[0].toUpperCase()) && !leadingZero)
634
783
  return false;
635
784
  const [before, after] = getContext(text, match.start, match.end);
636
785
  if (PLATE_CUE_NEAR.test(before + after))
@@ -1071,15 +1220,22 @@ function suppressRequiresContext(text, match) {
1071
1220
  return false;
1072
1221
  // A value filling an entire field of a delimited row is introduced by its
1073
1222
  // column, not by a word, so that is context — but only for narrow shapes.
1223
+ // A date's column decides when the table names it: under "début" or
1224
+ // "last_day" a date is not a birth date (rules-engine#126).
1074
1225
  if (DELIMITED_FIELD_TYPES.has(match.patternDef.entityType) &&
1075
- fillsADelimitedField(text, match.start, match.end))
1226
+ fillsADelimitedField(text, match.start, match.end) &&
1227
+ !(DATE_LABEL_TYPES.has(match.patternDef.entityType) && columnHeader(text, match.start) !== null))
1076
1228
  return false;
1077
1229
  if (DATE_LABEL_TYPES.has(match.patternDef.entityType)) {
1078
1230
  return !labelsThisDate(text, match.start, match.end, match.patternDef.contextKeywords, match.patternDef.entityType);
1079
1231
  }
1080
1232
  const [before, after] = getContext(text, match.start, match.end);
1081
1233
  const context = (before + " " + after).toLowerCase();
1082
- return !match.patternDef.contextKeywords.some(kw => context.includes(kw.toLowerCase()));
1234
+ if (match.patternDef.contextKeywords.some(kw => context.includes(kw.toLowerCase())))
1235
+ return false;
1236
+ // The column names it: a passport under "Passport" rows below the header (#130).
1237
+ const header = columnHeader(text, match.start);
1238
+ return header === null || !match.patternDef.contextKeywords.some(kw => keywordAt(header, kw, false) >= 0);
1083
1239
  }
1084
1240
  /** Date types decided by a label: DOB and DATE_OF_DEATH share one date shape. */
1085
1241
  const DATE_LABEL_TYPES = new Set([EntityType.DOB, EntityType.DATE_OF_DEATH]);
@@ -1115,10 +1271,16 @@ function nothingButAListBetween(gap) {
1115
1271
  */
1116
1272
  function keywordAt(haystack, keyword, last) {
1117
1273
  const kw = keyword.toLowerCase();
1274
+ const isLetter = (c) => c !== "" && c.toLowerCase() !== c.toUpperCase();
1118
1275
  if (keyword !== keyword.toUpperCase() || keyword === kw) {
1119
- return last ? haystack.lastIndexOf(kw) : haystack.indexOf(kw);
1276
+ // It must start a word, though it may run on into a longer one: "née le"
1277
+ // in "signée le" and "born" in "Newborn" are other words (rules-engine#126).
1278
+ let i = last ? haystack.lastIndexOf(kw) : haystack.indexOf(kw);
1279
+ while (i > 0 && isLetter(haystack[i - 1])) {
1280
+ i = last ? (i === 0 ? -1 : haystack.lastIndexOf(kw, i - 1)) : haystack.indexOf(kw, i + 1);
1281
+ }
1282
+ return i;
1120
1283
  }
1121
- const isLetter = (c) => c !== "" && c.toLowerCase() !== c.toUpperCase();
1122
1284
  const hits = [];
1123
1285
  for (let i = haystack.indexOf(kw); i !== -1; i = haystack.indexOf(kw, i + 1)) {
1124
1286
  let next = i + kw.length;
@@ -1180,29 +1342,44 @@ function columnHeader(text, start) {
1180
1342
  const lineStart = text.lastIndexOf("\n", start - 1) + 1;
1181
1343
  const lineEndAt = text.indexOf("\n", start);
1182
1344
  const line = text.slice(lineStart, lineEndAt >= 0 ? lineEndAt : text.length);
1183
- // The three lines above, by lastIndexOf: splitting the whole prefix was
1184
- // quadratic in the document, once per date candidate.
1185
- let aboveStart = lineStart - 1;
1186
- for (let k = 0; k < 3 && aboveStart >= 0; k++) {
1187
- aboveStart = aboveStart === 0 ? -1 : text.lastIndexOf("\n", aboveStart - 1);
1188
- }
1189
- const above = lineStart ? text.slice(aboveStart + 1, Math.max(0, lineStart - 1)).split("\n") : [];
1190
1345
  for (const sep of COLUMN_SEPARATORS) {
1191
1346
  if (!line.includes(sep))
1192
1347
  continue;
1193
1348
  const cells = line.split(sep);
1194
1349
  const column = text.slice(lineStart, start).split(sep).length - 1;
1195
- for (const header of [...above].reverse()) {
1196
- // A "label : value" field with a comma in its value is not a header (#111).
1197
- if (header.includes(":"))
1350
+ // Lines above, one lastIndexOf at a time: one of the three lines above, or
1351
+ // further up through rows of the same table (rules-engine#126).
1352
+ let end = lineStart - 1;
1353
+ let seen = 0;
1354
+ while (end >= 0 && seen < HEADER_REACH) {
1355
+ const begin = end === 0 ? 0 : text.lastIndexOf("\n", end - 1) + 1;
1356
+ const header = text.slice(begin, end);
1357
+ seen++;
1358
+ // A rule line ("|---|---|", "----+----") is neither header nor row (#130).
1359
+ if (RULE_LINE.test(header)) {
1360
+ end = begin - 1;
1198
1361
  continue;
1362
+ }
1199
1363
  const heads = header.split(sep);
1200
- if (heads.length === cells.length && !ANY_DATE.test(header))
1364
+ const same = heads.length === cells.length;
1365
+ // A "label : value" field with a comma in its value is not a header
1366
+ // (#111); a commented header ("# Spalten: Name;von;bis") is (#126).
1367
+ // Nor is a data row without a date: a header has no cell with four digits or more (#130).
1368
+ if (same && (!header.includes(":") || header.trimStart().startsWith("#")) && !ANY_DATE.test(header)
1369
+ && !heads.some((h) => (h.match(/\d/g) ?? []).length >= 4)) {
1201
1370
  return heads[column].toLowerCase();
1371
+ }
1372
+ if (seen >= 3 && !same)
1373
+ break; // past the table this value sits in
1374
+ end = begin - 1;
1202
1375
  }
1203
1376
  }
1204
1377
  return null;
1205
1378
  }
1379
+ /** How many lines up a table's header is looked for. */
1380
+ const HEADER_REACH = 200;
1381
+ /** A table's rule line: only dashes, equals signs, plus signs, colons, pipes. */
1382
+ const RULE_LINE = /^[\s\-=+:|]*-[\s\-=+:|]*$/;
1206
1383
  /** A dotted MAC shape glued to more of a reference is part of it: the tail of
1207
1384
  * "WSO-II.6151.4471.2025" (rules-engine#105). Dotted form only. */
1208
1385
  function suppressMacInReference(text, match) {
@@ -1237,12 +1414,18 @@ const TYPE_SUPPRESSORS = {
1237
1414
  [EntityType.CHAMBER_OF_COMMERCE]: [suppressReference],
1238
1415
  [EntityType.MAC_ADDRESS]: [suppressMacInReference],
1239
1416
  [EntityType.CREDIT_CARD]: [suppressCardInsideDigitRun],
1417
+ [EntityType.IP_ADDRESS]: [suppressIpNotAHost],
1240
1418
  };
1419
+ for (const type of EXAMPLE_TYPES) {
1420
+ TYPE_SUPPRESSORS[type] = [...(TYPE_SUPPRESSORS[type] ?? []), suppressExampleValue, suppressNotAnIdentifier];
1421
+ }
1241
1422
  export function shouldSuppress(text, match, scratch) {
1242
1423
  if (suppressSequential(text, match))
1243
1424
  return true;
1244
1425
  if (suppressRedactionPlaceholder(text, match))
1245
1426
  return true;
1427
+ if (suppressDummyValue(text, match))
1428
+ return true;
1246
1429
  const typeSups = TYPE_SUPPRESSORS[match.patternDef.entityType];
1247
1430
  if (typeSups) {
1248
1431
  for (const s of typeSups) {
package/dist/esm/sdk.d.ts CHANGED
@@ -104,6 +104,15 @@ export declare class TokenMapper {
104
104
  * restored, since leaving a token behind is the worse failure.
105
105
  */
106
106
  export declare function restore(text: string, tokens: Record<string, string>): string;
107
+ /**
108
+ * The entity types a caller asked to leave in the text, by canonical name.
109
+ *
110
+ * Names are matched case-insensitively and legacy names resolve (`IBAN` is
111
+ * `BANK_ACCOUNT`, `CREDENTIAL` is `SECRET`). A custom pattern's name counts
112
+ * once it is registered. Anything else throws: a typo in a type meant to be
113
+ * switched off would otherwise switch nothing off (rules-engine#125).
114
+ */
115
+ export declare function normalizeExcludeTypes(values: unknown, customNames?: ReadonlySet<string>, param?: string): Set<string>;
107
116
  export interface RedactOptions {
108
117
  /**
109
118
  * Scope. Detections attributed elsewhere are flagged `outOfScope`, never
@@ -144,6 +153,15 @@ export interface RedactOptions {
144
153
  * EMAIL and URL only, and covers subdomains. Merged with the instance list.
145
154
  */
146
155
  allowlistDomains?: string[] | null;
156
+ /**
157
+ * Entity types to leave in the text: neither in `detections` nor masked, and
158
+ * each such detection reported in `RedactResult.exempted` (`ruleKind:
159
+ * "type"`). Names are case-insensitive and legacy names resolve; unknown
160
+ * names throw. Merged with the instance's list. Default: nothing excluded.
161
+ * In `mode: "cloud"` an excluded rules type is still masked in the text sent
162
+ * to the service — this decides what comes back, not what may leave.
163
+ */
164
+ excludeTypes?: string[] | null;
147
165
  detectDates?: boolean;
148
166
  cache?: boolean;
149
167
  /**
@@ -161,16 +179,22 @@ export declare class EuRedact {
161
179
  private maxInputLength;
162
180
  private allowlist;
163
181
  private allowlistDomains;
182
+ private customNames;
183
+ private excludeTypes;
164
184
  /**
165
185
  * @param options.maxInputLength Longest document `redact` accepts, in characters.
166
186
  * @param options.allowlist Values never to redact, for every call on this
167
187
  * instance — an organisation's own name, its own addresses. Merged with
168
188
  * the per-call `allowlist`. Matched whole, case-insensitively.
189
+ * @param options.excludeTypes Entity types never reported or masked, for
190
+ * every call on this instance, e.g. `["JOB_TITLE", "FINANCIAL_AMOUNT"]`.
191
+ * Merged with the per-call value. Unknown names throw.
169
192
  */
170
193
  constructor(options?: {
171
194
  maxInputLength?: number;
172
195
  allowlist?: string[] | null;
173
196
  allowlistDomains?: string[] | null;
197
+ excludeTypes?: string[] | null;
174
198
  });
175
199
  addCustomPattern(name: string, pattern: string): void;
176
200
  /** Clear the result cache and referential integrity mappings.
@@ -178,6 +202,8 @@ export declare class EuRedact {
178
202
  clear(): void;
179
203
  /** The instance allowlist merged with a call's, in comparison form. */
180
204
  private allowedFor;
205
+ /** The instance's excluded types merged with a call's. */
206
+ private excludedFor;
181
207
  /** The instance domain list merged with a call's. */
182
208
  private domainsFor;
183
209
  /** The label function for one call, given its output options. */