euredact 0.4.0 → 0.5.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.
@@ -96,7 +96,14 @@ const SHARED = {
96
96
  // is Unicode-aware) matched local parts like "petras_ž@..." while this one
97
97
  // did not. \p{L}\p{N} with the `u` flag restores parity; the leading guard
98
98
  // is a Unicode-safe word boundary.
99
- p(EntityType.EMAIL, String.raw `(?<![\p{L}\p{N}._%+\-])[\p{L}\p{N}._%+\-]+@(?:[\p{L}\p{N}\-]+\.)+\p{L}{2,}\b`),
99
+ // The apostrophe is allowed only between word characters: it belongs in a
100
+ // local part, but putting it in the class would let the match absorb a
101
+ // quote from the surrounding text, while leaving it out made
102
+ // `johno'neill@outlook.ie` mask as `johno'[EMAIL]` (issue rules-engine#10).
103
+ // The second lookbehind refuses to start a match after `<word-char>'`,
104
+ // which is a match already in progress; without it every apostrophe was a
105
+ // fresh start offset and the scan went quadratic. Mirrors Python.
106
+ p(EntityType.EMAIL, String.raw `(?<![\p{L}\p{N}._%+\-])(?<![\p{L}\p{N}._%+\-]')[\p{L}\p{N}._%+\-]+(?:'[\p{L}\p{N}._%+\-]+)*@(?:[\p{L}\p{N}\-]+\.)+\p{L}{2,}\b`),
100
107
  // IBAN (any country) — structure + mod-97 only. Country-specific IBAN
101
108
  // patterns still exist and win on identical spans; this one guarantees an
102
109
  // IBAN is never missed just because its country was not requested.
@@ -106,7 +113,11 @@ const SHARED = {
106
113
  // "+43 664 8213 907" was invisible to the Austrian pattern, which expects
107
114
  // an unbroken subscriber number. A leading "+" is self-identifying, so
108
115
  // this is deliberately not country-gated.
109
- p(EntityType.PHONE, String.raw `\+\d{1,3}(?:[\s\-/]?\(?\d{1,4}\)?){2,7}`, "e164", "International phone number (E.164) — any country"),
116
+ // Parentheses are matched as a pair inside one group: `\(?...\)?` let the
117
+ // closing parenthesis of the surrounding text join the number, so
118
+ // "Call (+32 475 12 34 56) today." also masked the ")" (issue
119
+ // rules-engine#3). Mirrors the Python pattern character for character.
120
+ p(EntityType.PHONE, String.raw `\+\d{1,3}(?:[\s\-/]?(?:\(\d{1,4}\)|\d{1,4})){2,7}`, "e164", "International phone number (E.164) — any country"),
110
121
  p(EntityType.BIC, String.raw `\b[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}(?:[A-Z0-9]{3})?\b`, "bic"),
111
122
  // Space-separated form: "RZBA AT WW", "NICA BE BB". Written as a separate
112
123
  // pattern requiring a space between *every* group, rather than making the
@@ -117,7 +128,15 @@ const SHARED = {
117
128
  p(EntityType.CREDIT_CARD, String.raw `\b(?:4[0-9]{3}[\s\-]?[0-9]{4}[\s\-]?[0-9]{4}[\s\-]?[0-9]{4}|5[1-5][0-9]{2}[\s\-]?[0-9]{4}[\s\-]?[0-9]{4}[\s\-]?[0-9]{4}|3[47][0-9]{2}[\s\-]?[0-9]{6}[\s\-]?[0-9]{5})\b`, "luhn"),
118
129
  p(EntityType.VIN, String.raw `\b[A-HJ-NPR-Z0-9]{17}\b`, "vin"),
119
130
  p(EntityType.IP_ADDRESS, String.raw `\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`),
120
- p(EntityType.IPV6_ADDRESS, String.raw `\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b|\b(?:[0-9a-fA-F]{1,4}:){1,7}:\b|\b(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}\b|\b::(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}\b`),
131
+ // Branch order is load-bearing: alternation is leftmost-first, not
132
+ // longest-match, so the form that consumes most is tried first, and the
133
+ // IPv4-embedded branches lead because every group of a dotted quad is
134
+ // also valid hex. `\b` is the wrong anchor around ":" (a non-word
135
+ // character), which is why `::1` and `2001:db8::` were not detected at
136
+ // all. A bare "::" is deliberately not matched. Mirrors the Python
137
+ // pattern character for character -- see the comment there
138
+ // (issue rules-engine#5).
139
+ p(EntityType.IPV6_ADDRESS, String.raw `(?<![0-9A-Za-z_:.])(?:(?:[0-9A-Fa-f]{1,4}:){6}(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])|(?:[0-9A-Fa-f]{1,4}:){1,5}:(?:[0-9A-Fa-f]{1,4}:)*(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])|::(?:[0-9A-Fa-f]{1,4}:)*(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])|(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,7}(?::[0-9A-Fa-f]{1,4}){1,7}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|:(?::[0-9A-Fa-f]{1,4}){1,7})(?:%[0-9A-Za-z][0-9A-Za-z._-]*)?(?![0-9A-Za-z_:])`),
121
140
  p(EntityType.MAC_ADDRESS, String.raw `\b(?:[0-9a-fA-F]{2}[:\-]){5}[0-9a-fA-F]{2}\b`, null, "colon/dash"),
122
141
  p(EntityType.MAC_ADDRESS, String.raw `\b[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\b`, null, "Cisco"),
123
142
  p(EntityType.IMEI, String.raw `\b\d{15}\b`, "imei"),
@@ -221,12 +240,12 @@ const DE = {
221
240
  patterns: [
222
241
  p(EntityType.TAX_ID, String.raw `\b[1-9]\d{10}\b`, "german_tax_id"),
223
242
  p(EntityType.TAX_ID, String.raw `\b[1-9]\d[\s.]?\d{3}[\s.]?\d{3}[\s.]?\d{3}\b`, "german_tax_id"),
224
- p(EntityType.NATIONAL_ID, String.raw `\b[CFGHJKLMNPRTVWXYZ][0-9A-Z]{8,9}\b`, null, "", ["Personalausweis", "Personalausweisnummer", "Ausweis", "Ausweisnummer", "Ausweis-Nr", "identity card", "Identitätskarte", "Perso", "PA-Nummer"], true),
243
+ p(EntityType.NATIONAL_ID, String.raw `\b[CFGHJKLMNPRTVWXYZ][0-9CFGHJKLMNPRTVWXYZ]{8}\d?\b`, null, "", ["Personalausweis", "Personalausweisnummer", "Ausweis", "Ausweisnummer", "Ausweis-Nr", "identity card", "Identitätskarte", "Perso", "PA-Nummer"], true),
225
244
  p(EntityType.IBAN, String.raw `\bDE\d{2}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{2}\b`, "iban"),
226
245
  p(EntityType.VAT, String.raw `\bDE\s?\d{9}\b`, "vat_de"),
227
246
  p(EntityType.PHONE, String.raw `\b0[1-9]\d{1,4}[\s/\-]?\d{3,8}\b`),
228
247
  p(EntityType.PHONE, String.raw `\+49\s?\d{2,5}[\s/\-]?\d{3,8}\b`),
229
- p(EntityType.PASSPORT, String.raw `\b[CFGHJK][0-9A-Z]{8,9}\b`, null, "", ["Reisepass", "passport", "Passnummer", "Reisepassnummer", "Reisepass Nummer", "Reisepass-Nr", "Pass Nr"], true),
248
+ p(EntityType.PASSPORT, String.raw `\b[CFGHJK][0-9CFGHJKLMNPRTVWXYZ]{8}\d?\b`, null, "", ["Reisepass", "passport", "Passnummer", "Reisepassnummer", "Reisepass Nummer", "Reisepass-Nr", "Pass Nr"], true),
230
249
  // The separator after the city code is mandatory. When it was optional, a
231
250
  // contiguous letter run split across both groups and the hyphen was
232
251
  // consumed as the *second* separator, so any "LETTERS-DIGITS" token
package/dist/esm/sdk.d.ts CHANGED
@@ -1,5 +1,69 @@
1
1
  import { DocumentContext } from "./rules/context.js";
2
- import { type RedactResult } from "./types.js";
2
+ import { type Detection, type RedactResult } from "./types.js";
3
+ /** Returns the label that replaces `det`; `slice` is the exact text it covers. */
4
+ type LabelFor = (det: Detection, slice: string) => string;
5
+ /**
6
+ * Splice a label over every detection and return the masked text.
7
+ *
8
+ * `detections` must be sorted by `(start, -end)`.
9
+ *
10
+ * Labels are resolved right-to-left because the referential mapper numbers
11
+ * each entity type in call order, and that order is part of the output
12
+ * contract. The string itself is then assembled in a single forward pass:
13
+ * rebuilding it per detection copied the whole document each time, which is
14
+ * O(document x detections) — 1.68 s of pure copying on a 1 MB document with
15
+ * 15,000 detections, versus 2 ms here.
16
+ *
17
+ * Spans from the rule engine are deduplicated and non-overlapping. Spans from
18
+ * elsewhere (the cloud service) are only sorted, so a span may start behind
19
+ * the cursor. Its uncovered tail is still masked rather than dropped: dropping
20
+ * the span would leave those characters in the clear, and splicing it whole
21
+ * would corrupt the label already emitted over its head.
22
+ */
23
+ export declare function applyReplacements(text: string, detections: Detection[], labelFor: LabelFor): string;
24
+ /**
25
+ * Characters a token suffix is drawn from. No vowels, so a suffix never spells
26
+ * a word; no 0/1/I/O, so it survives being read back by a person; no
27
+ * underscore, so the type prefix stays unambiguous.
28
+ */
29
+ export declare const TOKEN_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
30
+ export declare const TOKEN_SUFFIX_LENGTH = 4;
31
+ /**
32
+ * Maps each distinct PII value in one call to a reversible token.
33
+ *
34
+ * A token is `TYPE_XXXX`: the entity type, then `TOKEN_SUFFIX_LENGTH` random
35
+ * characters from `TOKEN_ALPHABET`. The same value gets the same token within
36
+ * the call, so relationships survive; across calls it gets a different one, so
37
+ * two tokenized documents never reveal a shared value.
38
+ *
39
+ * Nothing is kept beyond the call. The mapping goes to the caller in
40
+ * `RedactResult.tokens`, and only the caller can turn it back into text with
41
+ * `restore()`.
42
+ *
43
+ * Tokens are also kept clear of any token-shaped string already in the
44
+ * document. A caller that redacts an LLM's reply to a tokenized prompt has
45
+ * exactly such a document, and a fresh token colliding with an old one would
46
+ * make `restore()` put the wrong value back.
47
+ */
48
+ export declare class TokenMapper {
49
+ private byValue;
50
+ private taken;
51
+ /** Token -> original value, for `RedactResult.tokens`. */
52
+ readonly tokens: Record<string, string>;
53
+ constructor(text: string, detections: Detection[]);
54
+ /** Return the token for `value`, minting one on first sight. */
55
+ getToken: (det: Detection, value: string) => string;
56
+ }
57
+ /**
58
+ * Put the original values back into `text`.
59
+ *
60
+ * `tokens` is `RedactResult.tokens` from the `redact(text, { tokenize: true })`
61
+ * call that produced the text this one derives from — typically the reply an
62
+ * LLM wrote to the tokenized prompt. Every occurrence of every token is
63
+ * replaced; a token an LLM glued to other characters (`EMAIL_K7Q2s`) is still
64
+ * restored, since leaving a token behind is the worse failure.
65
+ */
66
+ export declare function restore(text: string, tokens: Record<string, string>): string;
3
67
  export interface RedactOptions {
4
68
  /**
5
69
  * Scope. Detections attributed elsewhere are flagged `outOfScope`, never
@@ -24,6 +88,22 @@ export interface RedactOptions {
24
88
  chunkOffset?: number;
25
89
  mode?: string;
26
90
  referentialIntegrity?: boolean;
91
+ /**
92
+ * Replace each value with a reversible token (`EMAIL_K7Q2`) and return the
93
+ * token -> value mapping in `RedactResult.tokens`; see `restore()`. Tokens
94
+ * are unique to the call. Cannot be combined with `referentialIntegrity`.
95
+ */
96
+ tokenize?: boolean;
97
+ /**
98
+ * Values never to redact, matched whole and case-insensitively against each
99
+ * detection. Merged with the instance's allowlist.
100
+ */
101
+ allowlist?: string[] | null;
102
+ /**
103
+ * Domains whose addresses are never redacted, e.g. `["acme.be"]`. Applies to
104
+ * EMAIL and URL only, and covers subdomains. Merged with the instance list.
105
+ */
106
+ allowlistDomains?: string[] | null;
27
107
  detectDates?: boolean;
28
108
  cache?: boolean;
29
109
  }
@@ -32,13 +112,52 @@ export declare class EuRedact {
32
112
  private cache;
33
113
  private referentialMapper;
34
114
  private maxInputLength;
115
+ private allowlist;
116
+ private allowlistDomains;
117
+ /**
118
+ * @param options.maxInputLength Longest document `redact` accepts, in characters.
119
+ * @param options.allowlist Values never to redact, for every call on this
120
+ * instance — an organisation's own name, its own addresses. Merged with
121
+ * the per-call `allowlist`. Matched whole, case-insensitively.
122
+ */
35
123
  constructor(options?: {
36
124
  maxInputLength?: number;
125
+ allowlist?: string[] | null;
126
+ allowlistDomains?: string[] | null;
37
127
  });
38
128
  addCustomPattern(name: string, pattern: string): void;
39
129
  /** Clear the result cache and referential integrity mappings.
40
130
  * Call this in long-running processes to free PII from memory. */
41
131
  clear(): void;
132
+ /** The instance allowlist merged with a call's, in comparison form. */
133
+ private allowedFor;
134
+ /** The instance domain list merged with a call's. */
135
+ private domainsFor;
136
+ /** The label function for one call, given its output options. */
137
+ private labelFor;
138
+ /**
139
+ * Send a document to the cloud tier.
140
+ *
141
+ * Options the service cannot honour throw rather than being ignored. Silently
142
+ * dropping one would mean returning a result that does not match what was
143
+ * asked for — which, for anything that changes which spans come back, is
144
+ * under-redaction wearing a plausible face.
145
+ */
146
+ private redactViaCloud;
147
+ /**
148
+ * Rebuild the masked text from the service's spans, in place.
149
+ *
150
+ * The service masks with bracketed labels only. Any other output option is
151
+ * applied here, from its spans: the service builds its `redacted_text` from
152
+ * exactly the spans it returns (entities it reports but cannot place are
153
+ * listed separately and never applied), so nothing it masked is lost by
154
+ * masking again from the same spans.
155
+ *
156
+ * The service reports offsets in code points and this SDK slices UTF-16
157
+ * units, so the slice check is what stands between an emoji in the document
158
+ * and a rebuilt text with the wrong characters masked.
159
+ */
160
+ private remaskCloudResult;
42
161
  redact(text: string, options?: RedactOptions): RedactResult;
43
162
  /**
44
163
  * Redact, awaiting the cloud tier when `mode: "cloud"` is asked for.
@@ -49,3 +168,4 @@ export declare class EuRedact {
49
168
  redactAsync(text: string, options?: RedactOptions): Promise<RedactResult>;
50
169
  redactBatch(texts: string[], options?: RedactOptions): RedactResult[];
51
170
  }
171
+ export {};