crawlforge-extractors 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +57 -0
- package/index.js +2 -0
- package/package.json +1 -1
- package/src/pii.js +359 -0
package/index.d.ts
CHANGED
|
@@ -365,3 +365,60 @@ export declare function rankUnits(
|
|
|
365
365
|
query: string,
|
|
366
366
|
options?: { maxUnits?: number; minScore?: number }
|
|
367
367
|
): RankedHighlightUnit[];
|
|
368
|
+
|
|
369
|
+
/** The four entity classes `redactPii` finds with regex alone, no model. */
|
|
370
|
+
export declare const REGEX_ENTITIES: readonly ['EMAIL', 'PHONE', 'FINANCIAL', 'SECRET'];
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* The entity classes regex cannot find. `redactPii` never handles these — a
|
|
374
|
+
* caller wanting them routes the text through its own model pass and is
|
|
375
|
+
* charged separately for it.
|
|
376
|
+
*/
|
|
377
|
+
export declare const MODEL_ONLY_ENTITIES: readonly ['PERSON', 'LOCATION'];
|
|
378
|
+
|
|
379
|
+
/** The style `redactPii` uses when the caller names none: `<EMAIL>` and friends. */
|
|
380
|
+
export declare const DEFAULT_REPLACE_STYLE: 'tag';
|
|
381
|
+
|
|
382
|
+
/** What a redaction pass changed: a count per entity class, and the total. */
|
|
383
|
+
export interface PiiRedaction {
|
|
384
|
+
/** Per-class replacement counts. A class with no hits is omitted, not zero. */
|
|
385
|
+
entities: Record<string, number>;
|
|
386
|
+
/** Total replacements made across every class. */
|
|
387
|
+
count: number;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Replace the personal and secret data in a string, in one pass, with no model.
|
|
392
|
+
*
|
|
393
|
+
* Detectors contribute spans over the original text and the string is rebuilt
|
|
394
|
+
* once at the end, so overlaps are resolved a single time and nothing is
|
|
395
|
+
* counted twice. Accept order is SECRET, EMAIL, FINANCIAL, PHONE: a labelled
|
|
396
|
+
* credential beats whatever its value looks like, and a card number can never
|
|
397
|
+
* afterwards be re-read as a phone number.
|
|
398
|
+
*
|
|
399
|
+
* Precision is preferred to recall throughout — this runs on arbitrary page
|
|
400
|
+
* text, where a false positive silently destroys content the caller paid to
|
|
401
|
+
* scrape. Card numbers must pass Luhn and IBANs mod-97; prices, dates,
|
|
402
|
+
* version numbers and long ids are left alone.
|
|
403
|
+
*
|
|
404
|
+
* Markdown-safe. `scrape` returns markdown by default and turndown escapes it,
|
|
405
|
+
* writing `_` as `\_` and a line-leading `-` as `\-`, so the EMAIL local part
|
|
406
|
+
* and the `api_key` SECRET label match through that escape: an escaped address
|
|
407
|
+
* is redacted as ONE span (`simon\_lacey@example.com` becomes `<EMAIL>`, never
|
|
408
|
+
* `simon\<EMAIL>` with a count of 1 beside the surviving name). The escape is
|
|
409
|
+
* tolerated, never removed — text outside a replaced span comes back
|
|
410
|
+
* byte-identical, offsets intact. No other detector needs the tolerance.
|
|
411
|
+
*
|
|
412
|
+
* `entities` names are upper-cased and intersected with `REGEX_ENTITIES`.
|
|
413
|
+
* Passing `undefined`, a non-array or an EMPTY array means all four, because a
|
|
414
|
+
* missing selection should fail towards more redaction — but an array that
|
|
415
|
+
* leaves nothing behind (`['PERSON']`) redacts NOTHING rather than falling
|
|
416
|
+
* back to all. An unknown `replaceStyle` is treated as `'tag'`. SECRET keeps
|
|
417
|
+
* its label and replaces only the value in every style, `'remove'` included.
|
|
418
|
+
*
|
|
419
|
+
* Never throws: a non-string `text` comes back unchanged with a zero count.
|
|
420
|
+
*/
|
|
421
|
+
export declare function redactPii(
|
|
422
|
+
text: string,
|
|
423
|
+
options?: { entities?: string[]; replaceStyle?: 'tag' | 'mask' | 'remove' }
|
|
424
|
+
): { text: string; redaction: PiiRedaction };
|
package/index.js
CHANGED
|
@@ -35,3 +35,5 @@ export { shopifyProductFromJsonLd } from './src/shopifyJsonLd.js';
|
|
|
35
35
|
export { detectChallengePage, documentVerdict, SOFT_ERROR_MAX_CHARS } from './src/blockedPage.js';
|
|
36
36
|
|
|
37
37
|
export { segmentUnits, rankUnits } from './src/highlights.js';
|
|
38
|
+
|
|
39
|
+
export { redactPii, REGEX_ENTITIES, MODEL_ONLY_ENTITIES, DEFAULT_REPLACE_STYLE } from './src/pii.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-extractors",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "Extraction logic shared by the CrawlForge MCP server and REST API — scrape templates, charset-correct capped body reading, structural fingerprinting, embedded-state extraction, and query-scoped highlights. One implementation, so the two surfaces cannot drift apart.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
package/src/pii.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pii.js — redact the personal data a scraped page carries, before that text
|
|
3
|
+
* reaches a model, a log line or a customer's context window.
|
|
4
|
+
*
|
|
5
|
+
* A scrape returns whatever the page holds, and pages hold support-inbox
|
|
6
|
+
* addresses, staff phone lists, a checkout form's card number and — in an
|
|
7
|
+
* error page or a leaked config block — an API key. The MCP server already
|
|
8
|
+
* scrubbed the last of those out of its own logs (`secretMask.js`
|
|
9
|
+
* `redactSecretsFromString`); nothing scrubbed the page text it hands back.
|
|
10
|
+
* That heuristic moves here so both surfaces run one implementation, and it
|
|
11
|
+
* arrives with three more entity classes beside it.
|
|
12
|
+
*
|
|
13
|
+
* Everything in this module is pure regex over a string: no network, no
|
|
14
|
+
* model, no cheerio, deterministic. That is the point. A model-backed pass
|
|
15
|
+
* for PERSON and LOCATION exists on the calling side, is opt-in, and is
|
|
16
|
+
* priced separately; this module never invokes it. If a caller asks for
|
|
17
|
+
* PERSON or LOCATION here they are ignored in silence — `MODEL_ONLY_ENTITIES`
|
|
18
|
+
* is exported so the caller can route them, and so the two surfaces read one
|
|
19
|
+
* declaration of which entities a regex cannot decide.
|
|
20
|
+
*
|
|
21
|
+
* ## Precision over recall, deliberately
|
|
22
|
+
*
|
|
23
|
+
* This runs over arbitrary page text that a customer paid to scrape. A false
|
|
24
|
+
* positive silently destroys real content — a redacted price is worse than an
|
|
25
|
+
* un-redacted phone number, because the customer can see the second one and
|
|
26
|
+
* cannot recover the first. Every detector here is therefore built to be
|
|
27
|
+
* conservative and to fail by *not* matching:
|
|
28
|
+
*
|
|
29
|
+
* - a bare run of digits is never a phone number. A phone must carry a `+`
|
|
30
|
+
* country code or a real separator-bearing NANP shape;
|
|
31
|
+
* - a card number must pass **Luhn**, and an IBAN must pass **mod-97**. Both
|
|
32
|
+
* checks exist to reject the coincidence, not to validate the account;
|
|
33
|
+
* - a candidate that a check rejects is dropped, not retried shorter. Missing
|
|
34
|
+
* one number is the cheap failure.
|
|
35
|
+
*
|
|
36
|
+
* What it deliberately does NOT catch, so nobody reports these as bugs:
|
|
37
|
+
*
|
|
38
|
+
* - **PERSON, LOCATION** — model-only, by definition. No regex knows that
|
|
39
|
+
* "Paris Hilton" is not a hotel in France.
|
|
40
|
+
* - **7-digit local phone numbers** (`555-1234`) — indistinguishable from a
|
|
41
|
+
* part number, a date range or a score at scale.
|
|
42
|
+
* - **non-NANP domestic phone numbers written without a `+`** (`020 7123
|
|
43
|
+
* 4567`, `0912-345-678`) — a leading 0 area code is the shape of far too
|
|
44
|
+
* many identifiers. Written in E.164 (`+44 20 7123 4567`) they are caught.
|
|
45
|
+
* - **lowercase IBANs** — IBANs are printed uppercase; accepting lowercase
|
|
46
|
+
* would make every long word a candidate.
|
|
47
|
+
* - **national ID numbers, passport numbers, dates of birth, addresses,
|
|
48
|
+
* licence plates** — all of them are locale-specific digit runs with no
|
|
49
|
+
* check digit worth trusting.
|
|
50
|
+
* - **secrets with no label** — a bare `sk-live-...` in prose is not matched.
|
|
51
|
+
* The SECRET heuristic needs `Bearer `, or an `api_key` / `x-api-key` /
|
|
52
|
+
* `password` / `secret` / `token` label followed by `:` or `=`.
|
|
53
|
+
* - **markdown escapes other than `\\_` and a line-leading `\\-`** — see the
|
|
54
|
+
* next section. Those two are the only escaped characters that land inside
|
|
55
|
+
* a token this module has to match through; the rest (`\\*`, `` \\` ``,
|
|
56
|
+
* `\\[`, `\\]`) are not legal in an address or a label in the first place,
|
|
57
|
+
* so admitting them would widen the false-positive surface for nothing.
|
|
58
|
+
*
|
|
59
|
+
* ## Markdown escaping
|
|
60
|
+
*
|
|
61
|
+
* `scrape` returns markdown by default, and turndown escapes the characters
|
|
62
|
+
* markdown gives meaning to: `\\`, `*`, `` ` ``, `[`, `]` and `_` anywhere in
|
|
63
|
+
* a text node, and `-`, `+ `, `=`, `#`, `>` and `~~~` only at the start of a
|
|
64
|
+
* line. So `simon_lacey@example.com` reaches this module written
|
|
65
|
+
* `simon\\_lacey@example.com`, and the naive pattern matches only from the
|
|
66
|
+
* `_` onwards.
|
|
67
|
+
*
|
|
68
|
+
* That is worse than a miss. `simon\\<EMAIL>` reports `count: 1` — a
|
|
69
|
+
* successful redaction — while the given name is still sitting in the text.
|
|
70
|
+
* A redaction report that overstates what it redacted is the one failure this
|
|
71
|
+
* feature cannot have; a clean miss at least says `count: 0`. So the EMAIL
|
|
72
|
+
* local part and the `api_key` label tolerate the escape, and an escaped run
|
|
73
|
+
* matches as ONE span: `simon\\_lacey@example.com` becomes `<EMAIL>` entire.
|
|
74
|
+
*
|
|
75
|
+
* The escape is tolerated, never removed. Nothing here unescapes and
|
|
76
|
+
* re-escapes the text — the caller's string comes back with spans replaced
|
|
77
|
+
* and every character outside a span byte-identical.
|
|
78
|
+
*
|
|
79
|
+
* The other detectors were checked against turndown's own escape table and
|
|
80
|
+
* need no tolerance, which a round-trip test pins: PHONE and FINANCIAL use
|
|
81
|
+
* space, `.` and `-` as separators, and `-` is escaped only as the first
|
|
82
|
+
* character of a line, where a phone or card number begins with a digit, `+`
|
|
83
|
+
* or `(`. IBANs are letters, digits and spaces. `Bearer`, `x-api-key`,
|
|
84
|
+
* `password`, `secret` and `token` contain nothing escapable — only
|
|
85
|
+
* `api_key` does. And secret *values* never needed it: `\\S+` already matches
|
|
86
|
+
* a backslash, so `token=sk\\_live\\_abc` was always redacted whole. Code
|
|
87
|
+
* blocks are not escaped at all — turndown indents them instead.
|
|
88
|
+
*
|
|
89
|
+
* ## Detector order
|
|
90
|
+
*
|
|
91
|
+
* All detectors run over the ORIGINAL string and contribute spans; the string
|
|
92
|
+
* is rebuilt once at the end. Spans are accepted in this order, and a span
|
|
93
|
+
* that overlaps an already-accepted one is dropped — so nothing is counted or
|
|
94
|
+
* replaced twice, and a card number claimed by FINANCIAL can never afterwards
|
|
95
|
+
* be re-read as a PHONE:
|
|
96
|
+
*
|
|
97
|
+
* 1. SECRET — a labelled credential wins over whatever its value looks
|
|
98
|
+
* like (`password: ops@example.com` is a SECRET, not an
|
|
99
|
+
* EMAIL).
|
|
100
|
+
* 2. EMAIL — before the numeric detectors, so digits inside an address
|
|
101
|
+
* are not mistaken for an account number.
|
|
102
|
+
* 3. FINANCIAL — cards and IBANs before PHONE, so `4242 4242 4242 4242`
|
|
103
|
+
* can never be read as a phone number.
|
|
104
|
+
* 4. PHONE — the least specific shape, so it goes last.
|
|
105
|
+
*
|
|
106
|
+
* Within PHONE the E.164 patterns run before the NANP one, so `+1 555 123
|
|
107
|
+
* 4567` is one match and not a NANP match with a stray `+1` in front.
|
|
108
|
+
*
|
|
109
|
+
* ## The SECRET invariant
|
|
110
|
+
*
|
|
111
|
+
* SECRET keeps its label and replaces only the value — `Bearer <SECRET>`,
|
|
112
|
+
* `api_key=<SECRET>` — in every replace style, including `remove` (which
|
|
113
|
+
* leaves `Bearer `). That is not cosmetic: the MCP server's `maskError`
|
|
114
|
+
* depends on the label surviving so an error message still says *which*
|
|
115
|
+
* credential the request carried. Every other entity replaces the whole match.
|
|
116
|
+
*/
|
|
117
|
+
|
|
118
|
+
/** The entity classes this module decides with a regex. */
|
|
119
|
+
export const REGEX_ENTITIES = Object.freeze(['EMAIL', 'PHONE', 'FINANCIAL', 'SECRET']);
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The entity classes a regex cannot decide. `redactPii` ignores these; a
|
|
123
|
+
* caller that offers them routes them to its own model pass.
|
|
124
|
+
*/
|
|
125
|
+
export const MODEL_ONLY_ENTITIES = Object.freeze(['PERSON', 'LOCATION']);
|
|
126
|
+
|
|
127
|
+
/** The replace style used when the caller does not choose one. */
|
|
128
|
+
export const DEFAULT_REPLACE_STYLE = 'tag';
|
|
129
|
+
|
|
130
|
+
/** The `mask` style's replacement — the same constant secretMask.js uses. */
|
|
131
|
+
const MASK = '[REDACTED]';
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Patterns
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
// A standard local@domain with a real TLD. The TLD floor of two letters is
|
|
138
|
+
// what keeps "user@localhost" and "@handle" out.
|
|
139
|
+
//
|
|
140
|
+
// The local part also admits the pair \_ (and \-), which is how turndown
|
|
141
|
+
// writes an underscore, or a line-leading hyphen, in markdown — see the
|
|
142
|
+
// markdown-escaping section of the header. It is the PAIR, not a bare
|
|
143
|
+
// backslash in the character class: the escape can only appear where the
|
|
144
|
+
// character it escapes was already legal, so the local part still cannot run
|
|
145
|
+
// across a space or any other boundary. The domain needs no such tolerance —
|
|
146
|
+
// turndown escapes nothing that is legal in one.
|
|
147
|
+
const EMAIL =
|
|
148
|
+
/(?:[A-Za-z0-9._%+-]|\\[_-])+@[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*\.[A-Za-z]{2,24}\b/g;
|
|
149
|
+
|
|
150
|
+
// E.164 written as one run: +15551234567. The digit count is checked in code
|
|
151
|
+
// (ITU allows at most 15), not in the quantifier, so the rule is readable.
|
|
152
|
+
const PHONE_E164_RUN = /(?<![\w+])\+\d{8,15}(?!\w)/g;
|
|
153
|
+
|
|
154
|
+
// E.164 written in groups: +44 20 7123 4567, +1 (555) 123-4567, +1-555-123-4567.
|
|
155
|
+
// Every group after the country code must be introduced by a separator or be
|
|
156
|
+
// parenthesised, which is what stops "+1" swallowing the number beside it.
|
|
157
|
+
const PHONE_E164_GROUPED = /(?<![\w+])\+\d{1,3}(?:[ .-]?\(\d{1,4}\)|[ .-]\d{1,4}){1,5}(?!\w)/g;
|
|
158
|
+
|
|
159
|
+
// NANP with separators, parenthesised: (555) 123-4567, 1 (555) 123-4567.
|
|
160
|
+
const PHONE_NANP_PARENS = /(?<![\w.,-])(?:1[ .-]?)?\([2-9]\d{2}\)[ .]?\d{3}[ .-]\d{4}(?![\w-])/g;
|
|
161
|
+
|
|
162
|
+
// NANP with separators, plain: 555-123-4567, 555.123.4567, 555 123 4567,
|
|
163
|
+
// 1-555-123-4567. Three things carry the precision here. The [2-9] on the area
|
|
164
|
+
// code is a real NANP rule and rejects 012/123/100 and the other
|
|
165
|
+
// counting-sequence lookalikes (the exchange code cannot take the same rule:
|
|
166
|
+
// 555-123-4567, the number every document uses, has 123 there). The
|
|
167
|
+
// backreference makes the separator the same character throughout, which is
|
|
168
|
+
// how a real number is written and how "1,234.567 8901" is not. And the
|
|
169
|
+
// leading (?<![\w.,-]) keeps this out of ISBNs, dates, versions, grouped money
|
|
170
|
+
// and hyphenated identifiers while the trailing (?![\w-]) keeps it out of
|
|
171
|
+
// longer digit runs.
|
|
172
|
+
const PHONE_NANP_PLAIN = /(?<![\w.,-])(?:1[ .-])?[2-9]\d{2}([ .-])\d{3}\1\d{4}(?![\w-])/g;
|
|
173
|
+
|
|
174
|
+
// A payment card: 13-19 digits, either contiguous or in consistent groups of
|
|
175
|
+
// 2-6 separated by one repeated space or hyphen (\1 is the backreference that
|
|
176
|
+
// enforces "the same separator throughout"). Groups of at least two digits
|
|
177
|
+
// are what stops a table row of single digits reading as an account number.
|
|
178
|
+
// Luhn does the rest. The lookarounds keep it out of decimals.
|
|
179
|
+
const CARD = /(?<![\w-])(?<!\d\.)(?:\d{13,19}|\d{2,6}(?:([ -])\d{2,6})(?:\1\d{2,6})*)(?![\w-])(?!\.\d)/g;
|
|
180
|
+
|
|
181
|
+
// An IBAN: country code, two check digits, then the account part either
|
|
182
|
+
// contiguous or in the conventional groups of four. mod-97 gates it.
|
|
183
|
+
const IBAN = /(?<![A-Za-z0-9])[A-Z]{2}\d{2}(?:[A-Z0-9]{11,30}|(?: [A-Z0-9]{4})+(?: [A-Z0-9]{1,3})?)(?![A-Za-z0-9])/g;
|
|
184
|
+
|
|
185
|
+
// The secret heuristics, ported verbatim from the MCP server's
|
|
186
|
+
// secretMask.js redactSecretsFromString, in its original order. Group 1 is
|
|
187
|
+
// the label and is preserved; the span replaced is everything after it.
|
|
188
|
+
const SECRET_PATTERNS = [
|
|
189
|
+
/(Bearer\s+)\S+/gi,
|
|
190
|
+
/(api(?:\\?[_-])?key\s*[:=]\s*)\S+/gi,
|
|
191
|
+
/(x-api-key\s*[:=]\s*)\S+/gi,
|
|
192
|
+
/(password\s*[:=]\s*)\S+/gi,
|
|
193
|
+
/(secret\s*[:=]\s*)\S+/gi,
|
|
194
|
+
/(token\s*[:=]\s*)\S+/gi
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// Checks
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Luhn. 13-19 digits, and never a run of one repeated digit — 0000000000000000
|
|
203
|
+
* satisfies Luhn and is a placeholder, not a card.
|
|
204
|
+
* @param {string} candidate
|
|
205
|
+
* @returns {boolean}
|
|
206
|
+
*/
|
|
207
|
+
function luhnOk(candidate) {
|
|
208
|
+
const digits = candidate.replace(/\D/g, '');
|
|
209
|
+
if (digits.length < 13 || digits.length > 19) return false;
|
|
210
|
+
if (/^(\d)\1*$/.test(digits)) return false;
|
|
211
|
+
let sum = 0;
|
|
212
|
+
let double = false;
|
|
213
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
214
|
+
let d = digits.charCodeAt(i) - 48;
|
|
215
|
+
if (double) {
|
|
216
|
+
d *= 2;
|
|
217
|
+
if (d > 9) d -= 9;
|
|
218
|
+
}
|
|
219
|
+
sum += d;
|
|
220
|
+
double = !double;
|
|
221
|
+
}
|
|
222
|
+
return sum % 10 === 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* ISO 13616 mod-97: move the first four characters to the end, map A-Z to
|
|
227
|
+
* 10-35, and the remainder of the resulting number modulo 97 must be 1.
|
|
228
|
+
* Computed digit by digit so no value ever leaves the safe integer range.
|
|
229
|
+
* @param {string} candidate
|
|
230
|
+
* @returns {boolean}
|
|
231
|
+
*/
|
|
232
|
+
function ibanOk(candidate) {
|
|
233
|
+
const compact = candidate.replace(/ /g, '');
|
|
234
|
+
if (compact.length < 15 || compact.length > 34) return false;
|
|
235
|
+
const rearranged = compact.slice(4) + compact.slice(0, 4);
|
|
236
|
+
let remainder = 0;
|
|
237
|
+
for (const ch of rearranged) {
|
|
238
|
+
const value = ch >= 'A' ? ch.charCodeAt(0) - 55 : ch.charCodeAt(0) - 48;
|
|
239
|
+
if (value < 0 || value > 35) return false;
|
|
240
|
+
remainder = (remainder * (value > 9 ? 100 : 10) + value) % 97;
|
|
241
|
+
}
|
|
242
|
+
return remainder === 1;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** E.164 allows 8-15 digits in total, country code included. */
|
|
246
|
+
function e164Ok(candidate) {
|
|
247
|
+
const digits = candidate.replace(/\D/g, '').length;
|
|
248
|
+
return digits >= 8 && digits <= 15;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Detectors, in the order their spans are accepted
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
const DETECTORS = [
|
|
256
|
+
{ entity: 'SECRET', patterns: SECRET_PATTERNS.map(re => ({ re, keepsLabel: true })) },
|
|
257
|
+
{ entity: 'EMAIL', patterns: [{ re: EMAIL }] },
|
|
258
|
+
{ entity: 'FINANCIAL', patterns: [{ re: CARD, check: luhnOk }, { re: IBAN, check: ibanOk }] },
|
|
259
|
+
{
|
|
260
|
+
entity: 'PHONE',
|
|
261
|
+
patterns: [
|
|
262
|
+
{ re: PHONE_E164_RUN, check: e164Ok },
|
|
263
|
+
{ re: PHONE_E164_GROUPED, check: e164Ok },
|
|
264
|
+
{ re: PHONE_NANP_PARENS },
|
|
265
|
+
{ re: PHONE_NANP_PLAIN }
|
|
266
|
+
]
|
|
267
|
+
}
|
|
268
|
+
];
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Every span the enabled detectors claim, in detector order, with overlaps
|
|
272
|
+
* resolved in favour of whichever detector ran first. Returned sorted by
|
|
273
|
+
* start so the caller can rebuild the string in one pass.
|
|
274
|
+
* @param {string} text
|
|
275
|
+
* @param {Set<string>} entities
|
|
276
|
+
* @returns {Array<{ start: number, end: number, entity: string }>}
|
|
277
|
+
*/
|
|
278
|
+
function collectSpans(text, entities) {
|
|
279
|
+
const accepted = [];
|
|
280
|
+
for (const detector of DETECTORS) {
|
|
281
|
+
if (!entities.has(detector.entity)) continue;
|
|
282
|
+
for (const { re, check, keepsLabel } of detector.patterns) {
|
|
283
|
+
re.lastIndex = 0;
|
|
284
|
+
let match;
|
|
285
|
+
while ((match = re.exec(text)) !== null) {
|
|
286
|
+
if (match[0].length === 0) {
|
|
287
|
+
re.lastIndex++;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const end = match.index + match[0].length;
|
|
291
|
+
const start = keepsLabel ? match.index + match[1].length : match.index;
|
|
292
|
+
if (start >= end) continue;
|
|
293
|
+
if (check && !check(text.slice(start, end))) continue;
|
|
294
|
+
if (accepted.some(span => start < span.end && span.start < end)) continue;
|
|
295
|
+
accepted.push({ start, end, entity: detector.entity });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return accepted.sort((a, b) => a.start - b.start);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* @param {string} entity
|
|
304
|
+
* @param {'tag'|'mask'|'remove'} style
|
|
305
|
+
* @returns {string}
|
|
306
|
+
*/
|
|
307
|
+
function replacementFor(entity, style) {
|
|
308
|
+
if (style === 'mask') return MASK;
|
|
309
|
+
if (style === 'remove') return '';
|
|
310
|
+
return `<${entity}>`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Redact the entities this module can decide with a regex.
|
|
315
|
+
*
|
|
316
|
+
* `entities` narrows the run to its intersection with REGEX_ENTITIES. A name
|
|
317
|
+
* this module does not handle — PERSON and LOCATION included — is dropped in
|
|
318
|
+
* silence, and a selection that leaves nothing behind redacts nothing: a
|
|
319
|
+
* caller asking only for PERSON wants its own model pass, not everything.
|
|
320
|
+
* Anything that is not a non-empty array means all of REGEX_ENTITIES, because
|
|
321
|
+
* a missing selection should fail towards more redaction, not less.
|
|
322
|
+
*
|
|
323
|
+
* `replaceStyle` is 'tag' (`<EMAIL>`), 'mask' (`[REDACTED]`) or 'remove' (the
|
|
324
|
+
* empty string); anything else is treated as 'tag'. SECRET keeps its label in
|
|
325
|
+
* every style — see the module header.
|
|
326
|
+
*
|
|
327
|
+
* Never throws. A non-string `text` comes back untouched with a zero count.
|
|
328
|
+
*
|
|
329
|
+
* @param {string} text
|
|
330
|
+
* @param {{ entities?: string[], replaceStyle?: 'tag'|'mask'|'remove' }} [options]
|
|
331
|
+
* @returns {{ text: string, redaction: { entities: Record<string, number>, count: number } }}
|
|
332
|
+
*/
|
|
333
|
+
export function redactPii(text, { entities, replaceStyle } = {}) {
|
|
334
|
+
if (typeof text !== 'string' || text.length === 0) {
|
|
335
|
+
return { text, redaction: { entities: {}, count: 0 } };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const requested = Array.isArray(entities) && entities.length > 0
|
|
339
|
+
? entities.filter(name => typeof name === 'string').map(name => name.toUpperCase())
|
|
340
|
+
: REGEX_ENTITIES;
|
|
341
|
+
const wanted = new Set(requested.filter(name => REGEX_ENTITIES.includes(name)));
|
|
342
|
+
if (wanted.size === 0) return { text, redaction: { entities: {}, count: 0 } };
|
|
343
|
+
|
|
344
|
+
const spans = collectSpans(text, wanted);
|
|
345
|
+
if (spans.length === 0) return { text, redaction: { entities: {}, count: 0 } };
|
|
346
|
+
|
|
347
|
+
const style = replaceStyle === 'mask' || replaceStyle === 'remove' ? replaceStyle : DEFAULT_REPLACE_STYLE;
|
|
348
|
+
const counts = {};
|
|
349
|
+
let out = '';
|
|
350
|
+
let cursor = 0;
|
|
351
|
+
for (const span of spans) {
|
|
352
|
+
out += text.slice(cursor, span.start) + replacementFor(span.entity, style);
|
|
353
|
+
counts[span.entity] = (counts[span.entity] || 0) + 1;
|
|
354
|
+
cursor = span.end;
|
|
355
|
+
}
|
|
356
|
+
out += text.slice(cursor);
|
|
357
|
+
|
|
358
|
+
return { text: out, redaction: { entities: counts, count: spans.length } };
|
|
359
|
+
}
|