@coo-quack/sensitive-canary 0.7.0 → 0.8.1

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.
Files changed (37) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +798 -0
  3. package/README.md +142 -45
  4. package/dist/lib/bash-commands.js +405 -0
  5. package/dist/lib/command-tables.js +462 -0
  6. package/dist/lib/default-config.json +570 -0
  7. package/dist/lib/encoding.js +123 -0
  8. package/dist/lib/fail-closed.js +31 -0
  9. package/dist/lib/inspector.js +0 -0
  10. package/dist/lib/rules.js +399 -0
  11. package/dist/lib/shapes.js +161 -0
  12. package/dist/lib/shell.js +436 -0
  13. package/dist/lib/tool-inputs.js +217 -0
  14. package/dist/lib/transcript.js +115 -0
  15. package/dist/lib/validators.js +435 -0
  16. package/dist/pre-tool-use-hook.js +773 -0
  17. package/dist/user-prompt-submit-hook.js +105 -0
  18. package/hooks/hooks.json +1 -1
  19. package/package.json +25 -11
  20. package/src/lib/bash-commands.ts +455 -0
  21. package/src/lib/command-tables.ts +518 -0
  22. package/src/lib/default-config.json +155 -46
  23. package/src/lib/encoding.ts +135 -0
  24. package/src/lib/fail-closed.ts +36 -0
  25. package/src/lib/inspector.ts +0 -0
  26. package/src/lib/rules.ts +202 -365
  27. package/src/lib/shapes.ts +175 -0
  28. package/src/lib/shell.ts +512 -0
  29. package/src/lib/tool-inputs.ts +235 -0
  30. package/src/lib/transcript.ts +142 -0
  31. package/src/lib/validators.ts +435 -0
  32. package/src/pre-tool-use-hook.ts +774 -198
  33. package/src/user-prompt-submit-hook.ts +60 -18
  34. package/src/__tests__/pre-tool-use-hook.test.ts +0 -779
  35. package/src/__tests__/user-prompt-submit-hook.test.ts +0 -297
  36. package/src/lib/__tests__/inspector.test.ts +0 -289
  37. package/src/lib/__tests__/rules.test.ts +0 -1370
