@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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +830 -0
- package/README.md +269 -43
- 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 +570 -0
- 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 +482 -267
- 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 -281
- 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
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
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
|
-
|
|
317
|
-
|
|
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(
|
|
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
|
}
|