@crewhaus/tool-secure 0.7.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,38 @@
1
+ export type InjectionCategory = "override" | "role" | "exfiltration" | "tool-coercion" | "authority" | "secrecy" | "concealment";
2
+ export type InjectionRule = {
3
+ readonly id: string;
4
+ readonly category: InjectionCategory;
5
+ readonly weight: number;
6
+ readonly description: string;
7
+ readonly pattern: RegExp;
8
+ };
9
+ /** The phrase rules. Also run against decoded base64, once, no recursion. */
10
+ export declare const INJECTION_RULES: ReadonlyArray<InjectionRule>;
11
+ /** Decoded blobs are bounded so one base64 wall cannot blow up memory. */
12
+ export declare const MAX_BASE64_CANDIDATE_CHARS = 100000;
13
+ /** Decoding happens once. Content inside a decoded blob is never decoded again. */
14
+ export declare const BASE64_DEPTH = 1;
15
+ export type InjectionHit = {
16
+ readonly rule: string;
17
+ readonly category: InjectionCategory;
18
+ readonly weight: number;
19
+ readonly description: string;
20
+ readonly line: number;
21
+ readonly column: number;
22
+ readonly start: number;
23
+ /** Attacker-controlled text, capped. For a human to read, not a model to obey. */
24
+ readonly excerpt: string;
25
+ readonly source: "text" | "decoded-base64";
26
+ };
27
+ export type InjectionResult = {
28
+ readonly score: number;
29
+ readonly band: "none" | "low" | "medium" | "high";
30
+ readonly hits: ReadonlyArray<InjectionHit>;
31
+ readonly categories: ReadonlyArray<InjectionCategory>;
32
+ /** How many base64 blobs were decoded and rescanned. */
33
+ readonly decodedBlobs: number;
34
+ };
35
+ /** Decode a base64 blob when it is valid and decodes to mostly printable text. */
36
+ export declare function decodeBase64Text(candidate: string): string | undefined;
37
+ /** Run every rule. `text` is untrusted by assumption. */
38
+ export declare function scanInjection(text: string): InjectionResult;
@@ -0,0 +1,396 @@
1
+ /**
2
+ * Prompt-injection heuristics.
3
+ *
4
+ * ## This is not a defence
5
+ *
6
+ * It is a smoke detector. It matches phrasings and structural tricks that
7
+ * have shown up in real injections, and an attacker who has read this file —
8
+ * which is public — can write around every one of them in a minute. A low
9
+ * score means "none of these patterns fired", never "this content is safe to
10
+ * hand to a model with tools".
11
+ *
12
+ * The actual defences are architectural and live elsewhere: untrusted
13
+ * content stays data, tool calls need permission, side-effectful actions are
14
+ * confirmed by the user, and a model's instructions never come from the
15
+ * content it is reading. `PromptInjectionScan` is worth running because it
16
+ * is free and it catches the lazy attempt — triage, not a gate.
17
+ *
18
+ * ## Scoring
19
+ *
20
+ * Each rule carries a weight. The score sums each DISTINCT rule's weight
21
+ * once, capped at 100: a document that says "ignore previous instructions"
22
+ * forty times is not forty times more suspicious than one that says it once,
23
+ * and rewarding repetition would make the score trivially inflatable.
24
+ *
25
+ * ## Excerpts
26
+ *
27
+ * A hit carries a short excerpt so a human can judge it. That excerpt is
28
+ * attacker-controlled text. Show it to a person; do not paste it into a
29
+ * prompt as if it were instructions.
30
+ */
31
+ import { compareStrings, matchAll, withPositions } from "./text";
32
+ import { scanInvisible } from "./unicode";
33
+ /** The phrase rules. Also run against decoded base64, once, no recursion. */
34
+ export const INJECTION_RULES = [
35
+ {
36
+ id: "override.ignore-previous",
37
+ category: "override",
38
+ weight: 35,
39
+ description: "asks the reader to ignore earlier instructions",
40
+ pattern: /\bignore\s+(?:all\s+)?(?:of\s+)?(?:the\s+)?(?:previous|prior|above|earlier|preceding)\s+(?:instructions?|directions?|prompts?|rules?|messages?|context)/gi,
41
+ },
42
+ {
43
+ id: "override.disregard",
44
+ category: "override",
45
+ weight: 30,
46
+ description: "asks the reader to disregard what came before",
47
+ pattern: /\bdisregard\s+(?:all\s+)?(?:the\s+)?(?:above|previous|prior|earlier|foregoing|your\s+instructions)/gi,
48
+ },
49
+ {
50
+ id: "override.forget",
51
+ category: "override",
52
+ weight: 25,
53
+ description: "asks the reader to forget its instructions",
54
+ pattern: /\bforget\s+(?:everything|all\s+(?:previous|prior)|your\s+(?:instructions|rules|training))/gi,
55
+ },
56
+ {
57
+ id: "override.new-instructions",
58
+ category: "override",
59
+ weight: 25,
60
+ description: "announces replacement instructions",
61
+ pattern: /\b(?:new|updated|revised)\s+(?:system\s+)?(?:instructions?|directives?|rules?)\s*[:\-]/gi,
62
+ },
63
+ {
64
+ id: "override.chat-markup",
65
+ category: "override",
66
+ weight: 30,
67
+ description: "embeds chat-template or role markup, trying to close the current turn",
68
+ pattern: /<\/?(?:system|assistant|human|user)>|\[\/?INST\]|<\|im_(?:start|end)\|>|^\s*###\s*(?:instruction|system)/gim,
69
+ },
70
+ {
71
+ id: "role.you-are-now",
72
+ category: "role",
73
+ weight: 20,
74
+ description: "reassigns the reader's role",
75
+ pattern: /\byou\s+are\s+(?:now|no\s+longer)\b/gi,
76
+ },
77
+ {
78
+ id: "role.pretend",
79
+ category: "role",
80
+ weight: 15,
81
+ description: "role-play framing used to move past a refusal",
82
+ pattern: /\bpretend\s+(?:to\s+be|that\s+you|you\s+are)\b/gi,
83
+ },
84
+ {
85
+ id: "role.jailbreak-persona",
86
+ category: "role",
87
+ weight: 25,
88
+ description: "names a known jailbreak persona or mode",
89
+ pattern: /\b(?:DAN\s+mode|developer\s+mode|god\s+mode|unfiltered\s+mode|jailbreak(?:en)?)\b/gi,
90
+ },
91
+ {
92
+ id: "exfil.reveal-prompt",
93
+ category: "exfiltration",
94
+ weight: 35,
95
+ description: "asks for the system prompt or hidden instructions",
96
+ pattern: /\b(?:reveal|repeat|print|show|output|display|disclose)\s+(?:me\s+)?(?:your|the)\s+(?:full\s+|entire\s+|original\s+)?(?:system\s+)?(?:prompt|instructions?|rules|configuration)/gi,
97
+ },
98
+ {
99
+ id: "exfil.repeat-above",
100
+ category: "exfiltration",
101
+ weight: 30,
102
+ description: "asks for the preceding context verbatim",
103
+ pattern: /\brepeat\s+(?:the\s+)?(?:text|everything|all)\s+(?:above|before)/gi,
104
+ },
105
+ {
106
+ id: "exfil.send-elsewhere",
107
+ category: "exfiltration",
108
+ weight: 35,
109
+ description: "asks for data to be sent to an address the content supplies",
110
+ pattern: /\b(?:send|post|upload|forward|email|exfiltrate)\s+(?:it|this|them|the\s+\w+(?:\s+\w+)?)\s+to\s+(?:https?:\/\/\S+|\S+@\S+\.\w+)/gi,
111
+ },
112
+ {
113
+ id: "tool.run-command",
114
+ category: "tool-coercion",
115
+ weight: 25,
116
+ description: "instructs the reader to run supplied code",
117
+ pattern: /\b(?:run|execute|eval(?:uate)?)\s+(?:the\s+)?(?:following|this|these)\s+(?:command|script|code|snippet)/gi,
118
+ },
119
+ {
120
+ id: "tool.pipe-to-shell",
121
+ category: "tool-coercion",
122
+ weight: 35,
123
+ description: "a download-and-execute one-liner",
124
+ pattern: /\b(?:curl|wget)\s+[^\n|]{1,200}\|\s*(?:sudo\s+)?(?:ba|z|d|fi)?sh\b/gi,
125
+ },
126
+ {
127
+ id: "tool.credential-request",
128
+ category: "tool-coercion",
129
+ weight: 30,
130
+ description: "asks for credentials to be produced or entered",
131
+ pattern: /\b(?:paste|provide|enter|reveal|share|type)\s+(?:your|the)\s+(?:api[\s_-]?key|password|secret|token|credentials?|private\s+key)/gi,
132
+ },
133
+ {
134
+ id: "authority.claimed",
135
+ category: "authority",
136
+ weight: 20,
137
+ description: "the content claims to be the operator, developer or system",
138
+ pattern: /\b(?:i\s+am|this\s+is)\s+(?:the\s+)?(?:system|administrator|admin|developer|operator|your\s+(?:developer|creator|owner|principal))\b/gi,
139
+ },
140
+ {
141
+ id: "authority.preauthorized",
142
+ category: "authority",
143
+ weight: 25,
144
+ description: "the content asserts the user already consented",
145
+ pattern: /\b(?:the\s+)?(?:user|owner|operator|human)\s+(?:has\s+)?(?:already\s+)?(?:approved|authorized|authorised|consented|pre-?approved)\b/gi,
146
+ },
147
+ {
148
+ id: "authority.policy-override",
149
+ category: "authority",
150
+ weight: 30,
151
+ description: "asks for safety or policy constraints to be set aside",
152
+ pattern: /\b(?:override|bypass|ignore|suspend|disable)\s+(?:your\s+|the\s+)?(?:safety|security|content|policy|policies|guidelines?|restrictions?|filters?)/gi,
153
+ },
154
+ {
155
+ id: "secrecy.do-not-tell",
156
+ category: "secrecy",
157
+ weight: 30,
158
+ description: "asks for the action to be hidden from the user",
159
+ pattern: /\b(?:do\s*n[o']?t|never|avoid)\s+(?:tell|telling|inform|informing|mention(?:ing)?\s+to|notify(?:ing)?|alert(?:ing)?)\s+(?:the\s+)?(?:user|human|operator|owner)/gi,
160
+ },
161
+ {
162
+ id: "secrecy.silently",
163
+ category: "secrecy",
164
+ weight: 15,
165
+ description: "asks for an action without confirmation",
166
+ pattern: /\b(?:silently|quietly|without\s+(?:asking|telling|informing|confirmation|notifying))\b/gi,
167
+ },
168
+ ];
169
+ const HTML_COMMENT = /<!--([\s\S]{0,4000}?)-->/g;
170
+ const HIDDEN_STYLE = /style\s*=\s*["'][^"']{0,200}(?:display\s*:\s*none|visibility\s*:\s*hidden|font-size\s*:\s*0|opacity\s*:\s*0)/gi;
171
+ const MARKDOWN_LINK = /\[([^\]\n]{1,200})\]\(([^)\s]{1,500})\)/g;
172
+ const BASE64_BLOB = /[A-Za-z0-9+/]{40,}={0,2}/g;
173
+ const DOMAIN_IN_TEXT = /\b(?:https?:\/\/)?([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+)/i;
174
+ /** Decoded blobs are bounded so one base64 wall cannot blow up memory. */
175
+ export const MAX_BASE64_CANDIDATE_CHARS = 100_000;
176
+ /** Decoding happens once. Content inside a decoded blob is never decoded again. */
177
+ export const BASE64_DEPTH = 1;
178
+ const EXCERPT_CHARS = 120;
179
+ function excerptAt(text, start, length) {
180
+ const slice = text.slice(start, start + Math.min(length, EXCERPT_CHARS));
181
+ return slice.length < length ? `${slice}…` : slice;
182
+ }
183
+ function band(score) {
184
+ if (score === 0)
185
+ return "none";
186
+ if (score < 25)
187
+ return "low";
188
+ if (score < 50)
189
+ return "medium";
190
+ return "high";
191
+ }
192
+ /** Decode a base64 blob when it is valid and decodes to mostly printable text. */
193
+ export function decodeBase64Text(candidate) {
194
+ if (candidate.length > MAX_BASE64_CANDIDATE_CHARS)
195
+ return undefined;
196
+ if (candidate.length % 4 !== 0)
197
+ return undefined;
198
+ let decoded;
199
+ try {
200
+ const buf = Buffer.from(candidate, "base64");
201
+ if (buf.length === 0)
202
+ return undefined;
203
+ // Round-trip check: Buffer.from is permissive, and a blob that does not
204
+ // re-encode to itself was not base64 in the first place.
205
+ if (buf.toString("base64") !== candidate)
206
+ return undefined;
207
+ decoded = buf.toString("utf8");
208
+ }
209
+ catch {
210
+ return undefined;
211
+ }
212
+ let printable = 0;
213
+ for (let i = 0; i < decoded.length; i++) {
214
+ const code = decoded.charCodeAt(i);
215
+ if (code === 9 || code === 10 || code === 13 || (code >= 32 && code < 127))
216
+ printable++;
217
+ }
218
+ return printable / decoded.length >= 0.85 ? decoded : undefined;
219
+ }
220
+ function phraseHits(text, source) {
221
+ const raw = [];
222
+ for (const rule of INJECTION_RULES) {
223
+ for (const { index, match } of matchAll(text, rule.pattern)) {
224
+ raw.push({
225
+ type: rule.category,
226
+ rule: rule.id,
227
+ confidence: "possible",
228
+ start: index,
229
+ end: index + match[0].length,
230
+ value: match[0],
231
+ detail: { weight: rule.weight, source },
232
+ });
233
+ }
234
+ }
235
+ return raw;
236
+ }
237
+ const RULE_BY_ID = new Map(INJECTION_RULES.map((r) => [r.id, r]));
238
+ const STRUCTURAL = new Map([
239
+ [
240
+ "conceal.invisible-characters",
241
+ {
242
+ category: "concealment",
243
+ weight: 25,
244
+ description: "zero-width, tag or bidi characters hide content from a human reviewer",
245
+ },
246
+ ],
247
+ [
248
+ "conceal.html-comment",
249
+ {
250
+ category: "concealment",
251
+ weight: 20,
252
+ description: "instruction-shaped text inside an HTML comment, invisible when rendered",
253
+ },
254
+ ],
255
+ [
256
+ "conceal.hidden-style",
257
+ {
258
+ category: "concealment",
259
+ weight: 25,
260
+ description: "markup that renders text invisible (display:none, zero font size, zero opacity)",
261
+ },
262
+ ],
263
+ [
264
+ "conceal.link-cloaking",
265
+ {
266
+ category: "concealment",
267
+ weight: 20,
268
+ description: "a markdown link whose visible text names a different destination than its target",
269
+ },
270
+ ],
271
+ [
272
+ "conceal.base64-instructions",
273
+ {
274
+ category: "concealment",
275
+ weight: 35,
276
+ description: "a base64 blob that decodes to instruction-shaped text",
277
+ },
278
+ ],
279
+ ]);
280
+ /** Run every rule. `text` is untrusted by assumption. */
281
+ export function scanInjection(text) {
282
+ const raw = phraseHits(text, "text");
283
+ // Concealment: invisible characters.
284
+ const invisible = scanInvisible(text).filter((hit) => hit.class === "zero-width" || hit.class === "tag" || hit.class === "bidi-control");
285
+ const firstInvisible = invisible[0];
286
+ if (firstInvisible) {
287
+ raw.push({
288
+ type: "concealment",
289
+ rule: "conceal.invisible-characters",
290
+ confidence: "possible",
291
+ start: firstInvisible.start,
292
+ end: firstInvisible.end,
293
+ value: firstInvisible.label,
294
+ detail: { weight: 25, count: invisible.length },
295
+ });
296
+ }
297
+ // Concealment: instruction text inside an HTML comment.
298
+ for (const { index, match } of matchAll(text, HTML_COMMENT)) {
299
+ const inner = match[1] ?? "";
300
+ if (phraseHits(inner, "text").length === 0)
301
+ continue;
302
+ raw.push({
303
+ type: "concealment",
304
+ rule: "conceal.html-comment",
305
+ confidence: "possible",
306
+ start: index,
307
+ end: index + match[0].length,
308
+ value: match[0].slice(0, EXCERPT_CHARS),
309
+ detail: { weight: 20 },
310
+ });
311
+ }
312
+ for (const { index, match } of matchAll(text, HIDDEN_STYLE)) {
313
+ raw.push({
314
+ type: "concealment",
315
+ rule: "conceal.hidden-style",
316
+ confidence: "possible",
317
+ start: index,
318
+ end: index + match[0].length,
319
+ value: match[0].slice(0, EXCERPT_CHARS),
320
+ detail: { weight: 25 },
321
+ });
322
+ }
323
+ // Concealment: a link whose label names a different host than its target.
324
+ for (const { index, match } of matchAll(text, MARKDOWN_LINK)) {
325
+ const label = match[1] ?? "";
326
+ const href = match[2] ?? "";
327
+ const labelDomain = DOMAIN_IN_TEXT.exec(label)?.[1]?.toLowerCase();
328
+ const hrefScheme = /^([a-z][a-z0-9+.-]*):/i.exec(href)?.[1]?.toLowerCase();
329
+ const hrefDomain = DOMAIN_IN_TEXT.exec(href.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ""))?.[1]?.toLowerCase();
330
+ const dangerousScheme = hrefScheme === "javascript" || hrefScheme === "data" || hrefScheme === "vbscript";
331
+ const cloaked = labelDomain !== undefined &&
332
+ (dangerousScheme || (hrefDomain !== undefined && hrefDomain !== labelDomain));
333
+ if (!cloaked)
334
+ continue;
335
+ raw.push({
336
+ type: "concealment",
337
+ rule: "conceal.link-cloaking",
338
+ confidence: "possible",
339
+ start: index,
340
+ end: index + match[0].length,
341
+ value: match[0].slice(0, EXCERPT_CHARS),
342
+ detail: { weight: 20 },
343
+ });
344
+ }
345
+ // Concealment: base64 that decodes to instructions. Depth 1, by construction.
346
+ let decodedBlobs = 0;
347
+ for (const { index, match } of matchAll(text, BASE64_BLOB)) {
348
+ const decoded = decodeBase64Text(match[0]);
349
+ if (decoded === undefined)
350
+ continue;
351
+ decodedBlobs += 1;
352
+ const inner = phraseHits(decoded, "decoded-base64");
353
+ if (inner.length === 0)
354
+ continue;
355
+ const firstRule = inner[0]?.rule ?? "";
356
+ raw.push({
357
+ type: "concealment",
358
+ rule: "conceal.base64-instructions",
359
+ confidence: "possible",
360
+ start: index,
361
+ end: index + match[0].length,
362
+ value: decoded.slice(0, EXCERPT_CHARS),
363
+ detail: { weight: 35, decodedRule: firstRule },
364
+ });
365
+ }
366
+ // A total order: two hits at the same offset under the same rule must
367
+ // compare equal, or `sort` is free to order them differently on a different
368
+ // run and the "same input, same bytes" claim stops holding.
369
+ const located = withPositions(text, raw).sort((a, b) => a.start - b.start || compareStrings(a.rule, b.rule) || a.end - b.end);
370
+ const hits = located.map((f) => {
371
+ const rule = RULE_BY_ID.get(f.rule);
372
+ const structural = STRUCTURAL.get(f.rule);
373
+ const source = f.detail?.["source"] === "decoded-base64" ? "decoded-base64" : "text";
374
+ return {
375
+ rule: f.rule,
376
+ category: (rule?.category ?? structural?.category ?? "override"),
377
+ weight: rule?.weight ?? structural?.weight ?? 0,
378
+ description: rule?.description ?? structural?.description ?? "",
379
+ line: f.line,
380
+ column: f.column,
381
+ start: f.start,
382
+ excerpt: f.rule.startsWith("conceal.") ? f.value : excerptAt(text, f.start, f.end - f.start),
383
+ source,
384
+ };
385
+ });
386
+ const seen = new Map();
387
+ for (const hit of hits)
388
+ if (!seen.has(hit.rule))
389
+ seen.set(hit.rule, hit.weight);
390
+ let score = 0;
391
+ for (const weight of seen.values())
392
+ score += weight;
393
+ score = Math.min(100, score);
394
+ const categories = [...new Set(hits.map((h) => h.category))].sort();
395
+ return { score, band: band(score), hits, categories, decodedBlobs };
396
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Personal-data detectors.
3
+ *
4
+ * ## Read this before you trust a result
5
+ *
6
+ * Every rule here is a pattern over bytes. A pattern can prove that a string
7
+ * has the SHAPE of an identifier, and for two formats it can prove a check
8
+ * digit is consistent. It can never prove that a string IS somebody's
9
+ * personal data, and — far more dangerous — an empty result can never prove
10
+ * a document has none. A name, a free-text medical detail, an employee
11
+ * number with a local format, an address that does not end in "Street": all
12
+ * invisible here.
13
+ *
14
+ * So `PiiScan` reports what it LOOKED for, not what a document contains, and
15
+ * nothing downstream should read "0 findings" as "safe to publish".
16
+ *
17
+ * ## The rules, and exactly what each one proves
18
+ *
19
+ * | rule | proves |
20
+ * |---|---|
21
+ * | `email.addr-spec-subset` | dot-atom local part, dotted domain, TLD of 2+ letters. Not quoted local parts, not IP-literal domains, not internationalized addresses. |
22
+ * | `phone.e164` | `+` then 8–15 digits with common separators, taken as the longest whole-group run within that range. Where the next number is grouped the same way the two cannot be told apart, and the span runs long; `dedupeOverlaps` lets a verified card win that overlap. |
23
+ * | `phone.national.XX` | a national dialling shape for one of the seven supported countries. |
24
+ * | `ssn.us-format` | `NNN-NN-NNNN` with a group the SSA has never issued (000/666/900+ area, 00 group, 0000 serial) excluded. NOT validity: there is no public checksum for an SSN, and this package does not pretend otherwise. |
25
+ * | `iban.iso7064-mod97` | the ISO 7064 mod-97-10 check digits are correct, and where the country is known, the length matches. A real check, verifiable offline. |
26
+ * | `card.luhn` | the Luhn check digit is correct over 12–19 digits taken as whole digit groups. A real check. It does not mean the card exists or is live. |
27
+ * | `ip.v4-dotted` / `ip.v6-parsed` | a parseable address literal. Private, loopback and documentation ranges are labelled, because those are usually not personal data. |
28
+ * | `dob.labelled` / `date.calendar` | a calendar date. Only a nearby "date of birth"-style label, or a caller-supplied `referenceDate` that makes the age plausible, raises it above `possible`. |
29
+ * | `address.us-street-suffix` / `address.us-city-state-zip` | a US-style street line or city/state/ZIP tail. US conventions only. |
30
+ */
31
+ import { type Finding } from "./text";
32
+ export declare const PII_TYPES: readonly ["credit_card", "date_of_birth", "email", "iban", "ip_address", "phone", "postal_address", "us_ssn"];
33
+ export type PiiType = (typeof PII_TYPES)[number];
34
+ /** Countries whose national (non-`+`) dialling shapes are implemented. */
35
+ export declare const SUPPORTED_PHONE_COUNTRIES: readonly ["AU", "CA", "DE", "FR", "GB", "IN", "US"];
36
+ export type PhoneCountry = (typeof SUPPORTED_PHONE_COUNTRIES)[number];
37
+ export type PiiOptions = {
38
+ /** Which detectors to run. Defaults to all of them. */
39
+ readonly types?: ReadonlyArray<PiiType>;
40
+ /** Country hint for national phone shapes. E.164 is always scanned. */
41
+ readonly country?: string;
42
+ /** `YYYY-MM-DD`. Enables the age-plausibility test for dates of birth. */
43
+ readonly referenceDate?: string;
44
+ };
45
+ export declare class PiiOptionError extends Error {
46
+ readonly name = "PiiOptionError";
47
+ }
48
+ /** Luhn (ISO/IEC 7812-1) over a digits-only string. */
49
+ export declare function luhnValid(digits: string): boolean;
50
+ /**
51
+ * ISO 7064 mod-97-10, as IBANs use it: rotate the first four characters to
52
+ * the end, map letters to two-digit numbers, and take the remainder modulo
53
+ * 97 in chunks, because the integer does not fit in a double.
54
+ */
55
+ export declare function ibanMod97(iban: string): number;
56
+ /**
57
+ * Registered IBAN lengths. A country not listed is still check-digit tested,
58
+ * but reported at lower confidence and flagged `unknownCountry`, because the
59
+ * length rule is half of what makes an IBAN verifiable.
60
+ */
61
+ export declare const IBAN_LENGTHS: ReadonlyMap<string, number>;
62
+ /** Issuer, by the IIN prefix ranges that are stable and public. */
63
+ export declare function cardBrand(digits: string): string;
64
+ /** Dotted-quad parse. Rejects leading zeros, which are an obfuscation trick. */
65
+ export declare function parseIpv4(value: string): number[] | undefined;
66
+ /**
67
+ * IPv6 as RFC 4291 §2.2 forms 1–3: eight hextets, `::` compression at most
68
+ * once, and a trailing dotted-quad. Zone identifiers (`%eth0`) and CIDR
69
+ * suffixes are NOT accepted — they are addresses plus something else, and
70
+ * guessing which is a good way to be confidently wrong.
71
+ */
72
+ export declare function parseIpv6(value: string): number[] | undefined;
73
+ /**
74
+ * Days since 1970-01-01 for a valid proleptic Gregorian Y-M-D, or undefined.
75
+ *
76
+ * `Date.UTC` maps years 0–99 to 1900–1999, so `calendarDay(50, 1, 1)` would
77
+ * silently answer for 1950; the year is set explicitly to keep a four-digit
78
+ * `referenceDate` of `"0050-01-01"` meaning the year 50 rather than 1950.
79
+ */
80
+ export declare function calendarDay(year: number, month: number, day: number): number | undefined;
81
+ /**
82
+ * Run the selected detectors. Overlaps are NOT resolved here — the caller
83
+ * runs `dedupeOverlaps`, so a caller that wants every raw hit can have it.
84
+ */
85
+ export declare function scanPii(text: string, options?: PiiOptions): Finding[];
86
+ /**
87
+ * The value a pseudonym is derived from, so the same identifier produces the
88
+ * same token no matter how it was written. Case and separators only — no
89
+ * semantic normalization, because that would need judgement.
90
+ */
91
+ export declare function canonicalPiiValue(type: string, value: string): string;