@@ -0,0 +1,115 @@
1
+ // Which line of the transcript is the user speaking, and what tags they wrote.
2
+ //
3
+ // A tag lifts the checks, so the question this file answers is the most
4
+ // dangerous one in the product: everything the runtime writes under the user's
5
+ // role — a compaction summary, a skill body, the output of a `!` command, a
6
+ // background task reporting back — has to be told apart from someone typing.
7
+ import fs from "node:fs";
8
+ import { resolveTagPriority, userTypedText, } from "./inspector.js";
9
+ // Maximum bytes to read from the tail of a transcript file.
10
+ const MAX_TRANSCRIPT_TAIL_BYTES = 65_536; // 64 KB
11
+ // Whether a transcript line records something a person typed.
12
+ //
13
+ // The field is only present on lines that have one, so a line without it is
14
+ // left to the other tests rather than rejected: most user lines carry tool
15
+ // results and have no origin, and an older runtime writes none at all.
16
+ export function wasTypedByAHuman(line) {
17
+ const kind = line.origin?.kind;
18
+ return kind === undefined || kind === null || kind === "human";
19
+ }
20
+ // Returns true when the message carries text the user typed. A message that is
21
+ // only tool results, or only the machinery above, is not user input.
22
+ function hasTextContent(msg) {
23
+ if (typeof msg.content !== "string" &&
24
+ !msg.content.some((b) => b.type === "text"))
25
+ return false;
26
+ return userTypedText(msg).trim().length > 0;
27
+ }
28
+ // Load allow tags from the Claude Code session transcript.
29
+ // Transcript format (JSONL): { "type": "user"|"assistant", "message": { role, content }, … }
30
+ // Only the most recent user *text* message is consulted, and only if no tool_result
31
+ // entries have been recorded after it. This means allow tags are consumed by the first
32
+ // tool call — subsequent tool calls in the same AI turn will be blocked.
33
+ export function loadAllowTagsFromTranscript(transcriptPath) {
34
+ let raw;
35
+ try {
36
+ const stat = fs.statSync(transcriptPath);
37
+ // A FIFO here would block the read until something wrote to it, and a hook
38
+ // that never returns is killed by the timeout, which does not block.
39
+ if (!stat.isFile())
40
+ return new Set();
41
+ if (stat.size <= MAX_TRANSCRIPT_TAIL_BYTES) {
42
+ raw = fs.readFileSync(transcriptPath, "utf8");
43
+ }
44
+ else {
45
+ const buf = Buffer.alloc(MAX_TRANSCRIPT_TAIL_BYTES);
46
+ const fd = fs.openSync(transcriptPath, "r");
47
+ try {
48
+ const bytesRead = fs.readSync(fd, buf, 0, MAX_TRANSCRIPT_TAIL_BYTES, stat.size - MAX_TRANSCRIPT_TAIL_BYTES);
49
+ raw = buf.subarray(0, bytesRead).toString("utf8");
50
+ }
51
+ finally {
52
+ fs.closeSync(fd);
53
+ }
54
+ }
55
+ }
56
+ catch {
57
+ return new Set();
58
+ }
59
+ let lastUserMessage = null;
60
+ let toolResultAfterLastText = false;
61
+ for (const line of raw.split("\n")) {
62
+ const trimmed = line.trim();
63
+ if (!trimmed)
64
+ continue;
65
+ try {
66
+ const parsed = JSON.parse(trimmed);
67
+ const msg = parsed.message;
68
+ // A line the runtime wrote as an assistant turn is not user input,
69
+ // whatever the message inside it says its role is. Absent rather than
70
+ // contradictory is fine: the field is rejected only when it names some
71
+ // other kind of line.
72
+ //
73
+ // `isCompactSummary` and `isMeta` are two the runtime writes as the user
74
+ // without the user having typed them. A compaction summary is a
75
+ // re-injection of earlier turns, so a tag anyone discussed at any point in
76
+ // the conversation comes back armed; a meta line carries skill bodies and
77
+ // other file content, so writing a `SKILL.md` would be enough to lift
78
+ // every check. Neither is someone asking for anything.
79
+ //
80
+ // `origin.kind` says outright which lines those are, and it is asked
81
+ // before any of the rest: a background task reporting back arrives as
82
+ // `task-notification`, carrying an agent's free-form prose under the
83
+ // user's role. Prose about these very tags is enough, so a report that
84
+ // quotes the documentation arms the guard it is describing.
85
+ //
86
+ // Only lines that carry the field are judged by it. Most do not — a tool
87
+ // result has no origin — and treating absent as non-human would ignore
88
+ // every transcript written by a runtime that predates it.
89
+ if ((parsed.type === undefined || parsed.type === "user") &&
90
+ parsed.isCompactSummary !== true &&
91
+ parsed.isMeta !== true &&
92
+ wasTypedByAHuman(parsed) &&
93
+ msg?.role === "user" &&
94
+ msg.content !== undefined) {
95
+ if (hasTextContent(msg)) {
96
+ lastUserMessage = msg;
97
+ toolResultAfterLastText = false;
98
+ }
99
+ else {
100
+ toolResultAfterLastText = true;
101
+ }
102
+ }
103
+ }
104
+ catch {
105
+ // skip malformed lines
106
+ }
107
+ }
108
+ if (!lastUserMessage || toolResultAfterLastText)
109
+ return new Set();
110
+ // Through the same resolution the prompt hook uses, over the typed text
111
+ // rather than the raw content. Collecting every tag instead meant this hook
112
+ // did not see mask tags at all, so `[mask-secret] [allow-secret]` stopped the
113
+ // prompt and then allowed the tool call it was stopping.
114
+ return resolveTagPriority(userTypedText(lastUserMessage)).effectiveAllow;
115
+ }
@@ -0,0 +1,435 @@
1
+ // The checksum algorithms and range checks a rule may name in its `validate`
2
+ // field.
3
+ //
4
+ // These are code where the rules are data: a rule says `"validate": "luhn"` and
5
+ // the registry at the foot of this file resolves the name. Each answers one
6
+ // question about one value — does this pass the checksum, is this address
7
+ // reserved — and none of them knows what a rule is.
8
+ // Luhn algorithm checksum validation. Returns true if the number (digits only) passes.
9
+ // Card numbers every payment gateway publishes as test data. They pass Luhn by
10
+ // design, and a developer pasting one into a prompt or a fixture is not leaking
11
+ // anything — but the block reads the same as a real one, and that is the kind of
12
+ // block that gets the tool turned off.
13
+ const TEST_CARD_NUMBERS = new Set([
14
+ "4242424242424242",
15
+ "4111111111111111",
16
+ "4012888888881881",
17
+ "4000056655665556",
18
+ "5555555555554444",
19
+ "5105105105105100",
20
+ "5200828282828210",
21
+ "378282246310005",
22
+ "371449635398431",
23
+ "6011111111111117",
24
+ "6011000990139424",
25
+ "3056930009020004",
26
+ "3566002020360505",
27
+ ]);
28
+ // A card number that is not published test data and passes the checksum. This
29
+ // is what the `luhn` validator resolves to: `luhn` alone answers a narrower
30
+ // question, and having one function answer both meant it returned false for
31
+ // numbers that do pass the checksum.
32
+ export function isRealCardNumber(str) {
33
+ if (TEST_CARD_NUMBERS.has(str.replace(/\D/g, "")))
34
+ return false;
35
+ return luhn(str);
36
+ }
37
+ // AWS writes every key in its documentation with `EXAMPLE` where the random
38
+ // part would end — `AKIAIOSFODNN7EXAMPLE`, `ASIAIOSFODNN7EXAMPLE`,
39
+ // `AKIAI44QH8DHBEXAMPLE`. Those appear in setup guides, in READMEs that copy
40
+ // them, and in this project's own documentation, and a block on one reads
41
+ // exactly like a block on a live key.
42
+ //
43
+ // The suffix is the test rather than a list of bodies. The bodies differ
44
+ // between guides, so a list would exempt the three anyone thought to write down
45
+ // and block the fourth; the convention is what AWS keeps to. What it costs is a
46
+ // real key whose last seven characters spell the word, one in thirty-six to the
47
+ // seventh, and the rule's own character class keeps that to uppercase keys.
48
+ export function isRealAwsKey(str) {
49
+ return !/EXAMPLE$/.test(str);
50
+ }
51
+ export function luhn(str) {
52
+ const digits = str.replace(/\D/g, "");
53
+ if (digits.length === 0)
54
+ return false;
55
+ let sum = 0;
56
+ let double = false;
57
+ for (let i = digits.length - 1; i >= 0; i--) {
58
+ let d = parseInt(digits[i] ?? "", 10);
59
+ if (double) {
60
+ d *= 2;
61
+ if (d > 9)
62
+ d -= 9;
63
+ }
64
+ sum += d;
65
+ double = !double;
66
+ }
67
+ return sum % 10 === 0;
68
+ }
69
+ // ── National ID checksum validators ──────────────────────────────────────────
70
+ // Japanese Individual Number (My Number): 12 digits, weighted checksum over the
71
+ // first 11 digits with weights 6,5,4,3,2,7,6,5,4,3,2. The 12th digit is
72
+ // 11 - (sum mod 11); when the remainder is 0 or 1, the check digit is 0.
73
+ // Spec: 地方公共団体情報システム機構 (J-LIS).
74
+ export function validateMyNumber(input) {
75
+ // Twelve of the same digit satisfies the weighted sum by arithmetic, not by
76
+ // being anyone's number. Padding, zeroed records and hex dumps are full of
77
+ // them.
78
+ if (/^(\d)\1*$/.test(input.replace(/[-\s]/g, "")))
79
+ return false;
80
+ const digits = input.replace(/\D/g, "");
81
+ if (digits.length !== 12)
82
+ return false;
83
+ const weights = [6, 5, 4, 3, 2, 7, 6, 5, 4, 3, 2];
84
+ let sum = 0;
85
+ for (let i = 0; i < 11; i++) {
86
+ sum += parseInt(digits[i] ?? "", 10) * (weights[i] ?? 0);
87
+ }
88
+ const remainder = sum % 11;
89
+ const checkDigit = remainder <= 1 ? 0 : 11 - remainder;
90
+ return checkDigit === parseInt(digits[11] ?? "", 10);
91
+ }
92
+ // French NIR (Numéro de sécurité sociale / INSEE): 15 digits, 2-digit check key
93
+ // computed as 97 - (N mod 97) over the leading 13 digits. Corsica departements
94
+ // use 2A/2B, substituted to 19/18 before the mod. The 13-digit value can exceed
95
+ // Number.MAX_SAFE_INTEGER, so BigInt is used. Spec: INSEE / décret n°82-103.
96
+ export function validateFrenchNIR(input) {
97
+ const cleaned = input.replace(/\s/g, "");
98
+ let nir13;
99
+ let keyStr;
100
+ const standard = cleaned.match(/^([12]\d{12})(\d{2})$/);
101
+ const corseA = cleaned.match(/^([12]\d{4}2A\d{6})(\d{2})$/i);
102
+ const corseB = cleaned.match(/^([12]\d{4}2B\d{6})(\d{2})$/i);
103
+ if (standard) {
104
+ nir13 = standard[1] ?? "";
105
+ keyStr = standard[2] ?? "";
106
+ }
107
+ else if (corseA) {
108
+ nir13 = (corseA[1] ?? "").replace(/2A/i, "19");
109
+ keyStr = corseA[2] ?? "";
110
+ }
111
+ else if (corseB) {
112
+ nir13 = (corseB[1] ?? "").replace(/2B/i, "18");
113
+ keyStr = corseB[2] ?? "";
114
+ }
115
+ else {
116
+ return false;
117
+ }
118
+ const num = BigInt(nir13);
119
+ const computedKey = 97 - Number(num % 97n);
120
+ return computedKey === parseInt(keyStr, 10);
121
+ }
122
+ // Italian Codice Fiscale: 16 alphanumeric chars. The last char is a control
123
+ // character computed by summing odd/even position values (different maps) mod 26.
124
+ // Spec: Agenzia delle Entrate, DM 12 giugno 2007.
125
+ const CF_ODD_VALUES = {
126
+ "0": 1,
127
+ "1": 0,
128
+ "2": 5,
129
+ "3": 7,
130
+ "4": 9,
131
+ "5": 13,
132
+ "6": 15,
133
+ "7": 17,
134
+ "8": 19,
135
+ "9": 21,
136
+ A: 1,
137
+ B: 0,
138
+ C: 5,
139
+ D: 7,
140
+ E: 9,
141
+ F: 13,
142
+ G: 15,
143
+ H: 17,
144
+ I: 19,
145
+ J: 21,
146
+ K: 2,
147
+ L: 4,
148
+ M: 18,
149
+ N: 20,
150
+ O: 11,
151
+ P: 3,
152
+ Q: 6,
153
+ R: 8,
154
+ S: 12,
155
+ T: 14,
156
+ U: 16,
157
+ V: 10,
158
+ W: 22,
159
+ X: 25,
160
+ Y: 24,
161
+ Z: 23,
162
+ };
163
+ export function validateCodiceFiscale(input) {
164
+ const cf = input.toUpperCase().replace(/\s/g, "");
165
+ // Omocodia: when two people would share the first fifteen characters, the
166
+ // Agenzia delle Entrate substitutes letters for digits from the right,
167
+ // 0=L 1=M 2=N 3=P 4=Q 5=R 6=S 7=T 8=U 9=V, over the seven numeric
168
+ // positions. Requiring digits there rejected every substituted code — all
169
+ // of them issued to real people. The check character below needs no change:
170
+ // it is defined over the substituted fifteen, and the odd/even tables
171
+ // already carry letters.
172
+ if (!/^[A-Z]{6}[0-9LMNPQRSTUV]{2}[A-Z][0-9LMNPQRSTUV]{2}[A-Z][0-9LMNPQRSTUV]{3}[A-Z]$/.test(cf))
173
+ return false;
174
+ let sum = 0;
175
+ for (let i = 0; i < 15; i++) {
176
+ const ch = cf[i] ?? "";
177
+ if (i % 2 === 0) {
178
+ sum += CF_ODD_VALUES[ch] ?? -1;
179
+ }
180
+ else if (/[0-9]/.test(ch)) {
181
+ sum += parseInt(ch, 10);
182
+ }
183
+ else {
184
+ sum += ch.charCodeAt(0) - 65;
185
+ }
186
+ }
187
+ const expected = String.fromCharCode(65 + (sum % 26));
188
+ return expected === cf[15];
189
+ }
190
+ // German Steuer-Identifikationsnummer (IdNr.): 11 digits, first digit non-zero.
191
+ // The procedure is ISO/IEC 7064 MOD 11,10, though the tax administration's own
192
+ // specification states it as code rather than by that name.
193
+ //
194
+ // Deliberately not enforced: the digit-composition rule. Since 2016 it reads
195
+ // "exactly one digit occurs twice or three times in positions 1-10", replacing
196
+ // an older "exactly twice". Adding the older form as a tightening would reject
197
+ // valid current numbers.
198
+ // Spec: ELSTER, Prüfung der Steuer- und Steueridentifikationsnummer, §2.2.
199
+ export function validateGermanIdNr(input) {
200
+ const cleaned = input.replace(/\s/g, "");
201
+ if (!/^[1-9]\d{10}$/.test(cleaned))
202
+ return false;
203
+ let produkt = 10;
204
+ for (let i = 0; i < 10; i++) {
205
+ let summe = (parseInt(cleaned[i] ?? "", 10) + produkt) % 10;
206
+ if (summe === 0)
207
+ summe = 10;
208
+ produkt = (summe * 2) % 11;
209
+ }
210
+ let check = 11 - produkt;
211
+ if (check === 10)
212
+ check = 0;
213
+ return check === parseInt(cleaned[10] ?? "", 10);
214
+ }
215
+ // Spanish DNI (8 digits + letter) and NIE (X/Y/Z + 7 digits + letter). The
216
+ // control letter is selected from TRWAGMYFPDXBNJZSQVHLCKE by the number mod 23.
217
+ // NIE leading letters map X→0, Y→1, Z→2 before the mod.
218
+ // The X/Y/Z mapping and the mod-23 alphabet are what every implementation uses,
219
+ // but they were not confirmed against a Spanish government source here — the
220
+ // Interior page that documents them was unreachable. The governing decree is
221
+ // Real Decreto 255/2025, which repealed RD 1553/2005 on 2025-04-02, and it
222
+ // specifies neither digit count nor separator; the Agencia Tributaria describes
223
+ // the number as "ocho dígitos ... más una letra de control", with no separator.
224
+ // Hyphens are stripped above so both the official and the common form are read.
225
+ const NIF_LETTERS = "TRWAGMYFPDXBNJZSQVHLCKE";
226
+ export function validateSpanishNIF(input) {
227
+ const cleaned = input.toUpperCase().replace(/[\s-]/g, "");
228
+ const dni = cleaned.match(/^(\d{8})([A-Z])$/);
229
+ if (dni) {
230
+ return NIF_LETTERS[parseInt(dni[1] ?? "", 10) % 23] === dni[2];
231
+ }
232
+ const nie = cleaned.match(/^([XYZ])(\d{7})([A-Z])$/);
233
+ if (nie) {
234
+ const prefix = nie[1] === "X" ? "0" : nie[1] === "Y" ? "1" : "2";
235
+ const num = parseInt(prefix + (nie[2] ?? ""), 10);
236
+ return NIF_LETTERS[num % 23] === nie[3];
237
+ }
238
+ return false;
239
+ }
240
+ // Korean Resident Registration Number (RRN, 주민등록번호): 13 digits.
241
+ // Checksum is (11 - (weighted sum mod 11)) mod 10 with weights
242
+ // 2,3,4,5,6,7,8,9,2,3,4,5 over the first 12 digits.
243
+ // Numbers newly issued or changed on or after 2020-10-05 randomize digits 8-13,
244
+ // and the check digit is the 13th — so it is inside the randomized block and the
245
+ // weighted sum above holds only by chance, roughly one time in ten. Treat a pass
246
+ // as evidence, never as a requirement. 주민등록법 시행규칙 제2조 (행정안전부령
247
+ // 제204호) now reads "생년월일ㆍ성별 등을 표시할 수 있는 13자리의 숫자", with the
248
+ // 지역 (region) term of the older text removed. No rule ever specified the check
249
+ // digit, so no rule announces its end either.
250
+ // Spec: 주민등록법 시행규칙 제2조; 주민등록 사무편람 (Ministry of the Interior and Safety).
251
+ export function validateKoreanRRN(input) {
252
+ const s = input.replace(/[-\s]/g, "");
253
+ if (!/^\d{13}$/.test(s))
254
+ return false;
255
+ const weights = [2, 3, 4, 5, 6, 7, 8, 9, 2, 3, 4, 5];
256
+ let sum = 0;
257
+ for (let i = 0; i < 12; i++) {
258
+ sum += parseInt(s[i] ?? "", 10) * (weights[i] ?? 0);
259
+ }
260
+ const check = (11 - (sum % 11)) % 10;
261
+ return check === parseInt(s[12] ?? "", 10);
262
+ }
263
+ // Korean Business Registration Number (사업자등록번호): 10 digits. Uses the
264
+ // NTS (Hometax) standard algorithm: weights 1,3,7,1,3,7,1,3,5 over digits 1-9,
265
+ // plus floor(digit9 × 5 / 10), and the check digit is (10 - (sum mod 10)) mod 10.
266
+ export function validateKoreanBRN(input) {
267
+ const s = input.replace(/[-\s]/g, "");
268
+ if (!/^\d{10}$/.test(s))
269
+ return false;
270
+ const weights = [1, 3, 7, 1, 3, 7, 1, 3, 5];
271
+ let sum = 0;
272
+ for (let i = 0; i < 9; i++) {
273
+ sum += parseInt(s[i] ?? "", 10) * (weights[i] ?? 0);
274
+ }
275
+ sum += Math.floor((parseInt(s[8] ?? "", 10) * 5) / 10);
276
+ return (10 - (sum % 10)) % 10 === parseInt(s[9] ?? "", 10);
277
+ }
278
+ // Chinese Resident Identity Card (居民身份证): 18 chars (17 digits + check).
279
+ // ISO 7064 MOD 11-2 per GB 11643-1999. Weights
280
+ // 7,9,10,5,8,4,2,1,6,3,7,9,10,5,8,4,2; remainder maps to "10X98765432".
281
+ export function validateChineseID(input) {
282
+ const s = input.toUpperCase().replace(/[-\s]/g, "");
283
+ if (!/^\d{17}[\dX]$/.test(s))
284
+ return false;
285
+ const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
286
+ const code = "10X98765432";
287
+ let sum = 0;
288
+ for (let i = 0; i < 17; i++) {
289
+ sum += parseInt(s[i] ?? "", 10) * (weights[i] ?? 0);
290
+ }
291
+ return code[sum % 11] === s[17];
292
+ }
293
+ // IPv4 reserved / non-public ranges. Returns true for addresses that should
294
+ // NOT be flagged as PII (loopback, private, link-local, TEST-NET, multicast,
295
+ // reserved, CGN, benchmarking). Used by pii-ipv4-public to keep only public IPs.
296
+ export function isReservedIpv4(ip) {
297
+ const octets = ip.split(".");
298
+ // Require exactly 4 octets of 1–3 digits each, so partial parses
299
+ // (e.g. "1a" → 1 via parseInt) are treated as malformed, not public.
300
+ if (octets.length !== 4 || octets.some((o) => !/^\d{1,3}$/.test(o))) {
301
+ return true;
302
+ }
303
+ const parts = octets.map((o) => parseInt(o, 10));
304
+ if (parts.some((p) => p > 255)) {
305
+ return true;
306
+ }
307
+ const a = parts[0] ?? 0;
308
+ const b = parts[1] ?? 0;
309
+ const c = parts[2] ?? 0;
310
+ if (a === 0 || a === 10)
311
+ return true;
312
+ if (a === 100 && b >= 64 && b <= 127)
313
+ return true; // CGN 100.64.0.0/10
314
+ if (a === 127)
315
+ return true; // loopback
316
+ if (a === 169 && b === 254)
317
+ return true; // link-local
318
+ if (a === 172 && b >= 16 && b <= 31)
319
+ return true; // private
320
+ if (a === 192 && b === 0 && c === 0)
321
+ return true; // IETF protocol assignments
322
+ if (a === 192 && b === 0 && c === 2)
323
+ return true; // TEST-NET-1
324
+ if (a === 192 && b === 88 && c === 99)
325
+ return true; // 6to4 relay anycast (deprecated)
326
+ if (a === 192 && b === 168)
327
+ return true; // private
328
+ if (a === 198 && (b === 18 || b === 19))
329
+ return true; // benchmark
330
+ if (a === 198 && b === 51 && c === 100)
331
+ return true; // TEST-NET-2
332
+ if (a === 203 && b === 0 && c === 113)
333
+ return true; // TEST-NET-3
334
+ if (a >= 224)
335
+ return true; // multicast + reserved
336
+ return false;
337
+ }
338
+ // IPv6 reserved / non-public ranges. Returns true for addresses that should
339
+ // NOT be flagged as PII (loopback, unspecified, link-local, unique-local,
340
+ // multicast, documentation). Properly handles both compressed (::) and
341
+ // fully-expanded (0:0:0:0:0:0:0:1) forms. Used by pii-ipv6.
342
+ // Each group must be 1–4 hex digits; anything else is malformed.
343
+ const isHexGroup = (g) => /^[0-9a-f]{1,4}$/.test(g);
344
+ export function isReservedIpv6(ip) {
345
+ const lower = ip.toLowerCase();
346
+ // Multiple :: is invalid — treat as reserved.
347
+ const halves = lower.split("::");
348
+ if (halves.length > 2)
349
+ return true;
350
+ // Split and expand :: notation into zero groups.
351
+ let groups;
352
+ if (halves.length === 1) {
353
+ const raw = lower.split(":");
354
+ if (raw.some((g) => !isHexGroup(g)))
355
+ return true;
356
+ groups = raw.map((g) => Number.parseInt(g, 16));
357
+ }
358
+ else {
359
+ const leftRaw = halves[0] ? halves[0].split(":") : [];
360
+ const rightRaw = halves[1] ? halves[1].split(":") : [];
361
+ if (leftRaw.some((g) => !isHexGroup(g)) ||
362
+ rightRaw.some((g) => !isHexGroup(g))) {
363
+ return true;
364
+ }
365
+ // Too many groups to fit in 128 bits — malformed. A "::" that compresses
366
+ // zero groups (left + right === 8) is also invalid per RFC 4291 §2.2.
367
+ if (leftRaw.length + rightRaw.length >= 8)
368
+ return true;
369
+ const left = leftRaw.map((g) => Number.parseInt(g, 16));
370
+ const right = rightRaw.map((g) => Number.parseInt(g, 16));
371
+ const zeros = Array(8 - left.length - right.length).fill(0);
372
+ groups = [...left, ...zeros, ...right];
373
+ }
374
+ if (groups.length !== 8)
375
+ return true; // malformed — treat as reserved
376
+ // Unspecified (::)
377
+ if (groups.every((g) => g === 0))
378
+ return true;
379
+ // Loopback (::1)
380
+ if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1)
381
+ return true;
382
+ // Link-local fe80::/10
383
+ if ((groups[0] ?? 0) >= 0xfe80 && (groups[0] ?? 0) <= 0xfebf)
384
+ return true;
385
+ // Unique-local fc00::/7
386
+ if (((groups[0] ?? 0) & 0xfe00) === 0xfc00)
387
+ return true;
388
+ // Multicast ff00::/8
389
+ if (((groups[0] ?? 0) & 0xff00) === 0xff00)
390
+ return true;
391
+ // Documentation 2001:db8::/32
392
+ if ((groups[0] ?? 0) === 0x2001 && (groups[1] ?? 0) === 0x0db8)
393
+ return true;
394
+ return false;
395
+ }
396
+ // ── Validator registry ───────────────────────────────────────────────────────
397
+ // Validators are code (checksum algorithms), not data. They live here and are
398
+ // referenced by name from the JSON config. User-defined rules can use any of
399
+ // these validators or omit `validate` entirely.
400
+ // A Japanese telephone number: ten digits, or eleven for a mobile. The pattern
401
+ // alone also matched `01-02-2024`, which is a date, and `0000 0000 0000`, which
402
+ // is an identifier. Freephone prefixes are excluded — 0120 and 0800 belong to a
403
+ // business and are printed to be dialled.
404
+ export function validateJapanesePhone(input) {
405
+ const digits = input.replace(/\D/g, "");
406
+ if (!/^0\d{8,10}$/.test(digits))
407
+ return false;
408
+ if (digits.length !== 10 && digits.length !== 11)
409
+ return false;
410
+ if (/^0(?:120|800)/.test(digits))
411
+ return false;
412
+ return true;
413
+ }
414
+ const VALIDATORS = {
415
+ luhn: isRealCardNumber,
416
+ "aws-key": isRealAwsKey,
417
+ "mynumber-jp": validateMyNumber,
418
+ "phone-jp": validateJapanesePhone,
419
+ "nir-fr": validateFrenchNIR,
420
+ "codice-fiscale-it": validateCodiceFiscale,
421
+ "steuer-id-de": validateGermanIdNr,
422
+ "dni-nie-es": validateSpanishNIF,
423
+ "rrn-kr": validateKoreanRRN,
424
+ "brn-kr": validateKoreanBRN,
425
+ "resident-id-cn": validateChineseID,
426
+ "public-ipv4": (ip) => !isReservedIpv4(ip),
427
+ "public-ipv6": (ip) => !isReservedIpv6(ip),
428
+ };
429
+ // The names a config file may put in `validate`. Exported so the documents can
430
+ // be held to the same list: `phone-jp` was added to the registry and named in
431
+ // neither document, so a user writing a rule could not know it existed.
432
+ export const VALIDATOR_NAMES = Object.keys(VALIDATORS);
433
+ export function getValidator(name) {
434
+ return VALIDATORS[name];
435
+ }