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