@coo-quack/sensitive-canary 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +830 -0
  3. package/README.md +269 -43
  4. package/dist/lib/bash-commands.js +405 -0
  5. package/dist/lib/command-tables.js +462 -0
  6. package/dist/lib/default-config.json +570 -0
  7. package/dist/lib/encoding.js +123 -0
  8. package/dist/lib/fail-closed.js +31 -0
  9. package/dist/lib/inspector.js +0 -0
  10. package/dist/lib/rules.js +399 -0
  11. package/dist/lib/shapes.js +161 -0
  12. package/dist/lib/shell.js +436 -0
  13. package/dist/lib/tool-inputs.js +217 -0
  14. package/dist/lib/transcript.js +115 -0
  15. package/dist/lib/validators.js +435 -0
  16. package/dist/pre-tool-use-hook.js +773 -0
  17. package/dist/user-prompt-submit-hook.js +105 -0
  18. package/hooks/hooks.json +1 -1
  19. package/package.json +25 -11
  20. package/src/lib/bash-commands.ts +455 -0
  21. package/src/lib/command-tables.ts +518 -0
  22. package/src/lib/default-config.json +570 -0
  23. package/src/lib/encoding.ts +135 -0
  24. package/src/lib/fail-closed.ts +36 -0
  25. package/src/lib/inspector.ts +0 -0
  26. package/src/lib/rules.ts +482 -267
  27. package/src/lib/shapes.ts +175 -0
  28. package/src/lib/shell.ts +512 -0
  29. package/src/lib/tool-inputs.ts +235 -0
  30. package/src/lib/transcript.ts +142 -0
  31. package/src/lib/validators.ts +435 -0
  32. package/src/pre-tool-use-hook.ts +774 -198
  33. package/src/user-prompt-submit-hook.ts +60 -18
  34. package/src/__tests__/pre-tool-use-hook.test.ts +0 -779
  35. package/src/__tests__/user-prompt-submit-hook.test.ts +0 -297
  36. package/src/lib/__tests__/inspector.test.ts +0 -281
  37. package/src/lib/__tests__/rules.test.ts +0 -448
package/src/lib/rules.ts CHANGED
@@ -1,3 +1,16 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
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";
13
+
1
14
  export type Category = "secret" | "pii";
2
15
 
