@coo-quack/sensitive-canary 0.6.0 → 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.
- package/CHANGELOG.md +39 -0
- package/README.md +134 -5
- package/package.json +1 -1
- package/src/lib/__tests__/inspector.test.ts +8 -0
- package/src/lib/__tests__/rules.test.ts +923 -1
- package/src/lib/default-config.json +461 -0
- package/src/lib/rules.ts +618 -240
package/src/lib/rules.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
1
6
|
export type Category = "secret" | "pii";
|
|
2
7
|
|
|
3
8
|
export interface Finding {
|
|
@@ -6,6 +11,7 @@ export interface Finding {
|
|
|
6
11
|
category: Category;
|
|
7
12
|
matchRedacted: string;
|
|
8
13
|
secretValue: string;
|
|
14
|
+
score?: number;
|
|
9
15
|
}
|
|
10
16
|
|
|
11
17
|
interface Rule {
|
|
@@ -16,6 +22,33 @@ interface Rule {
|
|
|
16
22
|
entropyThreshold?: number;
|
|
17
23
|
validate?: (str: string) => boolean;
|
|
18
24
|
category: Category;
|
|
25
|
+
contextWords?: string[];
|
|
26
|
+
requireContext?: boolean;
|
|
27
|
+
contextWindow?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// JSON representation of a rule, as written in config files. The `regex` is a
|
|
31
|
+
// source string (not a RegExp literal), compiled at load time. `validate` is a
|
|
32
|
+
// name into the VALIDATORS registry.
|
|
33
|
+
export interface RuleConfig {
|
|
34
|
+
id: string;
|
|
35
|
+
description: string;
|
|
36
|
+
regex: string;
|
|
37
|
+
flags?: string;
|
|
38
|
+
secretGroup?: number;
|
|
39
|
+
entropyThreshold?: number;
|
|
40
|
+
validate?: string;
|
|
41
|
+
category: Category;
|
|
42
|
+
contextWords?: string[];
|
|
43
|
+
requireContext?: boolean;
|
|
44
|
+
contextWindow?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Top-level config file: a context window override plus a list of rules.
|
|
48
|
+
// User config files use the same shape and can override built-in rules by id.
|
|
49
|
+
export interface CanaryConfig {
|
|
50
|
+
contextWindow?: number;
|
|
51
|
+
rules: RuleConfig[];
|
|
19
52
|
}
|
|
20
53
|
|
|
21
54
|
const ALL_CATEGORIES: ReadonlySet<Category> = new Set(["secret", "pii"]);
|
|
@@ -44,6 +77,7 @@ export function enabledCategoriesFromEnv(): Set<Category> {
|
|
|
44
77
|
// Luhn algorithm checksum validation. Returns true if the number (digits only) passes.
|
|
45
78
|
export function luhn(str: string): boolean {
|
|
46
79
|
const digits = str.replace(/\D/g, "");
|
|
80
|
+
if (digits.length === 0) return false;
|
|
47
81
|
let sum = 0;
|
|
48
82
|
let double = false;
|
|
49
83
|
for (let i = digits.length - 1; i >= 0; i--) {
|
|
@@ -58,7 +92,295 @@ export function luhn(str: string): boolean {
|
|
|
58
92
|
return sum % 10 === 0;
|
|
59
93
|
}
|
|
60
94
|
|
|
61
|
-
//
|
|
95
|
+
// ── National ID checksum validators ──────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
// Japanese Individual Number (My Number): 12 digits, weighted checksum over the
|
|
98
|
+
// first 11 digits with weights 6,5,4,3,2,7,6,5,4,3,2. The 12th digit is
|
|
99
|
+
// 11 - (sum mod 11); when the remainder is 0 or 1, the check digit is 0.
|
|
100
|
+
// Spec: 地方公共団体情報システム機構 (J-LIS).
|
|
101
|
+
export function validateMyNumber(input: string): boolean {
|
|
102
|
+
const digits = input.replace(/\D/g, "");
|
|
103
|
+
if (digits.length !== 12) return false;
|
|
104
|
+
const weights = [6, 5, 4, 3, 2, 7, 6, 5, 4, 3, 2];
|
|
105
|
+
let sum = 0;
|
|
106
|
+
for (let i = 0; i < 11; i++) {
|
|
107
|
+
sum += parseInt(digits[i] ?? "", 10) * (weights[i] ?? 0);
|
|
108
|
+
}
|
|
109
|
+
const remainder = sum % 11;
|
|
110
|
+
const checkDigit = remainder <= 1 ? 0 : 11 - remainder;
|
|
111
|
+
return checkDigit === parseInt(digits[11] ?? "", 10);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// French NIR (Numéro de sécurité sociale / INSEE): 15 digits, 2-digit check key
|
|
115
|
+
// computed as 97 - (N mod 97) over the leading 13 digits. Corsica departements
|
|
116
|
+
// use 2A/2B, substituted to 19/18 before the mod. The 13-digit value can exceed
|
|
117
|
+
// Number.MAX_SAFE_INTEGER, so BigInt is used. Spec: INSEE / décret n°82-103.
|
|
118
|
+
export function validateFrenchNIR(input: string): boolean {
|
|
119
|
+
const cleaned = input.replace(/\s/g, "");
|
|
120
|
+
let nir13: string;
|
|
121
|
+
let keyStr: string;
|
|
122
|
+
|
|
123
|
+
const standard = cleaned.match(/^([12]\d{12})(\d{2})$/);
|
|
124
|
+
const corseA = cleaned.match(/^([12]\d{4}2A\d{6})(\d{2})$/i);
|
|
125
|
+
const corseB = cleaned.match(/^([12]\d{4}2B\d{6})(\d{2})$/i);
|
|
126
|
+
|
|
127
|
+
if (standard) {
|
|
128
|
+
nir13 = standard[1] ?? "";
|
|
129
|
+
keyStr = standard[2] ?? "";
|
|
130
|
+
} else if (corseA) {
|
|
131
|
+
nir13 = (corseA[1] ?? "").replace(/2A/i, "19");
|
|
132
|
+
keyStr = corseA[2] ?? "";
|
|
133
|
+
} else if (corseB) {
|
|
134
|
+
nir13 = (corseB[1] ?? "").replace(/2B/i, "18");
|
|
135
|
+
keyStr = corseB[2] ?? "";
|
|
136
|
+
} else {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const num = BigInt(nir13);
|
|
141
|
+
const computedKey = 97 - Number(num % 97n);
|
|
142
|
+
return computedKey === parseInt(keyStr, 10);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Italian Codice Fiscale: 16 alphanumeric chars. The last char is a control
|
|
146
|
+
// character computed by summing odd/even position values (different maps) mod 26.
|
|
147
|
+
// Spec: Agenzia delle Entrate, DM 12 giugno 2007.
|
|
148
|
+
const CF_ODD_VALUES: Record<string, number> = {
|
|
149
|
+
"0": 1,
|
|
150
|
+
"1": 0,
|
|
151
|
+
"2": 5,
|
|
152
|
+
"3": 7,
|
|
153
|
+
"4": 9,
|
|
154
|
+
"5": 13,
|
|
155
|
+
"6": 15,
|
|
156
|
+
"7": 17,
|
|
157
|
+
"8": 19,
|
|
158
|
+
"9": 21,
|
|
159
|
+
A: 1,
|
|
160
|
+
B: 0,
|
|
161
|
+
C: 5,
|
|
162
|
+
D: 7,
|
|
163
|
+
E: 9,
|
|
164
|
+
F: 13,
|
|
165
|
+
G: 15,
|
|
166
|
+
H: 17,
|
|
167
|
+
I: 19,
|
|
168
|
+
J: 21,
|
|
169
|
+
K: 2,
|
|
170
|
+
L: 4,
|
|
171
|
+
M: 18,
|
|
172
|
+
N: 20,
|
|
173
|
+
O: 11,
|
|
174
|
+
P: 3,
|
|
175
|
+
Q: 6,
|
|
176
|
+
R: 8,
|
|
177
|
+
S: 12,
|
|
178
|
+
T: 14,
|
|
179
|
+
U: 16,
|
|
180
|
+
V: 10,
|
|
181
|
+
W: 22,
|
|
182
|
+
X: 25,
|
|
183
|
+
Y: 24,
|
|
184
|
+
Z: 23,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export function validateCodiceFiscale(input: string): boolean {
|
|
188
|
+
const cf = input.toUpperCase().replace(/\s/g, "");
|
|
189
|
+
if (!/^[A-Z]{6}\d{2}[A-Z]\d{2}[A-Z]\d{3}[A-Z]$/.test(cf)) return false;
|
|
190
|
+
|
|
191
|
+
let sum = 0;
|
|
192
|
+
for (let i = 0; i < 15; i++) {
|
|
193
|
+
const ch = cf[i] ?? "";
|
|
194
|
+
if (i % 2 === 0) {
|
|
195
|
+
sum += CF_ODD_VALUES[ch] ?? -1;
|
|
196
|
+
} else if (/[0-9]/.test(ch)) {
|
|
197
|
+
sum += parseInt(ch, 10);
|
|
198
|
+
} else {
|
|
199
|
+
sum += ch.charCodeAt(0) - 65;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const expected = String.fromCharCode(65 + (sum % 26));
|
|
203
|
+
return expected === cf[15];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// German Steuer-Identifikationsnummer (IdNr.): 11 digits, first digit non-zero.
|
|
207
|
+
// Uses ISO/IEC 7064 MOD 11,10. Spec: Bundeszentralamt für Steuern.
|
|
208
|
+
export function validateGermanIdNr(input: string): boolean {
|
|
209
|
+
const cleaned = input.replace(/\s/g, "");
|
|
210
|
+
if (!/^[1-9]\d{10}$/.test(cleaned)) return false;
|
|
211
|
+
|
|
212
|
+
let produkt = 10;
|
|
213
|
+
for (let i = 0; i < 10; i++) {
|
|
214
|
+
let summe = (parseInt(cleaned[i] ?? "", 10) + produkt) % 10;
|
|
215
|
+
if (summe === 0) summe = 10;
|
|
216
|
+
produkt = (summe * 2) % 11;
|
|
217
|
+
}
|
|
218
|
+
let check = 11 - produkt;
|
|
219
|
+
if (check === 10) check = 0;
|
|
220
|
+
return check === parseInt(cleaned[10] ?? "", 10);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Spanish DNI (8 digits + letter) and NIE (X/Y/Z + 7 digits + letter). The
|
|
224
|
+
// control letter is selected from TRWAGMYFPDXBNJZSQVHLCKE by the number mod 23.
|
|
225
|
+
// NIE leading letters map X→0, Y→1, Z→2 before the mod.
|
|
226
|
+
// Spec: Ministerio del Interior, Orden INT/2058/2008.
|
|
227
|
+
const NIF_LETTERS = "TRWAGMYFPDXBNJZSQVHLCKE";
|
|
228
|
+
|
|
229
|
+
export function validateSpanishNIF(input: string): boolean {
|
|
230
|
+
const cleaned = input.toUpperCase().replace(/[\s-]/g, "");
|
|
231
|
+
|
|
232
|
+
const dni = cleaned.match(/^(\d{8})([A-Z])$/);
|
|
233
|
+
if (dni) {
|
|
234
|
+
return NIF_LETTERS[parseInt(dni[1] ?? "", 10) % 23] === dni[2];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const nie = cleaned.match(/^([XYZ])(\d{7})([A-Z])$/);
|
|
238
|
+
if (nie) {
|
|
239
|
+
const prefix = nie[1] === "X" ? "0" : nie[1] === "Y" ? "1" : "2";
|
|
240
|
+
const num = parseInt(prefix + (nie[2] ?? ""), 10);
|
|
241
|
+
return NIF_LETTERS[num % 23] === nie[3];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Korean Resident Registration Number (RRN, 주민등록번호): 13 digits.
|
|
248
|
+
// Checksum is (11 - (weighted sum mod 11)) mod 10 with weights
|
|
249
|
+
// 2,3,4,5,6,7,8,9,2,3,4,5 over the first 12 digits.
|
|
250
|
+
// Note: numbers issued after Oct 2020 randomize digits 8-13, so the checksum
|
|
251
|
+
// may not pass for valid recent numbers (false negatives possible).
|
|
252
|
+
// Spec: 주민등록 사무편람 (Ministry of the Interior and Safety).
|
|
253
|
+
export function validateKoreanRRN(input: string): boolean {
|
|
254
|
+
const s = input.replace(/[-\s]/g, "");
|
|
255
|
+
if (!/^\d{13}$/.test(s)) return false;
|
|
256
|
+
const weights = [2, 3, 4, 5, 6, 7, 8, 9, 2, 3, 4, 5];
|
|
257
|
+
let sum = 0;
|
|
258
|
+
for (let i = 0; i < 12; i++) {
|
|
259
|
+
sum += parseInt(s[i] ?? "", 10) * (weights[i] ?? 0);
|
|
260
|
+
}
|
|
261
|
+
const check = (11 - (sum % 11)) % 10;
|
|
262
|
+
return check === parseInt(s[12] ?? "", 10);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Korean Business Registration Number (사업자등록번호): 10 digits. Uses the
|
|
266
|
+
// NTS (Hometax) standard algorithm: weights 1,3,7,1,3,7,1,3,5 over digits 1-9,
|
|
267
|
+
// plus floor(digit9 × 5 / 10), and the check digit is (10 - (sum mod 10)) mod 10.
|
|
268
|
+
export function validateKoreanBRN(input: string): boolean {
|
|
269
|
+
const s = input.replace(/[-\s]/g, "");
|
|
270
|
+
if (!/^\d{10}$/.test(s)) return false;
|
|
271
|
+
const weights = [1, 3, 7, 1, 3, 7, 1, 3, 5];
|
|
272
|
+
let sum = 0;
|
|
273
|
+
for (let i = 0; i < 9; i++) {
|
|
274
|
+
sum += parseInt(s[i] ?? "", 10) * (weights[i] ?? 0);
|
|
275
|
+
}
|
|
276
|
+
sum += Math.floor((parseInt(s[8] ?? "", 10) * 5) / 10);
|
|
277
|
+
return (10 - (sum % 10)) % 10 === parseInt(s[9] ?? "", 10);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Chinese Resident Identity Card (居民身份证): 18 chars (17 digits + check).
|
|
281
|
+
// ISO 7064 MOD 11-2 per GB 11643-1999. Weights
|
|
282
|
+
// 7,9,10,5,8,4,2,1,6,3,7,9,10,5,8,4,2; remainder maps to "10X98765432".
|
|
283
|
+
export function validateChineseID(input: string): boolean {
|
|
284
|
+
const s = input.toUpperCase().replace(/[-\s]/g, "");
|
|
285
|
+
if (!/^\d{17}[\dX]$/.test(s)) return false;
|
|
286
|
+
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
|
|
287
|
+
const code = "10X98765432";
|
|
288
|
+
let sum = 0;
|
|
289
|
+
for (let i = 0; i < 17; i++) {
|
|
290
|
+
sum += parseInt(s[i] ?? "", 10) * (weights[i] ?? 0);
|
|
291
|
+
}
|
|
292
|
+
return code[sum % 11] === s[17];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// IPv4 reserved / non-public ranges. Returns true for addresses that should
|
|
296
|
+
// NOT be flagged as PII (loopback, private, link-local, TEST-NET, multicast,
|
|
297
|
+
// reserved, CGN, benchmarking). Used by pii-ipv4-public to keep only public IPs.
|
|
298
|
+
export function isReservedIpv4(ip: string): boolean {
|
|
299
|
+
const octets = ip.split(".");
|
|
300
|
+
// Require exactly 4 octets of 1–3 digits each, so partial parses
|
|
301
|
+
// (e.g. "1a" → 1 via parseInt) are treated as malformed, not public.
|
|
302
|
+
if (octets.length !== 4 || octets.some((o) => !/^\d{1,3}$/.test(o))) {
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
const parts = octets.map((o) => parseInt(o, 10));
|
|
306
|
+
if (parts.some((p) => p > 255)) {
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
const a = parts[0] ?? 0;
|
|
310
|
+
const b = parts[1] ?? 0;
|
|
311
|
+
const c = parts[2] ?? 0;
|
|
312
|
+
if (a === 0 || a === 10) return true;
|
|
313
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // CGN 100.64.0.0/10
|
|
314
|
+
if (a === 127) return true; // loopback
|
|
315
|
+
if (a === 169 && b === 254) return true; // link-local
|
|
316
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // private
|
|
317
|
+
if (a === 192 && b === 0 && c === 0) return true; // IETF protocol assignments
|
|
318
|
+
if (a === 192 && b === 0 && c === 2) return true; // TEST-NET-1
|
|
319
|
+
if (a === 192 && b === 88 && c === 99) return true; // 6to4 relay anycast (deprecated)
|
|
320
|
+
if (a === 192 && b === 168) return true; // private
|
|
321
|
+
if (a === 198 && (b === 18 || b === 19)) return true; // benchmark
|
|
322
|
+
if (a === 198 && b === 51 && c === 100) return true; // TEST-NET-2
|
|
323
|
+
if (a === 203 && b === 0 && c === 113) return true; // TEST-NET-3
|
|
324
|
+
if (a >= 224) return true; // multicast + reserved
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// IPv6 reserved / non-public ranges. Returns true for addresses that should
|
|
329
|
+
// NOT be flagged as PII (loopback, unspecified, link-local, unique-local,
|
|
330
|
+
// multicast, documentation). Properly handles both compressed (::) and
|
|
331
|
+
// fully-expanded (0:0:0:0:0:0:0:1) forms. Used by pii-ipv6.
|
|
332
|
+
// Each group must be 1–4 hex digits; anything else is malformed.
|
|
333
|
+
const isHexGroup = (g: string): boolean => /^[0-9a-f]{1,4}$/.test(g);
|
|
334
|
+
export function isReservedIpv6(ip: string): boolean {
|
|
335
|
+
const lower = ip.toLowerCase();
|
|
336
|
+
|
|
337
|
+
// Multiple :: is invalid — treat as reserved.
|
|
338
|
+
const halves = lower.split("::");
|
|
339
|
+
if (halves.length > 2) return true;
|
|
340
|
+
|
|
341
|
+
// Split and expand :: notation into zero groups.
|
|
342
|
+
let groups: number[];
|
|
343
|
+
if (halves.length === 1) {
|
|
344
|
+
const raw = lower.split(":");
|
|
345
|
+
if (raw.some((g) => !isHexGroup(g))) return true;
|
|
346
|
+
groups = raw.map((g) => Number.parseInt(g, 16));
|
|
347
|
+
} else {
|
|
348
|
+
const leftRaw = halves[0] ? halves[0].split(":") : [];
|
|
349
|
+
const rightRaw = halves[1] ? halves[1].split(":") : [];
|
|
350
|
+
if (
|
|
351
|
+
leftRaw.some((g) => !isHexGroup(g)) ||
|
|
352
|
+
rightRaw.some((g) => !isHexGroup(g))
|
|
353
|
+
) {
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
// Too many groups to fit in 128 bits — malformed. A "::" that compresses
|
|
357
|
+
// zero groups (left + right === 8) is also invalid per RFC 4291 §2.2.
|
|
358
|
+
if (leftRaw.length + rightRaw.length >= 8) return true;
|
|
359
|
+
const left = leftRaw.map((g) => Number.parseInt(g, 16));
|
|
360
|
+
const right = rightRaw.map((g) => Number.parseInt(g, 16));
|
|
361
|
+
const zeros = Array(8 - left.length - right.length).fill(0);
|
|
362
|
+
groups = [...left, ...zeros, ...right];
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (groups.length !== 8) return true; // malformed — treat as reserved
|
|
366
|
+
|
|
367
|
+
// Unspecified (::)
|
|
368
|
+
if (groups.every((g) => g === 0)) return true;
|
|
369
|
+
// Loopback (::1)
|
|
370
|
+
if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1) return true;
|
|
371
|
+
// Link-local fe80::/10
|
|
372
|
+
if ((groups[0] ?? 0) >= 0xfe80 && (groups[0] ?? 0) <= 0xfebf) return true;
|
|
373
|
+
// Unique-local fc00::/7
|
|
374
|
+
if (((groups[0] ?? 0) & 0xfe00) === 0xfc00) return true;
|
|
375
|
+
// Multicast ff00::/8
|
|
376
|
+
if (((groups[0] ?? 0) & 0xff00) === 0xff00) return true;
|
|
377
|
+
// Documentation 2001:db8::/32
|
|
378
|
+
if ((groups[0] ?? 0) === 0x2001 && (groups[1] ?? 0) === 0x0db8) return true;
|
|
379
|
+
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Shannon entropy (bits per character; ≈0–8 for byte-sized alphabets)
|
|
62
384
|
export function entropy(str: string): number {
|
|
63
385
|
if (str.length === 0) return 0;
|
|
64
386
|
const freq: Record<string, number> = {};
|
|
@@ -72,244 +394,282 @@ export function entropy(str: string): number {
|
|
|
72
394
|
return h;
|
|
73
395
|
}
|
|
74
396
|
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
{
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
{
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
397
|
+
// ── Context enhancement ──────────────────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
// Set from the default config during module initialisation (see buildRules).
|
|
400
|
+
let effectiveContextWindow = 3;
|
|
401
|
+
|
|
402
|
+
export function getDefaultContextWindow(): number {
|
|
403
|
+
return effectiveContextWindow;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Split on whitespace and Unicode punctuation. A cheap tokenizer with no NLP
|
|
407
|
+
// dependency, sufficient for matching context labels (phone, ZIP, etc.) in
|
|
408
|
+
// Latin-script text. Japanese PII rules rely on prefixes (〒) or required
|
|
409
|
+
// separators rather than context words, so this tokenizer not needing to
|
|
410
|
+
// handle Japanese word segmentation is acceptable.
|
|
411
|
+
function tokenize(text: string): string[] {
|
|
412
|
+
return text
|
|
413
|
+
.toLowerCase()
|
|
414
|
+
.split(/[\s\p{P}]+/u)
|
|
415
|
+
.filter(Boolean);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function hasNearbyContextWord(
|
|
419
|
+
text: string,
|
|
420
|
+
matchStart: number,
|
|
421
|
+
matchEnd: number,
|
|
422
|
+
contextWords: string[],
|
|
423
|
+
windowTokens: number,
|
|
424
|
+
): boolean {
|
|
425
|
+
if (contextWords.length === 0) return true;
|
|
426
|
+
const charWindow = windowTokens * 8;
|
|
427
|
+
const before = text.slice(Math.max(0, matchStart - charWindow), matchStart);
|
|
428
|
+
const after = text.slice(matchEnd, matchEnd + charWindow);
|
|
429
|
+
const nearby = new Set(tokenize(`${before} ${after}`));
|
|
430
|
+
return contextWords.some((word) => nearby.has(word.toLowerCase()));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ── Validator registry ───────────────────────────────────────────────────────
|
|
434
|
+
// Validators are code (checksum algorithms), not data. They live here and are
|
|
435
|
+
// referenced by name from the JSON config. User-defined rules can use any of
|
|
436
|
+
// these validators or omit `validate` entirely.
|
|
437
|
+
|
|
438
|
+
const VALIDATORS: Readonly<Record<string, (str: string) => boolean>> = {
|
|
439
|
+
luhn,
|
|
440
|
+
"mynumber-jp": validateMyNumber,
|
|
441
|
+
"nir-fr": validateFrenchNIR,
|
|
442
|
+
"codice-fiscale-it": validateCodiceFiscale,
|
|
443
|
+
"steuer-id-de": validateGermanIdNr,
|
|
444
|
+
"dni-nie-es": validateSpanishNIF,
|
|
445
|
+
"rrn-kr": validateKoreanRRN,
|
|
446
|
+
"brn-kr": validateKoreanBRN,
|
|
447
|
+
"resident-id-cn": validateChineseID,
|
|
448
|
+
"public-ipv4": (ip: string) => !isReservedIpv4(ip),
|
|
449
|
+
"public-ipv6": (ip: string) => !isReservedIpv6(ip),
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
export function getValidator(
|
|
453
|
+
name: string,
|
|
454
|
+
): ((str: string) => boolean) | undefined {
|
|
455
|
+
return VALIDATORS[name];
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ── Config loading ───────────────────────────────────────────────────────────
|
|
459
|
+
|
|
460
|
+
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
461
|
+
const DEFAULT_CONFIG_PATH = join(MODULE_DIR, "default-config.json");
|
|
462
|
+
const { SENSITIVE_CANARY_CONFIG: userConfigPath } = process.env;
|
|
463
|
+
const USER_CONFIG_PATH =
|
|
464
|
+
userConfigPath ??
|
|
465
|
+
join(homedir(), ".config", "sensitive-canary", "config.json");
|
|
466
|
+
|
|
467
|
+
function readJsonFile(filePath: string): unknown {
|
|
468
|
+
return JSON.parse(readFileSync(filePath, "utf-8"));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Validate a raw JSON object against the RuleConfig schema. Throws with a
|
|
472
|
+
// descriptive message when a required field is missing, a type is wrong, or a
|
|
473
|
+
// cross-field constraint is violated.
|
|
474
|
+
function validateRuleConfig(rc: unknown): asserts rc is RuleConfig {
|
|
475
|
+
if (typeof rc !== "object" || rc === null) {
|
|
476
|
+
throw new Error("rule must be an object");
|
|
477
|
+
}
|
|
478
|
+
const {
|
|
479
|
+
id,
|
|
480
|
+
description,
|
|
481
|
+
regex: source,
|
|
482
|
+
category,
|
|
483
|
+
flags,
|
|
484
|
+
secretGroup,
|
|
485
|
+
entropyThreshold,
|
|
486
|
+
validate: validateName,
|
|
487
|
+
contextWords,
|
|
488
|
+
requireContext,
|
|
489
|
+
contextWindow,
|
|
490
|
+
} = rc as Record<string, unknown>;
|
|
491
|
+
|
|
492
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
493
|
+
throw new Error('missing or empty "id" field');
|
|
494
|
+
}
|
|
495
|
+
if (typeof description !== "string" || description.length === 0) {
|
|
496
|
+
throw new Error('missing or empty "description" field');
|
|
497
|
+
}
|
|
498
|
+
if (typeof source !== "string" || source.length === 0) {
|
|
499
|
+
throw new Error('missing or empty "regex" field');
|
|
500
|
+
}
|
|
501
|
+
if (category !== "secret" && category !== "pii") {
|
|
502
|
+
throw new Error(
|
|
503
|
+
`invalid "category" ${JSON.stringify(category)} (must be "secret" or "pii")`,
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
if (flags != null && typeof flags !== "string") {
|
|
507
|
+
throw new Error('"flags" must be a string');
|
|
508
|
+
}
|
|
509
|
+
if (
|
|
510
|
+
secretGroup != null &&
|
|
511
|
+
(typeof secretGroup !== "number" ||
|
|
512
|
+
!Number.isInteger(secretGroup) ||
|
|
513
|
+
secretGroup < 0)
|
|
514
|
+
) {
|
|
515
|
+
throw new Error('"secretGroup" must be a non-negative integer');
|
|
516
|
+
}
|
|
517
|
+
if (
|
|
518
|
+
entropyThreshold != null &&
|
|
519
|
+
(typeof entropyThreshold !== "number" || entropyThreshold < 0)
|
|
520
|
+
) {
|
|
521
|
+
throw new Error('"entropyThreshold" must be a non-negative number');
|
|
522
|
+
}
|
|
523
|
+
if (validateName != null && typeof validateName !== "string") {
|
|
524
|
+
throw new Error('"validate" must be a string');
|
|
525
|
+
}
|
|
526
|
+
if (contextWords != null) {
|
|
527
|
+
if (
|
|
528
|
+
!Array.isArray(contextWords) ||
|
|
529
|
+
contextWords.some((w) => typeof w !== "string" || w.length === 0)
|
|
530
|
+
) {
|
|
531
|
+
throw new Error('"contextWords" must be an array of non-empty strings');
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (requireContext != null && typeof requireContext !== "boolean") {
|
|
535
|
+
throw new Error('"requireContext" must be a boolean');
|
|
536
|
+
}
|
|
537
|
+
if (
|
|
538
|
+
contextWindow != null &&
|
|
539
|
+
(typeof contextWindow !== "number" ||
|
|
540
|
+
!Number.isInteger(contextWindow) ||
|
|
541
|
+
contextWindow < 1)
|
|
542
|
+
) {
|
|
543
|
+
throw new Error('"contextWindow" must be a positive integer');
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Cross-field: requireContext is meaningless without contextWords
|
|
547
|
+
if (
|
|
548
|
+
requireContext === true &&
|
|
549
|
+
(!Array.isArray(contextWords) || contextWords.length === 0)
|
|
550
|
+
) {
|
|
551
|
+
throw new Error(
|
|
552
|
+
'"requireContext" is true but "contextWords" is empty — context gating would be disabled and the rule would always fire',
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Compile a single RuleConfig (JSON) into a Rule (with compiled RegExp and
|
|
558
|
+
// resolved validator function). Throws on invalid regex or missing required
|
|
559
|
+
// fields so the caller (buildRules) can catch and warn per-rule.
|
|
560
|
+
export function compileRule(rc: RuleConfig): Rule {
|
|
561
|
+
validateRuleConfig(rc);
|
|
562
|
+
const { regex: source, flags, validate: validateName, ...rest } = rc;
|
|
563
|
+
// matchAll requires the global flag; ensure it is always present.
|
|
564
|
+
const flagStr = flags ?? "g";
|
|
565
|
+
const withG = flagStr.includes("g") ? flagStr : `${flagStr}g`;
|
|
566
|
+
const rule: Rule = {
|
|
567
|
+
...rest,
|
|
568
|
+
regex: new RegExp(source, withG),
|
|
569
|
+
};
|
|
570
|
+
if (validateName) {
|
|
571
|
+
const fn = VALIDATORS[validateName];
|
|
572
|
+
if (fn) {
|
|
573
|
+
rule.validate = fn;
|
|
574
|
+
} else {
|
|
575
|
+
process.stderr.write(
|
|
576
|
+
`sensitive-canary: unknown validator "${validateName}" in rule "${rc.id}" — validation disabled\n`,
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return rule;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Load and compile the built-in default rules from default-config.json.
|
|
584
|
+
function loadDefaultConfig(): CanaryConfig {
|
|
585
|
+
return readJsonFile(DEFAULT_CONFIG_PATH) as CanaryConfig;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Load user config if it exists. Returns null when the file is absent (the
|
|
589
|
+
// common case). JSON parse errors and permission issues are reported on stderr
|
|
590
|
+
// so that a broken config file is not silently ignored.
|
|
591
|
+
function loadUserConfig(): CanaryConfig | null {
|
|
592
|
+
try {
|
|
593
|
+
return readJsonFile(USER_CONFIG_PATH) as CanaryConfig;
|
|
594
|
+
} catch (e) {
|
|
595
|
+
if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
596
|
+
process.stderr.write(
|
|
597
|
+
`sensitive-canary: could not read user config "${USER_CONFIG_PATH}": ${e instanceof Error ? e.message : String(e)}\n`,
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Build the final rule list: default rules first, then user rules. A user rule
|
|
605
|
+
// with the same id as a built-in rule replaces it; new ids are appended.
|
|
606
|
+
// Invalid user rules (bad regex, etc.) are skipped with a warning so that one
|
|
607
|
+
// bad entry does not break the entire hook.
|
|
608
|
+
function buildRules(): Rule[] {
|
|
609
|
+
const defaultConfig = loadDefaultConfig();
|
|
610
|
+
effectiveContextWindow = defaultConfig.contextWindow ?? 3;
|
|
611
|
+
|
|
612
|
+
const defaultRules: Rule[] = [];
|
|
613
|
+
for (const rc of defaultConfig.rules) {
|
|
614
|
+
try {
|
|
615
|
+
defaultRules.push(compileRule(rc));
|
|
616
|
+
} catch (e) {
|
|
617
|
+
process.stderr.write(
|
|
618
|
+
`sensitive-canary: failed to compile built-in rule "${(rc as { id?: unknown })?.id ?? "(unknown)"}": ${e instanceof Error ? e.message : String(e)}\n`,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const userConfig = loadUserConfig();
|
|
624
|
+
if (userConfig) {
|
|
625
|
+
if (
|
|
626
|
+
typeof userConfig.contextWindow === "number" &&
|
|
627
|
+
Number.isInteger(userConfig.contextWindow) &&
|
|
628
|
+
userConfig.contextWindow >= 1
|
|
629
|
+
) {
|
|
630
|
+
effectiveContextWindow = userConfig.contextWindow;
|
|
631
|
+
} else if (userConfig.contextWindow != null) {
|
|
632
|
+
process.stderr.write(
|
|
633
|
+
`sensitive-canary: invalid contextWindow in user config, ignoring\n`,
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
if (userConfig.rules != null && !Array.isArray(userConfig.rules)) {
|
|
637
|
+
process.stderr.write(
|
|
638
|
+
`sensitive-canary: "rules" in user config must be an array, ignoring\n`,
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
if (Array.isArray(userConfig.rules) && userConfig.rules.length) {
|
|
642
|
+
const userRules: Rule[] = [];
|
|
643
|
+
for (const rc of userConfig.rules) {
|
|
644
|
+
try {
|
|
645
|
+
userRules.push(compileRule(rc));
|
|
646
|
+
} catch (e) {
|
|
647
|
+
process.stderr.write(
|
|
648
|
+
`sensitive-canary: skipping user rule "${(rc as { id?: unknown })?.id ?? "(unknown)"}": ${e instanceof Error ? e.message : String(e)}\n`,
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
// De-duplicate by id (last definition wins) so duplicate ids in the
|
|
653
|
+
// user config don't produce duplicate rules and duplicate findings.
|
|
654
|
+
const byId = new Map<string, Rule>();
|
|
655
|
+
for (const rule of userRules) {
|
|
656
|
+
if (byId.has(rule.id)) {
|
|
657
|
+
process.stderr.write(
|
|
658
|
+
`sensitive-canary: duplicate user rule id "${rule.id}" — using the last definition\n`,
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
byId.set(rule.id, rule);
|
|
662
|
+
}
|
|
663
|
+
return defaultRules
|
|
664
|
+
.filter((r) => !byId.has(r.id))
|
|
665
|
+
.concat(...byId.values());
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return defaultRules;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
export const RULES: Rule[] = buildRules();
|
|
313
673
|
|
|
314
674
|
// Show first 4 + **** + last 4 chars; fully mask strings of 8 chars or fewer
|
|
315
675
|
export function redact(str: string): string {
|
|
@@ -335,7 +695,24 @@ export function scan(
|
|
|
335
695
|
entropy(secretValue) < rule.entropyThreshold
|
|
336
696
|
)
|
|
337
697
|
continue;
|
|
338
|
-
if (rule.validate != null && !rule.validate(
|
|
698
|
+
if (rule.validate != null && !rule.validate(secretValue)) continue;
|
|
699
|
+
|
|
700
|
+
const matchStart = match.index ?? 0;
|
|
701
|
+
const matchEnd = matchStart + match[0].length;
|
|
702
|
+
const hasContext =
|
|
703
|
+
!rule.contextWords || rule.contextWords.length === 0
|
|
704
|
+
? true
|
|
705
|
+
: hasNearbyContextWord(
|
|
706
|
+
text,
|
|
707
|
+
matchStart,
|
|
708
|
+
matchEnd,
|
|
709
|
+
rule.contextWords,
|
|
710
|
+
rule.contextWindow ?? effectiveContextWindow,
|
|
711
|
+
);
|
|
712
|
+
|
|
713
|
+
// Rules that require context (e.g. bare postal codes) are dropped when
|
|
714
|
+
// no context label is nearby, to avoid flagging every 5-digit number.
|
|
715
|
+
if (rule.requireContext && !hasContext) continue;
|
|
339
716
|
|
|
340
717
|
findings.push({
|
|
341
718
|
ruleId: rule.id,
|
|
@@ -343,6 +720,7 @@ export function scan(
|
|
|
343
720
|
category: rule.category,
|
|
344
721
|
matchRedacted: redact(secretValue),
|
|
345
722
|
secretValue,
|
|
723
|
+
score: hasContext ? 1.0 : 0.4,
|
|
346
724
|
});
|
|
347
725
|
}
|
|
348
726
|
}
|