@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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +798 -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
package/src/lib/rules.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import vm from "node:vm";
|
|
6
|
+
import {
|
|
7
|
+
entropy,
|
|
8
|
+
isNotSecretShaped,
|
|
9
|
+
isPlaceholder,
|
|
10
|
+
keyDescribesRatherThanHolds,
|
|
11
|
+
} from "./shapes.ts";
|
|
12
|
+
import { getValidator } from "./validators.ts";
|
|
5
13
|
|
|
6
14
|
export type Category = "secret" | "pii";
|
|
7
15
|
|
|
@@ -24,6 +32,10 @@ interface Rule {
|
|
|
24
32
|
category: Category;
|
|
25
33
|
contextWords?: string[];
|
|
26
34
|
requireContext?: boolean;
|
|
35
|
+
// Words that, found near a match, say it is not what the rule is looking for —
|
|
36
|
+
// the mirror of contextWords. The postal-code rule uses it: `65536 bytes` and
|
|
37
|
+
// `max 3` are five-digit numbers beside a word that says they are not places.
|
|
38
|
+
excludeContext?: string[];
|
|
27
39
|
contextWindow?: number;
|
|
28
40
|
}
|
|
29
41
|
|
|
@@ -41,6 +53,8 @@ export interface RuleConfig {
|
|
|
41
53
|
category: Category;
|
|
42
54
|
contextWords?: string[];
|
|
43
55
|
requireContext?: boolean;
|
|
56
|
+
// See Rule.excludeContext.
|
|
57
|
+
excludeContext?: string[];
|
|
44
58
|
contextWindow?: number;
|
|
45
59
|
}
|
|
46
60
|
|
|
@@ -74,326 +88,6 @@ export function enabledCategoriesFromEnv(): Set<Category> {
|
|
|
74
88
|
return parseCategories(SENSITIVE_CANARY_CATEGORIES);
|
|
75
89
|
}
|
|
76
90
|
|
|
77
|
-
// Luhn algorithm checksum validation. Returns true if the number (digits only) passes.
|
|
78
|
-
export function luhn(str: string): boolean {
|
|
79
|
-
const digits = str.replace(/\D/g, "");
|
|
80
|
-
if (digits.length === 0) return false;
|
|
81
|
-
let sum = 0;
|
|
82
|
-
let double = false;
|
|
83
|
-
for (let i = digits.length - 1; i >= 0; i--) {
|
|
84
|
-
let d = parseInt(digits[i] ?? "", 10);
|
|
85
|
-
if (double) {
|
|
86
|
-
d *= 2;
|
|
87
|
-
if (d > 9) d -= 9;
|
|
88
|
-
}
|
|
89
|
-
sum += d;
|
|
90
|
-
double = !double;
|
|
91
|
-
}
|
|
92
|
-
return sum % 10 === 0;
|
|
93
|
-
}
|
|
94
|
-
|
|
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)
|
|
384
|
-
export function entropy(str: string): number {
|
|
385
|
-
if (str.length === 0) return 0;
|
|
386
|
-
const freq: Record<string, number> = {};
|
|
387
|
-
for (const ch of str) freq[ch] = (freq[ch] ?? 0) + 1;
|
|
388
|
-
let h = 0;
|
|
389
|
-
const n = str.length;
|
|
390
|
-
for (const count of Object.values(freq)) {
|
|
391
|
-
const p = count / n;
|
|
392
|
-
h -= p * Math.log2(p);
|
|
393
|
-
}
|
|
394
|
-
return h;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
91
|
// ── Context enhancement ──────────────────────────────────────────────────────
|
|
398
92
|
|
|
399
93
|
// Set from the default config during module initialisation (see buildRules).
|
|
@@ -403,16 +97,21 @@ export function getDefaultContextWindow(): number {
|
|
|
403
97
|
return effectiveContextWindow;
|
|
404
98
|
}
|
|
405
99
|
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
function
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
100
|
+
// Words as they were written, with only the punctuation around them removed.
|
|
101
|
+
// Splitting on punctuation made `extract-zip` supply "zip" and
|
|
102
|
+
// `golang.org/x/mobile` supply "mobile", so a version number beside either read
|
|
103
|
+
// as a postal code or a telephone number — which is to say lockfiles and
|
|
104
|
+
// `go.sum` could not be read.
|
|
105
|
+
function contextTokens(text: string): Set<string> {
|
|
106
|
+
const out = new Set<string>();
|
|
107
|
+
for (const raw of text.split(/\s+/)) {
|
|
108
|
+
const word = raw
|
|
109
|
+
.replace(/^[\p{P}\p{S}]+/gu, "")
|
|
110
|
+
.replace(/[\p{P}\p{S}]+$/gu, "")
|
|
111
|
+
.toLowerCase();
|
|
112
|
+
if (word) out.add(word);
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
416
115
|
}
|
|
417
116
|
|
|
418
117
|
function hasNearbyContextWord(
|
|
@@ -426,33 +125,17 @@ function hasNearbyContextWord(
|
|
|
426
125
|
const charWindow = windowTokens * 8;
|
|
427
126
|
const before = text.slice(Math.max(0, matchStart - charWindow), matchStart);
|
|
428
127
|
const after = text.slice(matchEnd, matchEnd + charWindow);
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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];
|
|
128
|
+
const window = `${before} ${after}`;
|
|
129
|
+
const nearby = contextTokens(window);
|
|
130
|
+
const lowered = window.toLowerCase();
|
|
131
|
+
return contextWords.some((raw) => {
|
|
132
|
+
const word = raw.toLowerCase();
|
|
133
|
+
// A label in a language that does not put spaces around its words is
|
|
134
|
+
// written against the number, so it is looked for as written.
|
|
135
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: the ASCII range is the test
|
|
136
|
+
if (!/^[\x00-\x7f]+$/.test(word)) return lowered.includes(word);
|
|
137
|
+
return nearby.has(word);
|
|
138
|
+
});
|
|
456
139
|
}
|
|
457
140
|
|
|
458
141
|
// ── Config loading ───────────────────────────────────────────────────────────
|
|
@@ -485,6 +168,7 @@ function validateRuleConfig(rc: unknown): asserts rc is RuleConfig {
|
|
|
485
168
|
entropyThreshold,
|
|
486
169
|
validate: validateName,
|
|
487
170
|
contextWords,
|
|
171
|
+
excludeContext,
|
|
488
172
|
requireContext,
|
|
489
173
|
contextWindow,
|
|
490
174
|
} = rc as Record<string, unknown>;
|
|
@@ -523,6 +207,14 @@ function validateRuleConfig(rc: unknown): asserts rc is RuleConfig {
|
|
|
523
207
|
if (validateName != null && typeof validateName !== "string") {
|
|
524
208
|
throw new Error('"validate" must be a string');
|
|
525
209
|
}
|
|
210
|
+
if (excludeContext != null) {
|
|
211
|
+
if (
|
|
212
|
+
!Array.isArray(excludeContext) ||
|
|
213
|
+
excludeContext.some((w) => typeof w !== "string" || w.length === 0)
|
|
214
|
+
) {
|
|
215
|
+
throw new Error('"excludeContext" must be an array of non-empty strings');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
526
218
|
if (contextWords != null) {
|
|
527
219
|
if (
|
|
528
220
|
!Array.isArray(contextWords) ||
|
|
@@ -568,7 +260,7 @@ export function compileRule(rc: RuleConfig): Rule {
|
|
|
568
260
|
regex: new RegExp(source, withG),
|
|
569
261
|
};
|
|
570
262
|
if (validateName) {
|
|
571
|
-
const fn =
|
|
263
|
+
const fn = getValidator(validateName);
|
|
572
264
|
if (fn) {
|
|
573
265
|
rule.validate = fn;
|
|
574
266
|
} else {
|
|
@@ -590,6 +282,16 @@ function loadDefaultConfig(): CanaryConfig {
|
|
|
590
282
|
// so that a broken config file is not silently ignored.
|
|
591
283
|
function loadUserConfig(): CanaryConfig | null {
|
|
592
284
|
try {
|
|
285
|
+
// A FIFO or a device here would block the read until something wrote to
|
|
286
|
+
// it, and a hook that never returns is killed by the timeout, which does
|
|
287
|
+
// not block. The transcript reader and the file scanner both pay this stat
|
|
288
|
+
// already; this path was the one that did not.
|
|
289
|
+
if (!statSync(USER_CONFIG_PATH).isFile()) {
|
|
290
|
+
process.stderr.write(
|
|
291
|
+
`sensitive-canary: user config "${USER_CONFIG_PATH}" is not a regular file, ignoring\n`,
|
|
292
|
+
);
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
593
295
|
return readJsonFile(USER_CONFIG_PATH) as CanaryConfig;
|
|
594
296
|
} catch (e) {
|
|
595
297
|
if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
@@ -662,7 +364,7 @@ function buildRules(): Rule[] {
|
|
|
662
364
|
}
|
|
663
365
|
return defaultRules
|
|
664
366
|
.filter((r) => !byId.has(r.id))
|
|
665
|
-
.concat(
|
|
367
|
+
.concat(Array.from(byId.values()));
|
|
666
368
|
}
|
|
667
369
|
}
|
|
668
370
|
|
|
@@ -671,25 +373,144 @@ function buildRules(): Rule[] {
|
|
|
671
373
|
|
|
672
374
|
export const RULES: Rule[] = buildRules();
|
|
673
375
|
|
|
674
|
-
//
|
|
376
|
+
// Enough of a value to say which one was found, and no more.
|
|
377
|
+
//
|
|
378
|
+
// The block reason is written to stderr, which is where Claude reads it, so
|
|
379
|
+
// whatever is shown here reaches the API that the block exists to keep it from.
|
|
380
|
+
// Four characters at each end returned eight of a nine-character password.
|
|
381
|
+
// A quarter of the value, capped at four per end.
|
|
675
382
|
export function redact(str: string): string {
|
|
676
|
-
|
|
677
|
-
|
|
383
|
+
// Code points, not code units. Slicing by unit cuts a surrogate pair in half
|
|
384
|
+
// and writes a lone surrogate to the terminal, which is neither the character
|
|
385
|
+
// nor a redaction of it.
|
|
386
|
+
const characters = [...str];
|
|
387
|
+
const shown = Math.min(4, Math.floor(characters.length / 8));
|
|
388
|
+
if (shown === 0) return "****";
|
|
389
|
+
const head = characters.slice(0, shown).join("");
|
|
390
|
+
const tail = characters.slice(-shown).join("");
|
|
391
|
+
return `${head}****${tail}`;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Longer than any honest scan and far shorter than the hook timeout. A rule
|
|
395
|
+
// that backtracks badly takes minutes on a megabyte, and a hook killed by the
|
|
396
|
+
// timeout does not block, so the damage is silent. The patterns that did that
|
|
397
|
+
// are bounded; this catches the next one of that shape rather than letting it
|
|
398
|
+
// repeat. The check sits between rules because a single `matchAll` cannot be
|
|
399
|
+
// interrupted.
|
|
400
|
+
export const SCAN_BUDGET_MS = 10_000;
|
|
401
|
+
|
|
402
|
+
export class ScanBudgetExceeded extends Error {
|
|
403
|
+
constructor(ruleId: string, elapsed: number) {
|
|
404
|
+
super(
|
|
405
|
+
`the scan passed ${SCAN_BUDGET_MS}ms (${elapsed}ms at rule "${ruleId}")`,
|
|
406
|
+
);
|
|
407
|
+
this.name = "ScanBudgetExceeded";
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// The budget belongs to the hook invocation, not to one `scan()` call. A single
|
|
412
|
+
// call is a small part of the work: `scanEnvironment` scans once per variable,
|
|
413
|
+
// a file is scanned at both ends, and `Object.keys(process.env)` sets the
|
|
414
|
+
// multiplier. Per call, each stays inside the budget while the total runs past
|
|
415
|
+
// the hook timeout — and a hook killed by the timeout does not block.
|
|
416
|
+
//
|
|
417
|
+
// Set once by each hook entry point. Left unset, every call gets the full
|
|
418
|
+
// budget, which is what the test suite needs.
|
|
419
|
+
let deadline: number | null = null;
|
|
420
|
+
|
|
421
|
+
// `null` clears it, which is the state a process starts in.
|
|
422
|
+
export function beginScanBudget(totalMs: number | null = SCAN_BUDGET_MS): void {
|
|
423
|
+
deadline = totalMs === null ? null : Date.now() + totalMs;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// What is left of the budget, or the whole of it when none was begun.
|
|
427
|
+
function remainingBudget(): number {
|
|
428
|
+
return deadline === null ? SCAN_BUDGET_MS : deadline - Date.now();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// The between-rule check below cannot interrupt a single `matchAll`, and one
|
|
432
|
+
// rule from a user config is enough to hang the hook — which is then killed by
|
|
433
|
+
// the timeout, and a killed hook does not block. A V8-side timeout does
|
|
434
|
+
// interrupt a running match. Measured at 0.06ms per call, against a scan that
|
|
435
|
+
// costs hundreds of times that.
|
|
436
|
+
const SCAN_SLOT = "__sensitiveCanaryScan";
|
|
437
|
+
const HARD_LIMIT_SLACK_MS = 2_000;
|
|
438
|
+
|
|
439
|
+
// `limitMs` bounds this call so it cannot overshoot what the invocation has
|
|
440
|
+
// left. Without it a single call could run the full hard limit past a deadline
|
|
441
|
+
// that was already spent.
|
|
442
|
+
function runInterruptibly<T>(work: () => T, limitMs: number): T {
|
|
443
|
+
const slots = globalThis as unknown as Record<string, unknown>;
|
|
444
|
+
slots[SCAN_SLOT] = work;
|
|
445
|
+
try {
|
|
446
|
+
return vm.runInThisContext(`globalThis.${SCAN_SLOT}()`, {
|
|
447
|
+
timeout: limitMs,
|
|
448
|
+
displayErrors: false,
|
|
449
|
+
}) as T;
|
|
450
|
+
} catch (error) {
|
|
451
|
+
if (error instanceof Error && error.message.includes("timed out"))
|
|
452
|
+
throw new ScanBudgetExceeded("a single rule", limitMs);
|
|
453
|
+
throw error;
|
|
454
|
+
} finally {
|
|
455
|
+
delete slots[SCAN_SLOT];
|
|
456
|
+
}
|
|
678
457
|
}
|
|
679
458
|
|
|
680
459
|
export function scan(
|
|
681
460
|
text: string,
|
|
682
461
|
categories: ReadonlySet<Category> = ALL_CATEGORIES,
|
|
462
|
+
): Finding[] {
|
|
463
|
+
const remaining = remainingBudget();
|
|
464
|
+
if (remaining <= 0)
|
|
465
|
+
throw new ScanBudgetExceeded("this call's total", SCAN_BUDGET_MS);
|
|
466
|
+
return runInterruptibly(
|
|
467
|
+
() => scanUninterrupted(text, categories, remaining),
|
|
468
|
+
remaining + HARD_LIMIT_SLACK_MS,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function scanUninterrupted(
|
|
473
|
+
text: string,
|
|
474
|
+
categories: ReadonlySet<Category>,
|
|
475
|
+
budgetMs: number,
|
|
683
476
|
): Finding[] {
|
|
684
477
|
const findings: Finding[] = [];
|
|
478
|
+
const startedAt = Date.now();
|
|
685
479
|
|
|
686
480
|
for (const rule of RULES) {
|
|
687
481
|
if (!categories.has(rule.category)) continue;
|
|
482
|
+
const elapsed = Date.now() - startedAt;
|
|
483
|
+
// Thrown rather than returned: a partial result is indistinguishable from a
|
|
484
|
+
// clean one, and the hooks stop the call on an error they cannot explain.
|
|
485
|
+
if (elapsed > budgetMs) throw new ScanBudgetExceeded(rule.id, elapsed);
|
|
688
486
|
for (const match of text.matchAll(rule.regex)) {
|
|
689
487
|
const secretValue =
|
|
690
488
|
rule.secretGroup != null ? match[rule.secretGroup] : match[0];
|
|
691
489
|
|
|
692
490
|
if (!secretValue) continue;
|
|
491
|
+
// Both the captured value and the whole match: a rule with a
|
|
492
|
+
// `secretGroup` captures only part of what it matched, and the
|
|
493
|
+
// connection-string rule stops at the `@`, so the host — the one thing
|
|
494
|
+
// that separates `user:password@localhost` from `user:password@` in front
|
|
495
|
+
// of real infrastructure — is outside the capture.
|
|
496
|
+
const matchStart = match.index ?? 0;
|
|
497
|
+
const matchEnd = matchStart + match[0].length;
|
|
498
|
+
const following = text.slice(matchEnd, matchEnd + 64);
|
|
499
|
+
// The shape test applies only where the rule captured a free-form value.
|
|
500
|
+
// A rule that matches a fixed prefix has already said what the thing is —
|
|
501
|
+
// a Slack webhook is a URL and a secret, and asking whether it looks like
|
|
502
|
+
// a URL is asking the wrong question.
|
|
503
|
+
const capturesAValue = rule.secretGroup != null;
|
|
504
|
+
if (
|
|
505
|
+
rule.category === "secret" &&
|
|
506
|
+
(isPlaceholder(secretValue, following) ||
|
|
507
|
+
(capturesAValue &&
|
|
508
|
+
(isNotSecretShaped(secretValue) ||
|
|
509
|
+
isPlaceholder(match[0], following) ||
|
|
510
|
+
isNotSecretShaped(match[0]) ||
|
|
511
|
+
keyDescribesRatherThanHolds(match[0]))))
|
|
512
|
+
)
|
|
513
|
+
continue;
|
|
693
514
|
if (
|
|
694
515
|
rule.entropyThreshold != null &&
|
|
695
516
|
entropy(secretValue) < rule.entropyThreshold
|
|
@@ -697,8 +518,6 @@ export function scan(
|
|
|
697
518
|
continue;
|
|
698
519
|
if (rule.validate != null && !rule.validate(secretValue)) continue;
|
|
699
520
|
|
|
700
|
-
const matchStart = match.index ?? 0;
|
|
701
|
-
const matchEnd = matchStart + match[0].length;
|
|
702
521
|
const hasContext =
|
|
703
522
|
!rule.contextWords || rule.contextWords.length === 0
|
|
704
523
|
? true
|
|
@@ -714,6 +533,24 @@ export function scan(
|
|
|
714
533
|
// no context label is nearby, to avoid flagging every 5-digit number.
|
|
715
534
|
if (rule.requireContext && !hasContext) continue;
|
|
716
535
|
|
|
536
|
+
// And the other way: a word nearby that says this is not what the rule is
|
|
537
|
+
// for. `git clone git@github.com:…` and `ssh deploy@host` are addresses by
|
|
538
|
+
// shape, and the command in front of them is what says they are not
|
|
539
|
+
// anyone's mail.
|
|
540
|
+
if (
|
|
541
|
+
rule.excludeContext &&
|
|
542
|
+
rule.excludeContext.length > 0 &&
|
|
543
|
+
hasNearbyContextWord(
|
|
544
|
+
text,
|
|
545
|
+
matchStart,
|
|
546
|
+
matchEnd,
|
|
547
|
+
rule.excludeContext,
|
|
548
|
+
rule.contextWindow ?? effectiveContextWindow,
|
|
549
|
+
)
|
|
550
|
+
) {
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
|
|
717
554
|
findings.push({
|
|
718
555
|
ruleId: rule.id,
|
|
719
556
|
description: rule.description,
|