3
16
  export interface Finding {
@@ -6,6 +19,7 @@ export interface Finding {
6
19
  category: Category;
7
20
  matchRedacted: string;
8
21
  secretValue: string;
22
+ score?: number;
9
23
  }
10
24
 
11
25
  interface Rule {
@@ -16,6 +30,39 @@ interface Rule {
16
30
  entropyThreshold?: number;
17
31
  validate?: (str: string) => boolean;
18
32
  category: Category;
33
+ contextWords?: string[];
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[];
39
+ contextWindow?: number;
40
+ }
41
+
42
+ // JSON representation of a rule, as written in config files. The `regex` is a
43
+ // source string (not a RegExp literal), compiled at load time. `validate` is a
44
+ // name into the VALIDATORS registry.
45
+ export interface RuleConfig {
46
+ id: string;
47
+ description: string;
48
+ regex: string;
49
+ flags?: string;
50
+ secretGroup?: number;
51
+ entropyThreshold?: number;
52
+ validate?: string;
53
+ category: Category;
54
+ contextWords?: string[];
55
+ requireContext?: boolean;
56
+ // See Rule.excludeContext.
57
+ excludeContext?: string[];
58
+ contextWindow?: number;
59
+ }
60
+
61
+ // Top-level config file: a context window override plus a list of rules.
62
+ // User config files use the same shape and can override built-in rules by id.
63
+ export interface CanaryConfig {
64
+ contextWindow?: number;
65
+ rules: RuleConfig[];
19
66
  }
20
67
 
21
68
  const ALL_CATEGORIES: ReadonlySet<Category> = new Set(["secret", "pii"]);
@@ -41,301 +88,468 @@ export function enabledCategoriesFromEnv(): Set<Category> {
41
88
  return parseCategories(SENSITIVE_CANARY_CATEGORIES);
42
89
  }
43
90
 
44
- // Luhn algorithm checksum validation. Returns true if the number (digits only) passes.
45
- export function luhn(str: string): boolean {
46
- const digits = str.replace(/\D/g, "");
47
- let sum = 0;
48
- let double = false;
49
- for (let i = digits.length - 1; i >= 0; i--) {
50
- let d = parseInt(digits[i] ?? "", 10);
51
- if (double) {
52
- d *= 2;
53
- if (d > 9) d -= 9;
91
+ // ── Context enhancement ──────────────────────────────────────────────────────
92
+
93
+ // Set from the default config during module initialisation (see buildRules).
94
+ let effectiveContextWindow = 3;
95
+
96
+ export function getDefaultContextWindow(): number {
97
+ return effectiveContextWindow;
98
+ }
99
+
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;
115
+ }
116
+
117
+ function hasNearbyContextWord(
118
+ text: string,
119
+ matchStart: number,
120
+ matchEnd: number,
121
+ contextWords: string[],
122
+ windowTokens: number,
123
+ ): boolean {
124
+ if (contextWords.length === 0) return true;
125
+ const charWindow = windowTokens * 8;
126
+ const before = text.slice(Math.max(0, matchStart - charWindow), matchStart);
127
+ const after = text.slice(matchEnd, matchEnd + charWindow);
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
+ });
139
+ }
140
+
141
+ // ── Config loading ───────────────────────────────────────────────────────────
142
+
143
+ const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
144
+ const DEFAULT_CONFIG_PATH = join(MODULE_DIR, "default-config.json");
145
+ const { SENSITIVE_CANARY_CONFIG: userConfigPath } = process.env;
146
+ const USER_CONFIG_PATH =
147
+ userConfigPath ??
148
+ join(homedir(), ".config", "sensitive-canary", "config.json");
149
+
150
+ function readJsonFile(filePath: string): unknown {
151
+ return JSON.parse(readFileSync(filePath, "utf-8"));
152
+ }
153
+
154
+ // Validate a raw JSON object against the RuleConfig schema. Throws with a
155
+ // descriptive message when a required field is missing, a type is wrong, or a
156
+ // cross-field constraint is violated.
157
+ function validateRuleConfig(rc: unknown): asserts rc is RuleConfig {
158
+ if (typeof rc !== "object" || rc === null) {
159
+ throw new Error("rule must be an object");
160
+ }
161
+ const {
162
+ id,
163
+ description,
164
+ regex: source,
165
+ category,
166
+ flags,
167
+ secretGroup,
168
+ entropyThreshold,
169
+ validate: validateName,
170
+ contextWords,
171
+ excludeContext,
172
+ requireContext,
173
+ contextWindow,
174
+ } = rc as Record<string, unknown>;
175
+
176
+ if (typeof id !== "string" || id.length === 0) {
177
+ throw new Error('missing or empty "id" field');
178
+ }
179
+ if (typeof description !== "string" || description.length === 0) {
180
+ throw new Error('missing or empty "description" field');
181
+ }
182
+ if (typeof source !== "string" || source.length === 0) {
183
+ throw new Error('missing or empty "regex" field');
184
+ }
185
+ if (category !== "secret" && category !== "pii") {
186
+ throw new Error(
187
+ `invalid "category" ${JSON.stringify(category)} (must be "secret" or "pii")`,
188
+ );
189
+ }
190
+ if (flags != null && typeof flags !== "string") {
191
+ throw new Error('"flags" must be a string');
192
+ }
193
+ if (
194
+ secretGroup != null &&
195
+ (typeof secretGroup !== "number" ||
196
+ !Number.isInteger(secretGroup) ||
197
+ secretGroup < 0)
198
+ ) {
199
+ throw new Error('"secretGroup" must be a non-negative integer');
200
+ }
201
+ if (
202
+ entropyThreshold != null &&
203
+ (typeof entropyThreshold !== "number" || entropyThreshold < 0)
204
+ ) {
205
+ throw new Error('"entropyThreshold" must be a non-negative number');
206
+ }
207
+ if (validateName != null && typeof validateName !== "string") {
208
+ throw new Error('"validate" must be a string');
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
+ }
218
+ if (contextWords != null) {
219
+ if (
220
+ !Array.isArray(contextWords) ||
221
+ contextWords.some((w) => typeof w !== "string" || w.length === 0)
222
+ ) {
223
+ throw new Error('"contextWords" must be an array of non-empty strings');
54
224
  }
55
- sum += d;
56
- double = !double;
57
225
  }
58
- return sum % 10 === 0;
226
+ if (requireContext != null && typeof requireContext !== "boolean") {
227
+ throw new Error('"requireContext" must be a boolean');
228
+ }
229
+ if (
230
+ contextWindow != null &&
231
+ (typeof contextWindow !== "number" ||
232
+ !Number.isInteger(contextWindow) ||
233
+ contextWindow < 1)
234
+ ) {
235
+ throw new Error('"contextWindow" must be a positive integer');
236
+ }
237
+
238
+ // Cross-field: requireContext is meaningless without contextWords
239
+ if (
240
+ requireContext === true &&
241
+ (!Array.isArray(contextWords) || contextWords.length === 0)
242
+ ) {
243
+ throw new Error(
244
+ '"requireContext" is true but "contextWords" is empty — context gating would be disabled and the rule would always fire',
245
+ );
246
+ }
59
247
  }
60
248
 
61
- // Shannon entropy (bits per character, 0–8 scale)
62
- export function entropy(str: string): number {
63
- if (str.length === 0) return 0;
64
- const freq: Record<string, number> = {};
65
- for (const ch of str) freq[ch] = (freq[ch] ?? 0) + 1;
66
- let h = 0;
67
- const n = str.length;
68
- for (const count of Object.values(freq)) {
69
- const p = count / n;
70
- h -= p * Math.log2(p);
249
+ // Compile a single RuleConfig (JSON) into a Rule (with compiled RegExp and
250
+ // resolved validator function). Throws on invalid regex or missing required
251
+ // fields so the caller (buildRules) can catch and warn per-rule.
252
+ export function compileRule(rc: RuleConfig): Rule {
253
+ validateRuleConfig(rc);
254
+ const { regex: source, flags, validate: validateName, ...rest } = rc;
255
+ // matchAll requires the global flag; ensure it is always present.
256
+ const flagStr = flags ?? "g";
257
+ const withG = flagStr.includes("g") ? flagStr : `${flagStr}g`;
258
+ const rule: Rule = {
259
+ ...rest,
260
+ regex: new RegExp(source, withG),
261
+ };
262
+ if (validateName) {
263
+ const fn = getValidator(validateName);
264
+ if (fn) {
265
+ rule.validate = fn;
266
+ } else {
267
+ process.stderr.write(
268
+ `sensitive-canary: unknown validator "${validateName}" in rule "${rc.id}" — validation disabled\n`,
269
+ );
270
+ }
71
271
  }
72
- return h;
272
+ return rule;
273
+ }
274
+
275
+ // Load and compile the built-in default rules from default-config.json.
276
+ function loadDefaultConfig(): CanaryConfig {
277
+ return readJsonFile(DEFAULT_CONFIG_PATH) as CanaryConfig;
73
278
  }
74
279
 
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];
313
-
314
- // Show first 4 + **** + last 4 chars; fully mask strings of 8 chars or fewer
280
+ // Load user config if it exists. Returns null when the file is absent (the
281
+ // common case). JSON parse errors and permission issues are reported on stderr
282
+ // so that a broken config file is not silently ignored.
283
+ function loadUserConfig(): CanaryConfig | null {
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
+ }
295
+ return readJsonFile(USER_CONFIG_PATH) as CanaryConfig;
296
+ } catch (e) {
297
+ if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
298
+ process.stderr.write(
299
+ `sensitive-canary: could not read user config "${USER_CONFIG_PATH}": ${e instanceof Error ? e.message : String(e)}\n`,
300
+ );
301
+ }
302
+ return null;
303
+ }
304
+ }
305
+
306
+ // Build the final rule list: default rules first, then user rules. A user rule
307
+ // with the same id as a built-in rule replaces it; new ids are appended.
308
+ // Invalid user rules (bad regex, etc.) are skipped with a warning so that one
309
+ // bad entry does not break the entire hook.
310
+ function buildRules(): Rule[] {
311
+ const defaultConfig = loadDefaultConfig();
312
+ effectiveContextWindow = defaultConfig.contextWindow ?? 3;
313
+
314
+ const defaultRules: Rule[] = [];
315
+ for (const rc of defaultConfig.rules) {
316
+ try {
317
+ defaultRules.push(compileRule(rc));
318
+ } catch (e) {
319
+ process.stderr.write(
320
+ `sensitive-canary: failed to compile built-in rule "${(rc as { id?: unknown })?.id ?? "(unknown)"}": ${e instanceof Error ? e.message : String(e)}\n`,
321
+ );
322
+ }
323
+ }
324
+
325
+ const userConfig = loadUserConfig();
326
+ if (userConfig) {
327
+ if (
328
+ typeof userConfig.contextWindow === "number" &&
329
+ Number.isInteger(userConfig.contextWindow) &&
330
+ userConfig.contextWindow >= 1
331
+ ) {
332
+ effectiveContextWindow = userConfig.contextWindow;
333
+ } else if (userConfig.contextWindow != null) {
334
+ process.stderr.write(
335
+ `sensitive-canary: invalid contextWindow in user config, ignoring\n`,
336
+ );
337
+ }
338
+ if (userConfig.rules != null && !Array.isArray(userConfig.rules)) {
339
+ process.stderr.write(
340
+ `sensitive-canary: "rules" in user config must be an array, ignoring\n`,
341
+ );
342
+ }
343
+ if (Array.isArray(userConfig.rules) && userConfig.rules.length) {
344
+ const userRules: Rule[] = [];
345
+ for (const rc of userConfig.rules) {
346
+ try {
347
+ userRules.push(compileRule(rc));
348
+ } catch (e) {
349
+ process.stderr.write(
350
+ `sensitive-canary: skipping user rule "${(rc as { id?: unknown })?.id ?? "(unknown)"}": ${e instanceof Error ? e.message : String(e)}\n`,
351
+ );
352
+ }
353
+ }
354
+ // De-duplicate by id (last definition wins) so duplicate ids in the
355
+ // user config don't produce duplicate rules and duplicate findings.
356
+ const byId = new Map<string, Rule>();
357
+ for (const rule of userRules) {
358
+ if (byId.has(rule.id)) {
359
+ process.stderr.write(
360
+ `sensitive-canary: duplicate user rule id "${rule.id}" — using the last definition\n`,
361
+ );
362
+ }
363
+ byId.set(rule.id, rule);
364
+ }
365
+ return defaultRules
366
+ .filter((r) => !byId.has(r.id))
367
+ .concat(Array.from(byId.values()));
368
+ }
369
+ }
370
+
371
+ return defaultRules;
372
+ }
373
+
374
+ export const RULES: Rule[] = buildRules();
375
+
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.
315
382
  export function redact(str: string): string {
316
- if (str.length <= 8) return "****";
317
- return `${str.slice(0, 4)}****${str.slice(-4)}`;
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
+ }
318
457
  }
