@desert-ant-labs/redact 0.2.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.
@@ -0,0 +1,449 @@
1
+ // Port of redact_training.deterministic (v1.4) - the high-precision
2
+ // deterministic recognizer layer. Pure regex + checksums, no dependencies.
3
+ // Kept in parity with the Python detect() via scripts/parity_check.
4
+ const OWNED = new Set(["EMAIL", "URL", "IP_ADDRESS", "CREDIT_CARD", "SSN",
5
+ "BANK_ACCOUNT", "ROUTING_NUMBER", "TAX_ID", "GOVERNMENT_ID", "PASSPORT",
6
+ "DRIVERS_LICENSE", "IMEI"]);
7
+ // ---------------------------------------------------------------- regexes
8
+ const EMAIL_RE = /(?<![A-Za-z0-9.!#$%&'*+/=?^_`{|}~-])([\p{L}\p{N}.!#$%&'*+/=?^`{|}~-]{1,64}@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63})(?![A-Za-z0-9-])/gu;
9
+ const URL_RE = /\b((?:https?:\/\/|ftp:\/\/|www\.)[^\s<>()\[\]{}"']{3,})/gi;
10
+ const IPV4_RE = /(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?!\d)(?!\.\d)/g;
11
+ const IPV6_RE = /(?<![\w:])(?:[0-9a-f]{0,4}:){2,7}[0-9a-f]{0,4}(?![\w:])/gi;
12
+ const MAC_RE = /(?<![0-9a-f])(?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2}(?![0-9a-f])/gi;
13
+ const CC_RE = /(?<!\d)(?:\d[ -]?){13,19}(?!\d)/g;
14
+ const IBAN_RE = /\b[A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]){11,30}\b/gi;
15
+ const BIC_RE = /\b[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}(?:[A-Z0-9]{3})?\b/g;
16
+ const SSN_RE = /(?<!\d)(\d{3})[- ](\d{2})[- ](\d{4})(?!\d)/g;
17
+ const ROUTING_RE = /(?<!\d)\d{9}(?!\d)/g;
18
+ const VIN_RE = /(?<![A-Z0-9])[A-HJ-NPR-Z0-9]{17}(?![A-Z0-9])/gi;
19
+ const ES_DNI_RE = /(?<![A-Z0-9])(?:\d{8}|[XYZ]\d{7})[A-Z](?![A-Z0-9])/gi;
20
+ const NAT_ID_RE = /(?<![A-Za-z0-9])\d[\d .\-]{7,17}\d(?![A-Za-z0-9])/g;
21
+ const IT_CF_RE = /(?<![A-Za-z0-9])[A-Z]{6}\d{2}[A-Z]\d{2}[A-Z]\d{3}[A-Z](?![A-Za-z0-9])/gi;
22
+ const FI_HETU_RE = /(?<![A-Za-z0-9])\d{6}[-+A-F]\d{3}[0-9A-Y](?![A-Za-z0-9])/g;
23
+ const DK_CPR_RE = /(?<!\d)\d{6}[- ]?\d{4}(?!\d)/g;
24
+ const VAT_RE = /(?<![A-Za-z0-9])(AT|BE|BG|CY|CZ|DE|DK|EE|EL|GR|ES|FI|FR|HR|HU|IE|IT|LT|LU|LV|MT|NL|PL|PT|RO|SE|SI|SK)\s?([0-9A-Za-z]{5,14})(?![A-Za-z0-9])/g;
25
+ const IMEI_RE = /(?<!\d)\d{15}(?!\d)/g;
26
+ const SE_PN_RE = /(?<!\d)((?:\d{2})?\d{6})[-+](\d{4})(?!\d)/g;
27
+ const PASSPORT_VAL_RE = /(?<![A-Za-z0-9])[A-Z0-9]{6,9}(?![A-Za-z0-9])/g;
28
+ const PPS_RE = /(?<![A-Za-z0-9])\d{7}[A-Za-z]{1,2}(?![A-Za-z0-9])/g;
29
+ const PL_DL_RE = /(?<![0-9/])\d{5}\/\d{2}\/\d{4,7}(?![0-9/])/g;
30
+ const CONTEXT_DIGIT_RE = /(?<![A-Za-z0-9])\d{7,12}(?![A-Za-z0-9])/g;
31
+ const INTL_PHONE_RE = /(?<!\w)\+\d{1,3}[ .-]?(?:\(?\d{1,5}\)?[ .-]?){1,5}\d{2,5}(?!\w)/g;
32
+ const GENERIC_PHONE_RE = /(?<!\w)(?:\+?\d{1,3}[ .-]?)?(?:\(?\d{2,5}\)?[ .-]?){2,5}\d{2,5}(?!\w)/g;
33
+ // keyword contexts
34
+ const IP_CTX = /\b(?:ip|ipv4|ipv6|address|addr|host|server|node|endpoint|cidr)\b|地址/i;
35
+ const ROUTING_CTX = /\b(?:routing|aba|bank|wire|ach)\b/i;
36
+ const SSN_CTX = /\b(?:ssn|social security|social insurance|social number|sin|seguridad social)\b|社保|社会保障|사회보장/i;
37
+ const TAX_CTX = /\b(?:tax|taxnum|tax number|tax identification|tin|vat|npwp)\b|税号|税|세금/i;
38
+ const GOV_CTX = /\b(?:national id|identity card|id card|government id|nric|fin|dni|nie|cpf|cnpj|passport)\b|身份证|주민등록/i;
39
+ const NAT_ID_CTX = /(?<![\p{L}\p{N}])(?:id|ident\w*|national|personal (?:id|number|code)|pesel|bsn|burgerservice\w*|egn|ЕГН|cnp|oib|amka|ΑΜΚΑ|isikukood|henkilötunnus|hetu|codice fiscale|rodné|personnummer|personas kods|asmens kodas|emšo|emso|matricule|rijksregister\w*|steuer\w*|dni|nie|nif|nir|insee|sécu\w*|sécurité sociale|secu\w*|rodn[eé]|ad[oó]azonos[ií]t[oó]|ad[oó]sz[aá]m|cpr|nip|partita iva|p\.?\s?iva|iva|vat|svnr|sozialversicherung\w*|pps\w*|tax|fiscal\w*|social|seguridad)(?![\p{L}\p{N}])/iu;
40
+ const VAT_CTX = /(?<![\p{L}\p{N}])(?:vat|ust[- ]?id\w*|umsatzsteuer|tva|iva|partita iva|btw|moms|alv|dph|di[cč]|pvn|pvm|dds|nip|nif|cif|[aá]fa|arvonlis\w*|fiscal\w*|tax)(?![\p{L}\p{N}])|ΑΦΜ|ФДС/iu;
41
+ const IMEI_CTX = /\bimei\b/i;
42
+ const SE_PN_CTX = /\b(?:personnummer|person\s*number|födelsenummer|personnr)\b/i;
43
+ const PASSPORT_CTX = /\b(?:passport|passeport|reisepass|pasaporte|passaporto|paspoort)\b/i;
44
+ const PPS_CTX = /\b(?:pps|ppsn|personal\s*public\s*service)\b/i;
45
+ const DK_CPR_CTX = /\bcpr\b/i;
46
+ const CREDIT_CTX = /\b(?:credit\s*card|debit\s*card|payment\s*card|bank\s*card|card\s*(?:number|no|num|info|ending|on file)|card\s*(?:charged|debited)|charged?\s*(?:my\s*|the\s*)?card|\bcard\b|visa|mastercard|master\s*card|maestro|amex|american\s*express|discover|diners|tarjeta|carte bancaire|kreditkarte|carta di credito|cartão)\b|信用卡|银行卡|カード|카드/i;
47
+ const PHONE_CTX = /\b(?:phone|mobile|tel(?:ephone)?|cell|call(?:\s*me)?|fax|whatsapp|sms|contact number|phone number|telefon(?:ní|nummer|szám|o|oon)?|teléfono|téléphone|telepon|mobil(?:e|ni|telefon)?|gsm|tlf|zavolejte|zadzwoń|appelez|appeler|téléphonez|chiamare|chiami|chiama|llame|llamar|llamada|ligue|ligar|bel(?:len)?|hívja|hívjon|sunați|sună|ring|ringa|nazovite|καλέστε|τηλέφωνο|телефон)\b|电话|電話|연락처|전화/i;
48
+ // document-type keyword recognizers (keyword is case-insensitive, value is
49
+ // uppercase - JS has no inline (?-i:), so we match the keyword then extract the
50
+ // value case-sensitively from the remaining text).
51
+ const DL_KW = /(?<![\p{L}\p{N}])(driving licen[cs]e|driver'?s? licen[cs]e|licence number|permis de conduire|permis de conduite|f[uü]hrerschein|fahrerlaubnis(?:nummer)?|patente(?: di guida)?|numero patente|prawo jazdy|rijbewijs(?:nummer)?|(?:carn[eé]|permiso) de conducir|carta de condu[cç][aã]o|k[oö]rkort(?:snummer)?|k[oø]rekort|ajokortti|vezet[oő]i enged[eé]ly|[rř]idi[cč]sk[\p{L}]* pr[uů]kaz[\p{L}]*|vodi[cč]sk[\p{L}]* preukaz[\p{L}]*|permis de conducere|voza[cč]k[au] dozvol[ae]|vozni[sš]ko dovoljenje|vairuotojo pa[zž]ym[eė]jimas|vad[iī]t[aā]ja apliec[iī]ba|juhiluba|licenzja tas-sewqan)(?:[\s.:\-]*(?:nr|no|nummer|number|num[eé]ro|n[°º])\.?)?/giu;
52
+ const DOC_ID_KW = /\b(passport|passeport|reisepass|pasaporte|passaporto|paspoort|national id|identity card|id card|identity number|identification number|id number|id no|personalausweis|ausweisnummer|ausweis|carte d.identit[eé]|documento de identidad|documento di identit[aà]|carta d.identit[aà]|identiteitskaart|n[uú]mero de identificaci[oó]n|de identifica[cç][aã]o|c[eé]dula)/gi;
53
+ const PASSPORT_KW = /^(passport|passeport|reisepass|pasaporte|passaporto|paspoort)/i;
54
+ // value after a document keyword: uppercase/digit token with single-char seps.
55
+ const DOC_VALUE = /^[^A-Z0-9\n]{0,18}?([A-Z0-9](?:[A-Z0-9]|[ .\-/](?=[A-Z0-9])){4,44})/;
56
+ const DL_VALUE = /^[^A-Z0-9\n]{0,14}?([A-Z0-9](?:[A-Z0-9]|[ .\-/](?=[A-Z0-9])){4,24})/;
57
+ const BIC_BEFORE = /(?:swift\s*[-/]?\s*bic|swift\s+code|bic(?:\s+code)?)\s*[:#=(\[]?\s*$/i;
58
+ const _IBAN_LEN = { AD: 24, AE: 23, AL: 28, AT: 20, AZ: 28, BA: 20, BE: 16, BG: 22, BH: 22, BR: 29, BY: 28, CH: 21, CR: 22, CY: 28, CZ: 24, DE: 22, DK: 18, DO: 28, EE: 20, EG: 29, ES: 24, FI: 18, FO: 18, FR: 27, GB: 22, GE: 22, GI: 23, GL: 18, GR: 27, GT: 28, HR: 21, HU: 28, IE: 22, IL: 23, IS: 26, IT: 27, JO: 30, KW: 30, KZ: 20, LB: 28, LC: 32, LI: 21, LT: 20, LU: 20, LV: 21, MC: 27, MD: 24, ME: 22, MK: 19, MR: 27, MT: 31, MU: 30, NL: 18, NO: 15, PK: 24, PL: 28, PS: 29, PT: 25, QA: 29, RO: 24, RS: 22, SA: 24, SC: 31, SE: 24, SI: 19, SK: 24, SM: 27, TN: 24, TR: 26, UA: 29, VG: 24, XK: 20 };
59
+ // ---------------------------------------------------------------- helpers
60
+ const dig = (s) => s.replace(/\D/g, "");
61
+ const dl = (s) => [...s].filter((c) => c >= "0" && c <= "9").map(Number);
62
+ const isAlnum = (c) => /[A-Za-z0-9]/.test(c);
63
+ const isAlpha = (c) => /[A-Za-z]/.test(c);
64
+ const wsum = (d, w) => d.reduce((a, x, i) => a + (i < w.length ? x * w[i] : 0), 0);
65
+ function hasContext(re, text, start, end, window = 48) {
66
+ const lo = Math.max(0, start - window), hi = Math.min(text.length, end + window);
67
+ return re.test(text.slice(lo, hi));
68
+ }
69
+ function before(re, text, start, window = 64) {
70
+ return re.test(text.slice(Math.max(0, start - window), start));
71
+ }
72
+ // ---------------------------------------------------------------- checksums
73
+ function luhnLen(d) {
74
+ let total = 0;
75
+ const parity = d.length % 2;
76
+ for (let i = 0; i < d.length; i++) {
77
+ let x = d[i];
78
+ if (i % 2 === parity) {
79
+ x *= 2;
80
+ if (x > 9)
81
+ x -= 9;
82
+ }
83
+ total += x;
84
+ }
85
+ return total % 10 === 0;
86
+ }
87
+ function luhnOk(num) { const d = dl(num); return d.length >= 13 && d.length <= 19 && luhnLen(d); }
88
+ function imeiOk(v) { const d = dl(v); return d.length === 15 && luhnLen(d); }
89
+ function validSePn(v) { let d = dl(v); if (d.length === 12)
90
+ d = d.slice(2); return d.length === 10 && luhnLen(d); }
91
+ function ibanOk(value) {
92
+ const s = value.replace(/\s+/g, "").toUpperCase();
93
+ if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(s))
94
+ return false;
95
+ if (s.length !== _IBAN_LEN[s.slice(0, 2)])
96
+ return false;
97
+ const r = s.slice(4) + s.slice(0, 4);
98
+ let rem = 0;
99
+ for (const c of r) {
100
+ const v = (c >= "A" && c <= "Z") ? (c.charCodeAt(0) - 55).toString() : c;
101
+ for (const ch of v)
102
+ rem = (rem * 10 + (ch.charCodeAt(0) - 48)) % 97;
103
+ }
104
+ return rem === 1;
105
+ }
106
+ function abaRoutingOk(v) { if (!/^\d{9}$/.test(v))
107
+ return false; const d = dl(v); return (3 * (d[0] + d[3] + d[6]) + 7 * (d[1] + d[4] + d[7]) + (d[2] + d[5] + d[8])) % 10 === 0; }
108
+ function validUsSsn(v) { const m = /^(\d{3})[- ](\d{2})[- ](\d{4})$/.exec(v); if (!m)
109
+ return false; const a = +m[1]; if (m[1] === "000" || m[1] === "666" || (a >= 900 && a <= 999))
110
+ return false; return m[2] !== "00" && m[3] !== "0000"; }
111
+ function bicOk(v) { if (v !== v.toUpperCase())
112
+ return false; if (!/^[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$/.test(v))
113
+ return false; const c = v.slice(4, 6); return c !== "AA" && c !== "ZZ"; }
114
+ function vinOk(v) {
115
+ const s = v.toUpperCase();
116
+ if (!/^[A-HJ-NPR-Z0-9]{17}$/.test(s))
117
+ return false;
118
+ const vals = { A: 1, B: 2, C: 3, D: 4, E: 5, F: 6, G: 7, H: 8, J: 1, K: 2, L: 3, M: 4, N: 5, P: 7, R: 9, S: 2, T: 3, U: 4, V: 5, W: 6, X: 7, Y: 8, Z: 9 };
119
+ const val = (c) => (c >= "0" && c <= "9") ? +c : vals[c];
120
+ const w = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2];
121
+ let sum = 0;
122
+ for (let i = 0; i < 17; i++)
123
+ sum += val(s[i]) * w[i];
124
+ const chk = sum % 11;
125
+ return s[8] === (chk === 10 ? "X" : String(chk));
126
+ }
127
+ function esDniOk(v) { const s = v.toUpperCase(); if (!/^(?:\d{8}|[XYZ]\d{7})$/.test(s.slice(0, -1)) || !/[A-Z]$/.test(s))
128
+ return false; if (!/^(?:\d{8}|[XYZ]\d{7})[A-Z]$/.test(s))
129
+ return false; const pfx = { X: "0", Y: "1", Z: "2" }[s[0]]; const num = pfx !== undefined ? +(pfx + s.slice(1, 8)) : +s.slice(0, 8); return s[s.length - 1] === "TRWAGMYFPDXBNJZSQVHLCKE"[num % 23]; }
130
+ // national IDs (by digit count)
131
+ function plPeselOk(v) { const d = dl(v); if (d.length !== 11)
132
+ return false; if ((10 - wsum(d.slice(0, 10), [1, 3, 7, 9, 1, 3, 7, 9, 1, 3]) % 10) % 10 !== d[10])
133
+ return false; const mm = (d[2] * 10 + d[3]) % 20; return mm >= 1 && mm <= 12 && (d[4] * 10 + d[5]) >= 1 && (d[4] * 10 + d[5]) <= 31; }
134
+ function nlBsnOk(v) { const d = dl(v); return d.length === 9 && d.some((x) => x) && (wsum(d.slice(0, 8), [9, 8, 7, 6, 5, 4, 3, 2]) - d[8]) % 11 === 0; }
135
+ function bgEgnOk(v) { const d = dl(v); if (d.length !== 10)
136
+ return false; let c = wsum(d.slice(0, 9), [2, 4, 8, 5, 10, 9, 7, 3, 6]) % 11; if (c === 10)
137
+ c = 0; if (c !== d[9])
138
+ return false; let mm = d[2] * 10 + d[3]; mm = (mm >= 21 && mm <= 32) ? mm - 20 : (mm >= 41 && mm <= 52) ? mm - 40 : mm; return mm >= 1 && mm <= 12 && (d[4] * 10 + d[5]) >= 1 && (d[4] * 10 + d[5]) <= 31; }
139
+ function roCnpOk(v) { const d = dl(v); if (d.length !== 13)
140
+ return false; let c = wsum(d.slice(0, 12), [2, 7, 9, 1, 4, 6, 3, 5, 8, 2, 7, 9]) % 11; if (c === 10)
141
+ c = 1; return c === d[12] && d[0] >= 1 && d[0] <= 9 && (d[3] * 10 + d[4]) >= 1 && (d[3] * 10 + d[4]) <= 12 && (d[5] * 10 + d[6]) >= 1 && (d[5] * 10 + d[6]) <= 31; }
142
+ function hrOibOk(v) { const d = dl(v); if (d.length !== 11)
143
+ return false; let r = 10; for (let i = 0; i < 10; i++) {
144
+ r = (r + d[i]) % 10 || 10;
145
+ r = (r * 2) % 11;
146
+ } return (11 - r) % 10 === d[10]; }
147
+ function eeIsikukoodOk(v) { const d = dl(v); if (d.length !== 11)
148
+ return false; let c = wsum(d.slice(0, 10), [1, 2, 3, 4, 5, 6, 7, 8, 9, 1]) % 11; if (c === 10) {
149
+ c = wsum(d.slice(0, 10), [3, 4, 5, 6, 7, 8, 9, 1, 2, 3]) % 11;
150
+ if (c === 10)
151
+ c = 0;
152
+ } return c === d[10]; }
153
+ function grAmkaOk(v) { const d = dl(v); if (d.length !== 11 || (d[2] * 10 + d[3]) < 1 || (d[2] * 10 + d[3]) > 12)
154
+ return false; return luhnLen(d); }
155
+ function ptNifOk(v) { const d = dl(v); if (d.length !== 9 || ![1, 2, 3, 5, 6, 8, 9].includes(d[0]))
156
+ return false; let c = 11 - wsum(d.slice(0, 8), [9, 8, 7, 6, 5, 4, 3, 2]) % 11; if (c >= 10)
157
+ c = 0; return c === d[8]; }
158
+ function frNirOk(v) { const d = dl(v); if (d.length !== 15 || (d[0] !== 1 && d[0] !== 2))
159
+ return false; const k = 97 - Number(d.slice(0, 13).join("")) % 97; return d[13] * 10 + d[14] === (k || 97); }
160
+ function beRrnOk(v) { const d = dl(v); if (d.length !== 11)
161
+ return false; const base = Number(d.slice(0, 9).join("")), chk = d[9] * 10 + d[10]; return (97 - base % 97) % 97 === chk || (97 - (2e9 + base) % 97) % 97 === chk; }
162
+ function czRcOk(v) { const d = dl(v); if (d.length !== 10)
163
+ return false; const mm = d[2] * 10 + d[3]; if (![0, 20, 50, 70].some((o) => mm - o >= 1 && mm - o <= 12))
164
+ return false; return Number(d.join("")) % 11 === 0; }
165
+ function siEmsoOk(v) { const d = dl(v); if (d.length !== 13 || (d[2] * 10 + d[3]) < 1 || (d[2] * 10 + d[3]) > 12)
166
+ return false; const m = wsum(d.slice(0, 12), [7, 6, 5, 4, 3, 2, 7, 6, 5, 4, 3, 2]) % 11; const chk = m === 0 ? 0 : 11 - m; return chk !== 10 && chk === d[12]; }
167
+ function huAdoazOk(v) { const d = dl(v); if (d.length !== 10 || d[0] !== 8)
168
+ return false; let c = 0; for (let i = 0; i < 9; i++)
169
+ c += d[i] * (i + 1); c %= 11; return c !== 10 && c === d[9]; }
170
+ function lvPkOk(v) { const d = dl(v); if (d.length !== 11 || d[0] === 3 || (d[2] * 10 + d[3]) < 1 || (d[2] * 10 + d[3]) > 12)
171
+ return false; return ((1 - wsum(d.slice(0, 10), [1, 6, 3, 7, 9, 10, 5, 8, 4, 2])) % 11 + 11) % 11 % 10 === d[10]; }
172
+ function plNipOk(v) { const d = dl(v); if (d.length !== 10)
173
+ return false; const c = wsum(d.slice(0, 9), [6, 5, 7, 2, 3, 4, 5, 6, 7]) % 11; return c !== 10 && c === d[9]; }
174
+ function itPivaOk(v) { const d = dl(v); if (d.length !== 11)
175
+ return false; let x = 0, y = 0; for (let i = 0; i < 10; i += 2)
176
+ x += d[i]; for (let i = 1; i < 10; i += 2) {
177
+ let t = d[i] * 2;
178
+ y += t > 9 ? t - 9 : t;
179
+ } return (10 - (x + y) % 10) % 10 === d[10]; }
180
+ function atSvnrOk(v) { const d = dl(v); if (d.length !== 10)
181
+ return false; const w = [3, 7, 9, 0, 5, 8, 4, 2, 1, 6]; let c = 0; for (let i = 0; i < 10; i++)
182
+ if (i !== 3)
183
+ c += d[i] * w[i]; c %= 11; return c !== 10 && c === d[3]; }
184
+ function iePpsOk(v) { const m = /^(\d{7})([A-W])([A-W]?)$/.exec(v.toUpperCase()); if (!m)
185
+ return false; let s = 0; for (let i = 0; i < 7; i++)
186
+ s += +m[1][i] * (8 - i); if (m[3])
187
+ s += (m[3].charCodeAt(0) - 64) * 9; return "WABCDEFGHIJKLMNOPQRSTUV"[s % 23] === m[2]; }
188
+ function itCfOk(v) { const s = v.toUpperCase(); if (!/^[A-Z0-9]{16}$/.test(s))
189
+ return false; const odd = { "0": 1, "1": 0, "2": 5, "3": 7, "4": 9, "5": 13, "6": 15, "7": 17, "8": 19, "9": 21, A: 1, B: 0, C: 5, D: 7, E: 9, F: 13, G: 15, H: 17, I: 19, J: 21, K: 2, L: 4, M: 18, N: 20, O: 11, P: 3, Q: 6, R: 8, S: 12, T: 14, U: 16, V: 10, W: 22, X: 25, Y: 24, Z: 23 }; const evenV = (c) => (c >= "0" && c <= "9") ? +c : c.charCodeAt(0) - 65; let tot = 0; for (let i = 0; i < 15; i++)
190
+ tot += (i % 2 === 0 ? odd[s[i]] : evenV(s[i])); return String.fromCharCode(65 + tot % 26) === s[15]; }
191
+ function fiHetuOk(v) { const m = /^(\d{6})[-+A-F](\d{3})([0-9A-Y])$/.exec(v.replace(/ /g, "").toUpperCase()); return !!m && "0123456789ABCDEFHJKLMNPRSTUVWXY"[Number(m[1] + m[2]) % 31] === m[3]; }
192
+ const NAT_VALIDATORS = {
193
+ 9: [nlBsnOk, ptNifOk],
194
+ 10: [bgEgnOk, czRcOk, huAdoazOk, plNipOk, atSvnrOk],
195
+ 11: [plPeselOk, hrOibOk, grAmkaOk, eeIsikukoodOk, lvPkOk, beRrnOk, itPivaOk],
196
+ 13: [roCnpOk, siEmsoOk],
197
+ 15: [frNirOk],
198
+ };
199
+ // VAT: per-country checksum (reuse national-ID checks where they coincide)
200
+ const V = {
201
+ AT: (n) => { if (!/^U\d{8}$/.test(n))
202
+ return false; let s = 4; for (let i = 0; i < 7; i++) {
203
+ let x = +n[1 + i] * (i % 2 ? 2 : 1);
204
+ s += x > 9 ? x - 9 : x;
205
+ } return (10 - s % 10) % 10 === +n[8]; },
206
+ BE: (n) => /^0\d{9}$/.test(n) && (97 - Number(n.slice(0, 8)) % 97) === +n.slice(8),
207
+ BG: (n) => { if (/^\d{9}$/.test(n)) {
208
+ const d = dl(n);
209
+ let s = 0;
210
+ for (let i = 0; i < 8; i++)
211
+ s += d[i] * (i + 1);
212
+ s %= 11;
213
+ if (s === 10) {
214
+ s = 0;
215
+ for (let i = 0; i < 8; i++)
216
+ s += d[i] * (i + 3);
217
+ s %= 11;
218
+ if (s === 10)
219
+ s = 0;
220
+ }
221
+ return s === d[8];
222
+ } return /^\d{10}$/.test(n) && bgEgnOk(n); },
223
+ CY: (n) => { if (!/^\d{8}[A-Z]$/.test(n))
224
+ return false; const tr = { 0: 1, 1: 0, 2: 5, 3: 7, 4: 9, 5: 13, 6: 15, 7: 17, 8: 19, 9: 21 }; let s = 0; for (let i = 0; i < 8; i++)
225
+ s += (i % 2 === 0 ? tr[+n[i]] : +n[i]); return String.fromCharCode(65 + s % 26) === n[8]; },
226
+ CZ: (n) => { if (/^\d{8}$/.test(n)) {
227
+ let s = 0;
228
+ for (let i = 0; i < 7; i++)
229
+ s += +n[i] * (8 - i);
230
+ s %= 11;
231
+ return (11 - s) % 10 === +n[7];
232
+ } return /^\d{10}$/.test(n) && czRcOk(n); },
233
+ DE: (n) => { if (!/^\d{9}$/.test(n))
234
+ return false; let p = 10; for (let i = 0; i < 8; i++) {
235
+ let s = (+n[i] + p) % 10 || 10;
236
+ p = (s * 2) % 11;
237
+ } return (11 - p) % 10 === +n[8]; },
238
+ DK: (n) => /^\d{8}$/.test(n) && wsum(dl(n), [2, 7, 6, 5, 4, 3, 2, 1]) % 11 === 0,
239
+ EE: (n) => /^\d{9}$/.test(n) && (10 - wsum(dl(n).slice(0, 8), [3, 7, 1, 3, 7, 1, 3, 7]) % 10) % 10 === +n[8],
240
+ EL: (n) => { if (!/^\d{9}$/.test(n))
241
+ return false; let s = 0; for (let i = 0; i < 8; i++)
242
+ s += +n[i] * (2 ** (8 - i)); s %= 11; return (s < 10 ? s : 0) === +n[8]; },
243
+ ES: (n) => /^[A-Z0-9]\d{7}[A-Z0-9]$/.test(n),
244
+ FI: (n) => { if (!/^\d{8}$/.test(n))
245
+ return false; const s = wsum(dl(n).slice(0, 7), [7, 9, 10, 5, 8, 4, 2]) % 11; return s !== 1 && (s === 0 ? 0 : 11 - s) === +n[7]; },
246
+ FR: (n) => { if (!/^\d{11}$/.test(n))
247
+ return false; const siren = n.slice(2); const d = dl(siren); let ls = 0; for (let i = 0; i < d.length; i++) {
248
+ let x = d[d.length - 1 - i] * (i % 2 ? 2 : 1);
249
+ ls += x > 9 ? x - 9 : x;
250
+ } return ls % 10 === 0 && +n.slice(0, 2) === (12 + 3 * (Number(siren) % 97)) % 97; },
251
+ HR: (n) => hrOibOk(n), HU: (n) => /^\d{8}$/.test(n) && (10 - wsum(dl(n).slice(0, 7), [9, 7, 3, 1, 9, 7, 3]) % 10) % 10 === +n[7],
252
+ IE: (n) => { const m = /^(\d{7})([A-W])([A-W]?)$/.exec(n); if (!m)
253
+ return false; let s = 0; for (let i = 0; i < 7; i++)
254
+ s += +m[1][i] * (8 - i); if (m[3])
255
+ s += (m[3].charCodeAt(0) - 64) * 9; return "WABCDEFGHIJKLMNOPQRSTUV"[s % 23] === m[2]; },
256
+ IT: (n) => itPivaOk(n),
257
+ LT: (n) => { if (!/^(\d{9}|\d{12})$/.test(n))
258
+ return false; const d = dl(n), k = d.length; let s = 0; for (let i = 0; i < k - 1; i++)
259
+ s += d[i] * ((i % 9) + 1); s %= 11; if (s === 10) {
260
+ s = 0;
261
+ for (let i = 0; i < k - 1; i++)
262
+ s += d[i] * ((i % 9) + 3);
263
+ s %= 11;
264
+ if (s === 10)
265
+ s = 0;
266
+ } return s === d[k - 1]; },
267
+ LU: (n) => /^\d{8}$/.test(n) && Number(n.slice(0, 6)) % 89 === +n.slice(6),
268
+ LV: (n) => /^\d{11}$/.test(n), MT: (n) => /^\d{8}$/.test(n) && (37 - wsum(dl(n).slice(0, 6), [3, 4, 6, 7, 8, 9]) % 37) % 37 === +n.slice(6),
269
+ NL: (n) => /^\d{9}B\d{2}$/.test(n), PL: (n) => plNipOk(n), PT: (n) => ptNifOk(n),
270
+ RO: (n) => { if (!/^\d{2,10}$/.test(n))
271
+ return false; const b = n.slice(0, -1); const full = [7, 5, 3, 2, 1, 7, 5, 3, 2]; const w = full.slice(full.length - b.length); let s = 0; for (let i = 0; i < b.length; i++)
272
+ s += +b[i] * w[i]; s = (s * 10) % 11; return (s === 10 ? 0 : s) === +n[n.length - 1]; },
273
+ SE: (n) => { if (!/^\d{10}01$/.test(n))
274
+ return false; const d = dl(n.slice(0, 10)); let ls = 0; for (let i = 0; i < 10; i++) {
275
+ let x = d[i] * (i % 2 === 0 ? 2 : 1);
276
+ ls += x > 9 ? x - 9 : x;
277
+ } return ls % 10 === 0; },
278
+ SI: (n) => { if (!/^\d{8}$/.test(n))
279
+ return false; const s = wsum(dl(n).slice(0, 7), [8, 7, 6, 5, 4, 3, 2]) % 11; const c = 11 - s; return c !== 10 && (c === 11 ? 0 : c) === +n[7]; },
280
+ SK: (n) => /^\d{10}$/.test(n) && Number(n) % 11 === 0,
281
+ };
282
+ V.GR = V.EL;
283
+ const VAT_FMT_ONLY = new Set(["ES", "LV", "NL"]);
284
+ // ---------------------------------------------------------------- merge
285
+ function merge(spans) {
286
+ const ordered = spans.slice().sort((a, b) => a.start - b.start || (b.end - b.start) - (a.end - a.start) || a.label.localeCompare(b.label));
287
+ const out = [];
288
+ for (const s of ordered) {
289
+ if (!out.length || s.start >= out[out.length - 1].end)
290
+ out.push(s);
291
+ else {
292
+ const p = out[out.length - 1];
293
+ const sl = s.end - s.start, pl = p.end - p.start;
294
+ if (sl > pl || (sl === pl && s.score > p.score))
295
+ out[out.length - 1] = s;
296
+ }
297
+ }
298
+ return out;
299
+ }
300
+ // trim a document-keyword value that swallowed a following lowercase word
301
+ function trimTrailingWord(text, val, end) {
302
+ while (val.length >= 2 && isAlpha(val[val.length - 1]) && val[val.length - 2] === " " && end < text.length && text[end] >= "a" && text[end] <= "z") {
303
+ val = val.slice(0, -2);
304
+ end -= 2;
305
+ }
306
+ return [val, end];
307
+ }
308
+ export function deterministicDetect(text, enabled) {
309
+ const en = enabled ? new Set(enabled) : OWNED;
310
+ const spans = [];
311
+ const add = (s, e, label, score = 1.0) => { if (s < e)
312
+ spans.push({ start: s, end: e, label, score }); };
313
+ for (const m of text.matchAll(EMAIL_RE))
314
+ add(m.index, m.index + m[1].length, "EMAIL");
315
+ for (const m of text.matchAll(URL_RE)) {
316
+ if (m.index > 0 && text[m.index - 1] === "@")
317
+ continue;
318
+ add(m.index, m.index + m[1].length, "URL");
319
+ }
320
+ for (const m of text.matchAll(IPV4_RE))
321
+ if (isIp(m[0]) && hasContext(IP_CTX, text, m.index, m.index + m[0].length, 40))
322
+ add(m.index, m.index + m[0].length, "IP_ADDRESS");
323
+ for (const m of text.matchAll(IPV6_RE)) {
324
+ if (m[0] === ":" || m[0] === "::")
325
+ continue;
326
+ if (isIp(m[0]) && hasContext(IP_CTX, text, m.index, m.index + m[0].length, 40))
327
+ add(m.index, m.index + m[0].length, "IP_ADDRESS");
328
+ }
329
+ for (const m of text.matchAll(MAC_RE))
330
+ add(m.index, m.index + m[0].length, "IP_ADDRESS");
331
+ for (const m of text.matchAll(CC_RE)) {
332
+ const val = m[0];
333
+ if (new Set(dig(val)).size <= 1)
334
+ continue;
335
+ const b = text.slice(Math.max(0, m.index - 56), m.index);
336
+ if (luhnOk(val) && CREDIT_CTX.test(b)) {
337
+ let end = m.index + val.length;
338
+ while (end > m.index && " -.".includes(text[end - 1]))
339
+ end--;
340
+ add(m.index, end, "CREDIT_CARD");
341
+ }
342
+ }
343
+ for (const m of text.matchAll(IBAN_RE))
344
+ if (ibanOk(m[0]))
345
+ add(m.index, m.index + m[0].length, "BANK_ACCOUNT");
346
+ for (const m of text.matchAll(BIC_RE))
347
+ if (bicOk(m[0]) && before(BIC_BEFORE, text, m.index, 64))
348
+ add(m.index, m.index + m[0].length, "BANK_ACCOUNT");
349
+ for (const m of text.matchAll(ES_DNI_RE))
350
+ if (esDniOk(m[0]) && hasContext(GOV_CTX, text, m.index, m.index + m[0].length, 56))
351
+ add(m.index, m.index + m[0].length, "GOVERNMENT_ID");
352
+ for (const m of text.matchAll(NAT_ID_RE)) {
353
+ const d = m[0];
354
+ const n = dl(d).length;
355
+ const vals = NAT_VALIDATORS[n];
356
+ if (vals && vals.some((fn) => fn(d)) && hasContext(NAT_ID_CTX, text, m.index, m.index + d.length, 64))
357
+ add(m.index, m.index + d.length, "GOVERNMENT_ID", 0.92);
358
+ }
359
+ for (const m of text.matchAll(IT_CF_RE))
360
+ if (itCfOk(m[0]))
361
+ add(m.index, m.index + m[0].length, "GOVERNMENT_ID", 0.95);
362
+ for (const m of text.matchAll(FI_HETU_RE))
363
+ if (fiHetuOk(m[0]))
364
+ add(m.index, m.index + m[0].length, "GOVERNMENT_ID", 0.95);
365
+ for (const m of text.matchAll(DK_CPR_RE)) {
366
+ const d = dl(m[0]);
367
+ if ((d[0] * 10 + d[1]) >= 1 && (d[0] * 10 + d[1]) <= 31 && (d[2] * 10 + d[3]) >= 1 && (d[2] * 10 + d[3]) <= 12 && before(DK_CPR_CTX, text, m.index, 40))
368
+ add(m.index, m.index + m[0].length, "GOVERNMENT_ID", 0.85);
369
+ }
370
+ for (const m of text.matchAll(VAT_RE)) {
371
+ const cc = m[1];
372
+ const num = m[2].replace(/\s/g, "").toUpperCase();
373
+ const fn = V[cc];
374
+ if (fn && fn(num) && (!VAT_FMT_ONLY.has(cc) || hasContext(VAT_CTX, text, m.index, m.index + m[0].length, 40)))
375
+ add(m.index, m.index + m[0].length, "TAX_ID", 0.95);
376
+ }
377
+ for (const m of text.matchAll(IMEI_RE))
378
+ if (imeiOk(m[0]) && hasContext(IMEI_CTX, text, m.index, m.index + m[0].length, 32))
379
+ add(m.index, m.index + m[0].length, "IMEI", 0.9);
380
+ for (const m of text.matchAll(SSN_RE))
381
+ if (validUsSsn(m[0]) || hasContext(SSN_CTX, text, m.index, m.index + m[0].length))
382
+ add(m.index, m.index + m[0].length, "SSN");
383
+ for (const m of text.matchAll(SE_PN_RE))
384
+ if (validSePn(m[0]) || before(SE_PN_CTX, text, m.index, 40))
385
+ add(m.index, m.index + m[0].length, "GOVERNMENT_ID");
386
+ for (const m of text.matchAll(PASSPORT_VAL_RE))
387
+ if (/\d/.test(m[0]) && before(PASSPORT_CTX, text, m.index, 32))
388
+ add(m.index, m.index + m[0].length, "PASSPORT");
389
+ // driving licences: keyword then case-sensitive value
390
+ for (const m of text.matchAll(DL_KW)) {
391
+ const vm = DL_VALUE.exec(text.slice(m.index + m[0].length));
392
+ if (!vm)
393
+ continue;
394
+ const vs = m.index + m[0].length + vm.index + vm[0].indexOf(vm[1]);
395
+ let [val, end] = trimTrailingWord(text, vm[1], vs + vm[1].length);
396
+ if ([...val].filter(isAlnum).length >= 5)
397
+ add(vs, end, "DRIVERS_LICENSE", 0.9);
398
+ }
399
+ for (const m of text.matchAll(PL_DL_RE))
400
+ add(m.index, m.index + m[0].length, "DRIVERS_LICENSE", 0.9);
401
+ for (const m of text.matchAll(DOC_ID_KW)) {
402
+ const vm = DOC_VALUE.exec(text.slice(m.index + m[0].length));
403
+ if (!vm)
404
+ continue;
405
+ const vs = m.index + m[0].length + vm.index + vm[0].indexOf(vm[1]);
406
+ let [val, end] = trimTrailingWord(text, vm[1], vs + vm[1].length);
407
+ if ([...val].filter((c) => c >= "0" && c <= "9").length >= 1 && [...val].filter(isAlnum).length >= 6) {
408
+ const lab = PASSPORT_KW.test(m[1]) ? "PASSPORT" : "GOVERNMENT_ID";
409
+ add(vs, end, lab, 0.9);
410
+ }
411
+ }
412
+ for (const m of text.matchAll(PPS_RE))
413
+ if (iePpsOk(m[0]) || hasContext(PPS_CTX, text, m.index, m.index + m[0].length, 32))
414
+ add(m.index, m.index + m[0].length, "GOVERNMENT_ID");
415
+ for (const m of text.matchAll(CONTEXT_DIGIT_RE)) {
416
+ const d = dig(m[0]);
417
+ const b = text.slice(Math.max(0, m.index - 56), m.index);
418
+ if (d.length >= 7 && d.length <= 12 && SSN_CTX.test(b) && !TAX_CTX.test(b))
419
+ add(m.index, m.index + m[0].length, "SSN", 0.9);
420
+ }
421
+ for (const m of text.matchAll(ROUTING_RE))
422
+ if (abaRoutingOk(m[0]) && hasContext(ROUTING_CTX, text, m.index, m.index + m[0].length))
423
+ add(m.index, m.index + m[0].length, "ROUTING_NUMBER");
424
+ for (const m of text.matchAll(INTL_PHONE_RE)) {
425
+ const n = dig(m[0]).length;
426
+ if (n >= 8 && n <= 15)
427
+ add(m.index, m.index + m[0].length, "PHONE", 0.92);
428
+ }
429
+ for (const m of text.matchAll(GENERIC_PHONE_RE)) {
430
+ const d = dig(m[0]);
431
+ const b = text.slice(Math.max(0, m.index - 56), m.index);
432
+ const grouped = /[ .\-]/.test(m[0]);
433
+ if (PHONE_CTX.test(b) && ((d.length >= 9 && d.length <= 15) || (d.length >= 7 && d.length <= 8 && grouped)))
434
+ add(m.index, m.index + m[0].length, "PHONE", 0.88);
435
+ }
436
+ return merge(spans).filter((s) => en.has(s.label)).map((s) => ({ start: s.start, end: s.end, label: s.label }));
437
+ }
438
+ function isIp(v) {
439
+ if (/^(\d{1,3}\.){3}\d{1,3}$/.test(v))
440
+ return v.split(".").every((p) => +p <= 255);
441
+ if (/^[0-9a-f:]+$/i.test(v) && v.includes(":") && v !== ":" && v !== "::") {
442
+ const parts = v.split("::");
443
+ if (parts.length > 2)
444
+ return false;
445
+ return true;
446
+ }
447
+ return false;
448
+ }
449
+ export { OWNED as OWNED_LABELS };
@@ -0,0 +1,21 @@
1
+ import { Redact, type LoadOptions } from "./model.js";
2
+ import type { Options, Redaction, Span } from "./types.js";
3
+ export { Redact, type LoadOptions } from "./model.js";
4
+ export type { Options, Redaction, RedactionItem, Span, RedactLabel } from "./types.js";
5
+ export { deterministicDetect, OWNED_LABELS } from "./deterministic.js";
6
+ /**
7
+ * Default loading configuration. Mutate before the first {@link redaction} /
8
+ * {@link detect} call, or pass overrides to {@link load}.
9
+ */
10
+ export declare const env: LoadOptions;
11
+ /** Loads the model (Hugging Face Hub, cached) or a local dir if `localPath` is set. */
12
+ export declare function load(options?: LoadOptions): Promise<Redact>;
13
+ /**
14
+ * Redact the PII in `text`, returning the masked text, the detections, and a
15
+ * `restore` helper. The model loads (and caches) lazily on first call.
16
+ */
17
+ export declare function redaction(text: string, options?: Options): Promise<Redaction>;
18
+ /** Detect PII spans in `text` without building placeholders. */
19
+ export declare function detect(text: string, options?: Options): Promise<Span[]>;
20
+ /** Clears the memoized model so the next call re-reads {@link env}. */
21
+ export declare function reset(): void;
package/dist/index.js ADDED
@@ -0,0 +1,42 @@
1
+ import { Redact } from "./model.js";
2
+ export { Redact } from "./model.js";
3
+ export { deterministicDetect, OWNED_LABELS } from "./deterministic.js";
4
+ /**
5
+ * Default loading configuration. Mutate before the first {@link redaction} /
6
+ * {@link detect} call, or pass overrides to {@link load}.
7
+ */
8
+ export const env = {
9
+ repo: "desert-ant-labs/redact",
10
+ revision: "v0.2.0",
11
+ useCache: true,
12
+ };
13
+ /** Loads the model (Hugging Face Hub, cached) or a local dir if `localPath` is set. */
14
+ export async function load(options = {}) {
15
+ return Redact.load({ ...env, ...options });
16
+ }
17
+ let modelPromise = null;
18
+ function shared() {
19
+ if (!modelPromise) {
20
+ // Don't cache a rejected load: clear the slot on failure so the next call retries.
21
+ modelPromise = load().catch((err) => {
22
+ modelPromise = null;
23
+ throw err;
24
+ });
25
+ }
26
+ return modelPromise;
27
+ }
28
+ /**
29
+ * Redact the PII in `text`, returning the masked text, the detections, and a
30
+ * `restore` helper. The model loads (and caches) lazily on first call.
31
+ */
32
+ export async function redaction(text, options = {}) {
33
+ return (await shared()).redaction(text, options);
34
+ }
35
+ /** Detect PII spans in `text` without building placeholders. */
36
+ export async function detect(text, options = {}) {
37
+ return (await shared()).detect(text, options);
38
+ }
39
+ /** Clears the memoized model so the next call re-reads {@link env}. */
40
+ export function reset() {
41
+ modelPromise = null;
42
+ }
@@ -0,0 +1,40 @@
1
+ import type { Options, Redaction, Span } from "./types.js";
2
+ /** How to resolve the model files. */
3
+ export interface LoadOptions {
4
+ /** Model repo id. Default `"desert-ant-labs/redact"`. */
5
+ repo?: string;
6
+ /** Pinned revision (tag/branch/commit). Default `"v0.2.0"`. */
7
+ revision?: string;
8
+ /** Load from a local directory of model files instead of the Hugging Face Hub. */
9
+ localPath?: string;
10
+ /** Cache downloaded files. Default `true`. */
11
+ useCache?: boolean;
12
+ /** Directory for the on-disk cache (Node). */
13
+ cacheDir?: string;
14
+ /** transformers.js weight dtype. Default `"fp32"` (the published int8 ONNX). */
15
+ dtype?: string;
16
+ }
17
+ /**
18
+ * On-device multilingual PII redaction. A tiny BIOES token classifier (via
19
+ * transformers.js / ONNX Runtime) reconciled with a dependency-free deterministic
20
+ * recognizer layer. Load once with {@link Redact.load} and reuse.
21
+ */
22
+ export declare class Redact {
23
+ private readonly tokenizer;
24
+ private readonly model;
25
+ private readonly id2label;
26
+ private readonly specialIds;
27
+ private constructor();
28
+ /** Loads the model from the Hugging Face Hub (cached) or a local directory. */
29
+ static load(options?: LoadOptions): Promise<Redact>;
30
+ private reconstructOffsets;
31
+ private mlSpans;
32
+ /** Detect PII spans (the full deterministic-owner hybrid pipeline). */
33
+ detect(text: string, options?: Options): Promise<Span[]>;
34
+ /**
35
+ * Detect and redact the PII in `text`. Each entity is replaced by a unique,
36
+ * numbered placeholder (`[EMAIL_1]`, `[GIVEN_NAME_1]`, ...) so the result is
37
+ * safe to hand to an LLM and restore afterwards via {@link Redaction.restore}.
38
+ */
39
+ redaction(text: string, options?: Options): Promise<Redaction>;
40
+ }