@coo-quack/sensitive-canary 0.5.3 → 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 +61 -8
- package/README.md +163 -8
- package/package.json +6 -6
- package/src/__tests__/pre-tool-use-hook.test.ts +87 -21
- package/src/__tests__/user-prompt-submit-hook.test.ts +43 -1
- package/src/lib/__tests__/inspector.test.ts +8 -0
- package/src/lib/__tests__/rules.test.ts +1020 -2
- package/src/lib/default-config.json +461 -0
- package/src/lib/rules.ts +650 -243
- package/src/pre-tool-use-hook.ts +21 -6
- package/src/user-prompt-submit-hook.ts +4 -2
package/src/lib/rules.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
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
|
+
|
|
6
|
+
export type Category = "secret" | "pii";
|
|
7
|
+
|
|
1
8
|
export interface Finding {
|
|
2
9
|
ruleId: string;
|
|
3
10
|
description: string;
|
|
4
|
-
category:
|
|
11
|
+
category: Category;
|
|
5
12
|
matchRedacted: string;
|
|
6
13
|
secretValue: string;
|
|
14
|
+
score?: number;
|
|
7
15
|
}
|
|
8
16
|
|
|
9
17
|
interface Rule {
|
|
@@ -13,12 +21,63 @@ interface Rule {
|
|
|
13
21
|
secretGroup?: number;
|
|
14
22
|
entropyThreshold?: number;
|
|
15
23
|
validate?: (str: string) => boolean;
|
|
16
|
-
category:
|
|
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[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ALL_CATEGORIES: ReadonlySet<Category> = new Set(["secret", "pii"]);
|
|
55
|
+
|
|
56
|
+
// Parse the SENSITIVE_CANARY_CATEGORIES env var: a comma-separated list of
|
|
57
|
+
// "secret", "pii", or "all" (e.g. "secret" or "secret,pii"). Unset, empty, or
|
|
58
|
+
// containing no valid token means all categories are enabled.
|
|
59
|
+
export function parseCategories(value: string | undefined): Set<Category> {
|
|
60
|
+
const categories = new Set<Category>();
|
|
61
|
+
for (const token of (value ?? "").split(",")) {
|
|
62
|
+
const normalized = token.trim().toLowerCase();
|
|
63
|
+
if (normalized === "all") return new Set(ALL_CATEGORIES);
|
|
64
|
+
if (normalized === "secret" || normalized === "pii")
|
|
65
|
+
categories.add(normalized);
|
|
66
|
+
}
|
|
67
|
+
return categories.size > 0 ? categories : new Set(ALL_CATEGORIES);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Rule categories enabled for this process, from SENSITIVE_CANARY_CATEGORIES
|
|
71
|
+
// ("secret", "pii", "secret,pii", or "all"; default: all).
|
|
72
|
+
export function enabledCategoriesFromEnv(): Set<Category> {
|
|
73
|
+
const { SENSITIVE_CANARY_CATEGORIES } = process.env;
|
|
74
|
+
return parseCategories(SENSITIVE_CANARY_CATEGORIES);
|
|
17
75
|
}
|
|
18
76
|
|
|
19
77
|
// Luhn algorithm checksum validation. Returns true if the number (digits only) passes.
|
|
20
78
|
export function luhn(str: string): boolean {
|
|
21
79
|
const digits = str.replace(/\D/g, "");
|
|
80
|
+
if (digits.length === 0) return false;
|
|
22
81
|
let sum = 0;
|
|
23
82
|
let double = false;
|
|
24
83
|
for (let i = digits.length - 1; i >= 0; i--) {
|
|
@@ -33,7 +92,295 @@ export function luhn(str: string): boolean {
|
|
|
33
92
|
return sum % 10 === 0;
|
|
34
93
|
}
|
|
35
94
|
|
|
36
|
-
//
|
|
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)
|
|
37
384
|
export function entropy(str: string): number {
|
|
38
385
|
if (str.length === 0) return 0;
|
|
39
386
|
const freq: Record<string, number> = {};
|
|
@@ -47,244 +394,282 @@ export function entropy(str: string): number {
|
|
|
47
394
|
return h;
|
|
48
395
|
}
|
|
49
396
|
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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();
|
|
288
673
|
|
|
289
674
|
// Show first 4 + **** + last 4 chars; fully mask strings of 8 chars or fewer
|
|
290
675
|
export function redact(str: string): string {
|
|
@@ -292,10 +677,14 @@ export function redact(str: string): string {
|
|
|
292
677
|
return `${str.slice(0, 4)}****${str.slice(-4)}`;
|
|
293
678
|
}
|
|
294
679
|
|
|
295
|
-
export function scan(
|
|
680
|
+
export function scan(
|
|
681
|
+
text: string,
|
|
682
|
+
categories: ReadonlySet<Category> = ALL_CATEGORIES,
|
|
683
|
+
): Finding[] {
|
|
296
684
|
const findings: Finding[] = [];
|
|
297
685
|
|
|
298
686
|
for (const rule of RULES) {
|
|
687
|
+
if (!categories.has(rule.category)) continue;
|
|
299
688
|
for (const match of text.matchAll(rule.regex)) {
|
|
300
689
|
const secretValue =
|
|
301
690
|
rule.secretGroup != null ? match[rule.secretGroup] : match[0];
|
|
@@ -306,7 +695,24 @@ export function scan(text: string): Finding[] {
|
|
|
306
695
|
entropy(secretValue) < rule.entropyThreshold
|
|
307
696
|
)
|
|
308
697
|
continue;
|
|
309
|
-
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;
|
|
310
716
|
|
|
311
717
|
findings.push({
|
|
312
718
|
ruleId: rule.id,
|
|
@@ -314,6 +720,7 @@ export function scan(text: string): Finding[] {
|
|
|
314
720
|
category: rule.category,
|
|
315
721
|
matchRedacted: redact(secretValue),
|
|
316
722
|
secretValue,
|
|
723
|
+
score: hasContext ? 1.0 : 0.4,
|
|
317
724
|
});
|
|
318
725
|
}
|
|
319
726
|
}
|