@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/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
- // Shannon entropy (bits per character, 0–8 scale)
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
- // Patterns sourced from gitleaks and TruffleHog detector definitions.
76
- // Each rule:
77
- // regex — must have /g flag
78
- // secretGroup — capture group containing the secret (default: 0 = full match)
79
- // entropyThreshold — skip match if entropy(secretValue) is below threshold
80
-
81
- // ── Secrets ───────────────────────────────────────────────────────────────────
82
-
83
- const SECRET_RULES: Rule[] = [
84
- // Cloud
85
- {
86
- id: "aws-access-key",
87
- description: "AWS Access Key ID",
88
- regex:
89
- /\b(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}\b/g,
90
- category: "secret",
91
- },
92
- {
93
- id: "gcp-api-key",
94
- description: "Google Cloud API Key",
95
- regex: /AIza[0-9A-Za-z_-]{35}/g,
96
- category: "secret",
97
- },
98
- {
99
- id: "private-key",
100
- description: "PEM Private Key",
101
- // Covers RSA, EC, DSA, PGP, and OpenSSH private keys
102
- regex: /-----BEGIN (RSA |EC |DSA |PGP |OPENSSH )?PRIVATE KEY/g,
103
- category: "secret",
104
- },
105
-
106
- // Source control
107
- {
108
- id: "github-pat",
109
- description: "GitHub Personal Access Token",
110
- regex: /gh[pousr]_[A-Za-z0-9]{36,255}/g,
111
- category: "secret",
112
- },
113
- {
114
- id: "github-fine-grained",
115
- description: "GitHub Fine-Grained Token",
116
- regex: /github_pat_[A-Za-z0-9_]{82}/g,
117
- category: "secret",
118
- },
119
- {
120
- id: "gitlab-pat",
121
- description: "GitLab Personal Access Token",
122
- regex: /glpat-[A-Za-z0-9_=-]{20,22}/g,
123
- category: "secret",
124
- },
125
-
126
- // Package registries
127
- {
128
- id: "npm-token",
129
- description: "npm Access Token",
130
- regex: /npm_[A-Za-z0-9]{36}/g,
131
- category: "secret",
132
- },
133
-
134
- // Communication
135
- {
136
- id: "slack-token",
137
- description: "Slack Token",
138
- regex: /xox[baprs]-[0-9a-zA-Z-]{10,72}/g,
139
- category: "secret",
140
- },
141
- {
142
- id: "slack-webhook",
143
- description: "Slack Webhook URL",
144
- regex:
145
- /https:\/\/hooks\.slack\.com\/services\/T[A-Za-z0-9_]{8,10}\/B[A-Za-z0-9_]{8,12}\/[A-Za-z0-9_]{23,24}/g,
146
- category: "secret",
147
- },
148
- {
149
- id: "discord-webhook",
150
- description: "Discord Webhook URL",
151
- regex:
152
- /https:\/\/discord(?:app)?\.com\/api\/webhooks\/[0-9]{17,20}\/[A-Za-z0-9_-]{68}/g,
153
- category: "secret",
154
- },
155
- {
156
- id: "telegram-bot-token",
157
- description: "Telegram Bot Token",
158
- regex: /[0-9]{8,10}:AA[0-9A-Za-z_-]{33}/g,
159
- category: "secret",
160
- },
161
- {
162
- id: "twilio-sid",
163
- description: "Twilio Account SID",
164
- regex: /AC[0-9a-f]{32}/g,
165
- category: "secret",
166
- },
167
-
168
- // Email services
169
- {
170
- id: "sendgrid-key",
171
- description: "SendGrid API Key",
172
- regex: /SG\.[A-Za-z0-9_-]{20,24}\.[A-Za-z0-9_-]{39,50}/g,
173
- category: "secret",
174
- },
175
- {
176
- id: "mailgun-key",
177
- description: "Mailgun API Key",
178
- regex: /key-[0-9a-zA-Z]{32}/g,
179
- category: "secret",
180
- },
181
- {
182
- id: "mailchimp-key",
183
- description: "Mailchimp API Key",
184
- regex: /[0-9a-f]{32}-us[0-9]{1,2}/g,
185
- category: "secret",
186
- },
187
-
188
- // Payment
189
- {
190
- id: "stripe-secret-key",
191
- description: "Stripe Secret Key",
192
- regex: /sk_(live|test)_[0-9a-zA-Z]{24}/g,
193
- category: "secret",
194
- },
195
- {
196
- id: "stripe-restricted-key",
197
- description: "Stripe Restricted Key",
198
- regex: /rk_(live|test)_[0-9a-zA-Z]{24}/g,
199
- category: "secret",
200
- },
201
-
202
- // AI services
203
- {
204
- id: "openai-key",
205
- description: "OpenAI API Key (legacy)",
206
- regex: /sk-(?!proj-|ant-)[A-Za-z0-9]{48}/g,
207
- category: "secret",
208
- },
209
- {
210
- id: "openai-project-key",
211
- description: "OpenAI Project API Key",
212
- regex: /sk-proj-[A-Za-z0-9_-]{40,}/g,
213
- entropyThreshold: 3.5,
214
- category: "secret",
215
- },
216
- {
217
- id: "anthropic-key",
218
- description: "Anthropic API Key",
219
- regex: /sk-ant-[A-Za-z0-9_-]{95}/g,
220
- category: "secret",
221
- },
222
-
223
- // Auth tokens
224
- {
225
- id: "jwt",
226
- description: "JSON Web Token (JWT)",
227
- regex: /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
228
- category: "secret",
229
- },
230
-
231
- // Generic / env-based
232
- {
233
- id: "generic-secret",
234
- description: "Generic API Key / Secret",
235
- regex:
236
- /(api[_-]?key|secret[_-]?key|access[_-]?token|api[_-]?secret)\s*[:=]\s*['"]?([A-Za-z0-9\-_.]{20,})/gi,
237
- secretGroup: 2,
238
- entropyThreshold: 3.5,
239
- category: "secret",
240
- },
241
- {
242
- id: "env-assignment",
243
- description: ".env style secret assignment",
244
- regex:
245
- /\b[A-Z_]*(SECRET|PASSWORD|PASSWD|TOKEN|API_KEY|PRIVATE_KEY)[A-Z_0-9]*\s*=\s*(\S{8,})/g,
246
- secretGroup: 2,
247
- entropyThreshold: 3.0,
248
- category: "secret",
249
- },
250
- {
251
- id: "connection-string",
252
- description: "Database Connection String with credentials",
253
- regex: /(mongodb|mysql|postgres|postgresql|redis):\/\/[^:\s]+:[^@\s]+@/g,
254
- category: "secret",
255
- },
256
- ];
257
-
258
- // ── PII ───────────────────────────────────────────────────────────────────────
259
-
260
- const PII_RULES: Rule[] = [
261
- {
262
- id: "pii-email",
263
- description: "Email Address",
264
- regex: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
265
- category: "pii",
266
- },
267
- {
268
- id: "pii-credit-card",
269
- description: "Credit Card Number",
270
- // Visa (16d) | Mastercard (16d) | Amex (15d) | Discover (16d)
271
- // Optional spaces or dashes between digit groups
272
- regex:
273
- /\b(?:4[0-9]{3}(?:[\s-]?[0-9]{4}){3}|5[1-5][0-9]{2}(?:[\s-]?[0-9]{4}){3}|3[47][0-9]{2}[\s-]?[0-9]{6}[\s-]?[0-9]{5}|6(?:011|5[0-9]{2})[0-9](?:[\s-]?[0-9]{4}){3})\b/g,
274
- validate: luhn,
275
- category: "pii",
276
- },
277
- {
278
- id: "pii-ssn",
279
- description: "US Social Security Number",
280
- regex: /\b(?!000|666|9\d{2})\d{3}[- ](?!00)\d{2}[- ](?!0000)\d{4}\b/g,
281
- category: "pii",
282
- },
283
- {
284
- id: "pii-phone-us",
285
- description: "US Phone Number",
286
- regex: /\b(\+1[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}\b/g,
287
- category: "pii",
288
- },
289
- {
290
- id: "pii-phone-jp",
291
- description: "Japanese Phone Number",
292
- regex: /\b0\d{1,4}[\s-]\d{1,4}[\s-]\d{4}\b/g,
293
- category: "pii",
294
- },
295
- {
296
- id: "pii-postal-jp",
297
- description: "Japanese Postal Code",
298
- // Require 〒 prefix to avoid false positives (e.g. phone number fragments)
299
- regex: /〒\d{3}[\s-]\d{4}/g,
300
- category: "pii",
301
- },
302
- {
303
- id: "pii-ipv4",
304
- description: "IPv4 Address (private range)",
305
- // Only flag RFC-1918 private addresses to reduce noise
306
- regex:
307
- /\b(10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b/g,
308
- category: "pii",
309
- },
310
- ];
311
-
312
- export const RULES: Rule[] = [...SECRET_RULES, ...PII_RULES];
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(match[0])) continue;
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
  }