319
458
 
320
459
  export function scan(
321
460
  text: string,
322
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,
323
476
  ): Finding[] {
324
477
  const findings: Finding[] = [];
478
+ const startedAt = Date.now();
325
479
 
326
480
  for (const rule of RULES) {
327
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);
328
486
  for (const match of text.matchAll(rule.regex)) {
329
487
  const secretValue =
330
488
  rule.secretGroup != null ? match[rule.secretGroup] : match[0];
331
489
 
332
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;
333
514
  if (
334
515
  rule.entropyThreshold != null &&
335
516
  entropy(secretValue) < rule.entropyThreshold
336
517
  )
337
518
  continue;
338
- if (rule.validate != null && !rule.validate(match[0])) continue;
519
+ if (rule.validate != null && !rule.validate(secretValue)) continue;
520
+
521
+ const hasContext =
522
+ !rule.contextWords || rule.contextWords.length === 0
523
+ ? true
524
+ : hasNearbyContextWord(
525
+ text,
526
+ matchStart,
527
+ matchEnd,
528
+ rule.contextWords,
529
+ rule.contextWindow ?? effectiveContextWindow,
530
+ );
531
+
532
+ // Rules that require context (e.g. bare postal codes) are dropped when
533
+ // no context label is nearby, to avoid flagging every 5-digit number.
534
+ if (rule.requireContext && !hasContext) continue;
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
+ }
339
553
 
340
554
  findings.push({
341
555
  ruleId: rule.id,
@@ -343,6 +557,7 @@ export function scan(
343
557
  category: rule.category,
344
558
  matchRedacted: redact(secretValue),
345
559
  secretValue,
560
+ score: hasContext ? 1.0 : 0.4,
346
561
  });
347
562
  }
348
563
  }