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 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.8.0",
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
+ }