@coo-quack/sensitive-canary 0.4.2
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/marketplace.json +22 -0
- package/.claude-plugin/plugin.json +11 -0
- package/CHANGELOG.md +73 -0
- package/LICENSE +21 -0
- package/README.md +385 -0
- package/hooks/hooks.json +26 -0
- package/package.json +42 -0
- package/src/__tests__/pre-tool-use-hook.test.ts +646 -0
- package/src/__tests__/user-prompt-submit-hook.test.ts +255 -0
- package/src/lib/__tests__/inspector.test.ts +281 -0
- package/src/lib/__tests__/rules.test.ts +322 -0
- package/src/lib/inspector.ts +113 -0
- package/src/lib/rules.ts +308 -0
- package/src/pre-tool-use-hook.ts +316 -0
- package/src/user-prompt-submit-hook.ts +106 -0
package/src/lib/rules.ts
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
export interface Finding {
|
|
2
|
+
ruleId: string;
|
|
3
|
+
description: string;
|
|
4
|
+
category: "secret" | "pii";
|
|
5
|
+
matchRedacted: string;
|
|
6
|
+
secretValue: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface Rule {
|
|
10
|
+
id: string;
|
|
11
|
+
description: string;
|
|
12
|
+
regex: RegExp;
|
|
13
|
+
secretGroup?: number;
|
|
14
|
+
entropyThreshold?: number;
|
|
15
|
+
validate?: (str: string) => boolean;
|
|
16
|
+
category: "secret" | "pii";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Luhn algorithm checksum validation. Returns true if the number (digits only) passes.
|
|
20
|
+
export function luhn(str: string): boolean {
|
|
21
|
+
const digits = str.replace(/\D/g, "");
|
|
22
|
+
let sum = 0;
|
|
23
|
+
let double = false;
|
|
24
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
25
|
+
let d = parseInt(digits[i] ?? "", 10);
|
|
26
|
+
if (double) {
|
|
27
|
+
d *= 2;
|
|
28
|
+
if (d > 9) d -= 9;
|
|
29
|
+
}
|
|
30
|
+
sum += d;
|
|
31
|
+
double = !double;
|
|
32
|
+
}
|
|
33
|
+
return sum % 10 === 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Shannon entropy (bits per character, 0–8 scale)
|
|
37
|
+
export function entropy(str: string): number {
|
|
38
|
+
if (str.length === 0) return 0;
|
|
39
|
+
const freq: Record<string, number> = {};
|
|
40
|
+
for (const ch of str) freq[ch] = (freq[ch] || 0) + 1;
|
|
41
|
+
let h = 0;
|
|
42
|
+
const n = str.length;
|
|
43
|
+
for (const count of Object.values(freq)) {
|
|
44
|
+
const p = count / n;
|
|
45
|
+
h -= p * Math.log2(p);
|
|
46
|
+
}
|
|
47
|
+
return h;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Patterns sourced from gitleaks and TruffleHog detector definitions.
|
|
51
|
+
// Each rule:
|
|
52
|
+
// regex — must have /g flag
|
|
53
|
+
// secretGroup — capture group containing the secret (default: 0 = full match)
|
|
54
|
+
// entropyThreshold — skip match if entropy(secretValue) is below threshold
|
|
55
|
+
|
|
56
|
+
// ── Secrets ───────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
const SECRET_RULES: Rule[] = [
|
|
59
|
+
// Cloud
|
|
60
|
+
{
|
|
61
|
+
id: "aws-access-key",
|
|
62
|
+
description: "AWS Access Key ID",
|
|
63
|
+
regex:
|
|
64
|
+
/\b(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}\b/g,
|
|
65
|
+
category: "secret",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "private-key",
|
|
69
|
+
description: "PEM Private Key",
|
|
70
|
+
// Covers RSA, EC, DSA, PGP, and OpenSSH private keys
|
|
71
|
+
regex: /-----BEGIN (RSA |EC |DSA |PGP |OPENSSH )?PRIVATE KEY/g,
|
|
72
|
+
category: "secret",
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
// Source control
|
|
76
|
+
{
|
|
77
|
+
id: "github-pat",
|
|
78
|
+
description: "GitHub Personal Access Token",
|
|
79
|
+
regex: /gh[pousr]_[A-Za-z0-9]{36,255}/g,
|
|
80
|
+
category: "secret",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "github-fine-grained",
|
|
84
|
+
description: "GitHub Fine-Grained Token",
|
|
85
|
+
regex: /github_pat_[A-Za-z0-9_]{82}/g,
|
|
86
|
+
category: "secret",
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: "gitlab-pat",
|
|
90
|
+
description: "GitLab Personal Access Token",
|
|
91
|
+
regex: /glpat-[A-Za-z0-9_=-]{20,22}/g,
|
|
92
|
+
category: "secret",
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
// Communication
|
|
96
|
+
{
|
|
97
|
+
id: "slack-token",
|
|
98
|
+
description: "Slack Token",
|
|
99
|
+
regex: /xox[baprs]-[0-9a-zA-Z-]{10,72}/g,
|
|
100
|
+
category: "secret",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: "slack-webhook",
|
|
104
|
+
description: "Slack Webhook URL",
|
|
105
|
+
regex:
|
|
106
|
+
/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,
|
|
107
|
+
category: "secret",
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: "discord-webhook",
|
|
111
|
+
description: "Discord Webhook URL",
|
|
112
|
+
regex:
|
|
113
|
+
/https:\/\/discord(?:app)?\.com\/api\/webhooks\/[0-9]{17,20}\/[A-Za-z0-9_-]{68}/g,
|
|
114
|
+
category: "secret",
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: "telegram-bot-token",
|
|
118
|
+
description: "Telegram Bot Token",
|
|
119
|
+
regex: /[0-9]{8,10}:AA[0-9A-Za-z_-]{33}/g,
|
|
120
|
+
category: "secret",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: "twilio-sid",
|
|
124
|
+
description: "Twilio Account SID",
|
|
125
|
+
regex: /AC[0-9a-f]{32}/g,
|
|
126
|
+
category: "secret",
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
// Email services
|
|
130
|
+
{
|
|
131
|
+
id: "sendgrid-key",
|
|
132
|
+
description: "SendGrid API Key",
|
|
133
|
+
regex: /SG\.[A-Za-z0-9_-]{20,24}\.[A-Za-z0-9_-]{39,50}/g,
|
|
134
|
+
category: "secret",
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: "mailgun-key",
|
|
138
|
+
description: "Mailgun API Key",
|
|
139
|
+
regex: /key-[0-9a-zA-Z]{32}/g,
|
|
140
|
+
category: "secret",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: "mailchimp-key",
|
|
144
|
+
description: "Mailchimp API Key",
|
|
145
|
+
regex: /[0-9a-f]{32}-us[0-9]{1,2}/g,
|
|
146
|
+
category: "secret",
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
// Payment
|
|
150
|
+
{
|
|
151
|
+
id: "stripe-secret-key",
|
|
152
|
+
description: "Stripe Secret Key",
|
|
153
|
+
regex: /sk_(live|test)_[0-9a-zA-Z]{24}/g,
|
|
154
|
+
category: "secret",
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
id: "stripe-restricted-key",
|
|
158
|
+
description: "Stripe Restricted Key",
|
|
159
|
+
regex: /rk_(live|test)_[0-9a-zA-Z]{24}/g,
|
|
160
|
+
category: "secret",
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
// AI services
|
|
164
|
+
{
|
|
165
|
+
id: "openai-key",
|
|
166
|
+
description: "OpenAI API Key (legacy)",
|
|
167
|
+
regex: /sk-[A-Za-z0-9]{48}/g,
|
|
168
|
+
category: "secret",
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
id: "openai-project-key",
|
|
172
|
+
description: "OpenAI Project API Key",
|
|
173
|
+
regex: /sk-proj-[A-Za-z0-9_-]{40,}/g,
|
|
174
|
+
entropyThreshold: 3.5,
|
|
175
|
+
category: "secret",
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: "anthropic-key",
|
|
179
|
+
description: "Anthropic API Key",
|
|
180
|
+
regex: /sk-ant-[A-Za-z0-9_-]{95}/g,
|
|
181
|
+
category: "secret",
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
// Auth tokens
|
|
185
|
+
{
|
|
186
|
+
id: "jwt",
|
|
187
|
+
description: "JSON Web Token (JWT)",
|
|
188
|
+
regex: /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
|
|
189
|
+
category: "secret",
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
// Generic / env-based
|
|
193
|
+
{
|
|
194
|
+
id: "generic-secret",
|
|
195
|
+
description: "Generic API Key / Secret",
|
|
196
|
+
regex:
|
|
197
|
+
/(api[_-]?key|secret[_-]?key|access[_-]?token|api[_-]?secret)\s*[:=]\s*['"]?([A-Za-z0-9\-_.]{20,})/gi,
|
|
198
|
+
secretGroup: 2,
|
|
199
|
+
entropyThreshold: 3.5,
|
|
200
|
+
category: "secret",
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
id: "env-assignment",
|
|
204
|
+
description: ".env style secret assignment",
|
|
205
|
+
regex:
|
|
206
|
+
/\b[A-Z_]*(SECRET|PASSWORD|PASSWD|TOKEN|API_KEY|PRIVATE_KEY)[A-Z_0-9]*\s*=\s*(\S{8,})/g,
|
|
207
|
+
secretGroup: 2,
|
|
208
|
+
entropyThreshold: 3.0,
|
|
209
|
+
category: "secret",
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
id: "connection-string",
|
|
213
|
+
description: "Database Connection String with credentials",
|
|
214
|
+
regex: /(mongodb|mysql|postgres|postgresql|redis):\/\/[^:\s]+:[^@\s]+@/g,
|
|
215
|
+
category: "secret",
|
|
216
|
+
},
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
// ── PII ───────────────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
const PII_RULES: Rule[] = [
|
|
222
|
+
{
|
|
223
|
+
id: "pii-email",
|
|
224
|
+
description: "Email Address",
|
|
225
|
+
regex: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
|
|
226
|
+
category: "pii",
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
id: "pii-credit-card",
|
|
230
|
+
description: "Credit Card Number",
|
|
231
|
+
// Visa (16d) | Mastercard (16d) | Amex (15d) | Discover (16d)
|
|
232
|
+
// Optional spaces or dashes between digit groups
|
|
233
|
+
regex:
|
|
234
|
+
/\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,
|
|
235
|
+
validate: luhn,
|
|
236
|
+
category: "pii",
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
id: "pii-ssn",
|
|
240
|
+
description: "US Social Security Number",
|
|
241
|
+
regex: /\b(?!000|666|9\d{2})\d{3}[- ](?!00)\d{2}[- ](?!0000)\d{4}\b/g,
|
|
242
|
+
category: "pii",
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
id: "pii-phone-us",
|
|
246
|
+
description: "US Phone Number",
|
|
247
|
+
regex: /\b(\+1[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}\b/g,
|
|
248
|
+
category: "pii",
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
id: "pii-phone-jp",
|
|
252
|
+
description: "Japanese Phone Number",
|
|
253
|
+
regex: /\b0\d{1,4}[\s-]\d{1,4}[\s-]\d{4}\b/g,
|
|
254
|
+
category: "pii",
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
id: "pii-postal-jp",
|
|
258
|
+
description: "Japanese Postal Code",
|
|
259
|
+
// Require 〒 prefix to avoid false positives (e.g. phone number fragments)
|
|
260
|
+
regex: /〒\d{3}[\s-]\d{4}/g,
|
|
261
|
+
category: "pii",
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
id: "pii-ipv4",
|
|
265
|
+
description: "IPv4 Address (private range)",
|
|
266
|
+
// Only flag RFC-1918 private addresses to reduce noise
|
|
267
|
+
regex:
|
|
268
|
+
/\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,
|
|
269
|
+
category: "pii",
|
|
270
|
+
},
|
|
271
|
+
];
|
|
272
|
+
|
|
273
|
+
export const RULES: Rule[] = [...SECRET_RULES, ...PII_RULES];
|
|
274
|
+
|
|
275
|
+
// Show first 4 + **** + last 4 chars; fully mask strings of 8 chars or fewer
|
|
276
|
+
export function redact(str: string): string {
|
|
277
|
+
if (str.length <= 8) return "****";
|
|
278
|
+
return `${str.slice(0, 4)}****${str.slice(-4)}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function scan(text: string): Finding[] {
|
|
282
|
+
const findings: Finding[] = [];
|
|
283
|
+
|
|
284
|
+
for (const rule of RULES) {
|
|
285
|
+
for (const match of text.matchAll(rule.regex)) {
|
|
286
|
+
const secretValue =
|
|
287
|
+
rule.secretGroup != null ? match[rule.secretGroup] : match[0];
|
|
288
|
+
|
|
289
|
+
if (!secretValue) continue;
|
|
290
|
+
if (
|
|
291
|
+
rule.entropyThreshold != null &&
|
|
292
|
+
entropy(secretValue) < rule.entropyThreshold
|
|
293
|
+
)
|
|
294
|
+
continue;
|
|
295
|
+
if (rule.validate != null && !rule.validate(match[0])) continue;
|
|
296
|
+
|
|
297
|
+
findings.push({
|
|
298
|
+
ruleId: rule.id,
|
|
299
|
+
description: rule.description,
|
|
300
|
+
category: rule.category,
|
|
301
|
+
matchRedacted: redact(secretValue),
|
|
302
|
+
secretValue,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return findings;
|
|
308
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
applyAllowTags,
|
|
7
|
+
dedupeFindings,
|
|
8
|
+
findingsToLines,
|
|
9
|
+
type Message,
|
|
10
|
+
parseAllowTags,
|
|
11
|
+
randomBird,
|
|
12
|
+
} from "./lib/inspector.ts";
|
|
13
|
+
import { type Finding, scan } from "./lib/rules.ts";
|
|
14
|
+
|
|
15
|
+
interface HookInput {
|
|
16
|
+
transcript_path?: string;
|
|
17
|
+
tool_name?: string;
|
|
18
|
+
tool_input?: {
|
|
19
|
+
file_path?: string;
|
|
20
|
+
command?: string;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface TranscriptLine {
|
|
25
|
+
message?: Message;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── Transcript ────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
// Returns true when the message contains at least one text content block
|
|
31
|
+
// (or is a plain string). Tool-result-only messages are not real user input.
|
|
32
|
+
function hasTextContent(msg: Message): boolean {
|
|
33
|
+
if (typeof msg.content === "string") return true;
|
|
34
|
+
return msg.content.some((b) => b.type === "text");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Load allow tags from the Claude Code session transcript.
|
|
38
|
+
// Transcript format (JSONL): { "type": "user"|"assistant", "message": { role, content }, … }
|
|
39
|
+
// Only the most recent user *text* message is consulted, and only if no tool_result
|
|
40
|
+
// entries have been recorded after it. This means allow tags are consumed by the first
|
|
41
|
+
// tool call — subsequent tool calls in the same AI turn will be blocked.
|
|
42
|
+
function loadAllowTagsFromTranscript(transcriptPath: string): Set<string> {
|
|
43
|
+
let raw: string;
|
|
44
|
+
try {
|
|
45
|
+
raw = fs.readFileSync(transcriptPath, "utf8");
|
|
46
|
+
} catch {
|
|
47
|
+
return new Set();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let lastUserMessage: Message | null = null;
|
|
51
|
+
let toolResultAfterLastText = false;
|
|
52
|
+
for (const line of raw.split("\n")) {
|
|
53
|
+
const trimmed = line.trim();
|
|
54
|
+
if (!trimmed) continue;
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(trimmed) as TranscriptLine;
|
|
57
|
+
const msg = parsed.message;
|
|
58
|
+
if (msg?.role === "user" && msg.content !== undefined) {
|
|
59
|
+
if (hasTextContent(msg)) {
|
|
60
|
+
lastUserMessage = msg;
|
|
61
|
+
toolResultAfterLastText = false;
|
|
62
|
+
} else {
|
|
63
|
+
toolResultAfterLastText = true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
// skip malformed lines
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!lastUserMessage || toolResultAfterLastText) return new Set();
|
|
72
|
+
return parseAllowTags([lastUserMessage]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Bash helpers ──────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
const FILE_READ_COMMANDS = new Set([
|
|
78
|
+
"cat",
|
|
79
|
+
"head",
|
|
80
|
+
"tail",
|
|
81
|
+
"less",
|
|
82
|
+
"more",
|
|
83
|
+
"bat",
|
|
84
|
+
"nl",
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
function extractEnvVarNames(command: string): string[] {
|
|
88
|
+
const names = new Set<string>();
|
|
89
|
+
const re = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
90
|
+
for (const match of command.matchAll(re)) {
|
|
91
|
+
const name = match[1] ?? match[2];
|
|
92
|
+
if (name) names.add(name);
|
|
93
|
+
}
|
|
94
|
+
return [...names];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function extractFilePathsFromCommand(command: string): string[] {
|
|
98
|
+
const paths: string[] = [];
|
|
99
|
+
const segments = command.split(/\s*[|;&]+\s*/);
|
|
100
|
+
|
|
101
|
+
for (const seg of segments) {
|
|
102
|
+
const tokens = seg.trim().split(/\s+/).filter(Boolean);
|
|
103
|
+
if (tokens.length < 2) continue;
|
|
104
|
+
|
|
105
|
+
const cmd = path.basename(tokens[0] ?? "");
|
|
106
|
+
if (!FILE_READ_COMMANDS.has(cmd)) continue;
|
|
107
|
+
|
|
108
|
+
let skipNext = false;
|
|
109
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
110
|
+
if (skipNext) {
|
|
111
|
+
skipNext = false;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const tok = tokens[i];
|
|
115
|
+
if (!tok) continue;
|
|
116
|
+
if (tok.startsWith("-")) continue;
|
|
117
|
+
if (tok === ">" || tok === ">>" || tok === "<") {
|
|
118
|
+
skipNext = true;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
paths.push(tok);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return [...new Set(paths)];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── .env pattern ──────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
// .env and .env.* (e.g. .env.local, .env.production) are blocked unconditionally.
|
|
131
|
+
// Files that merely end in .env (e.g. production.env) are handled by content scanning.
|
|
132
|
+
function isBlockedEnvFile(filePath: string): boolean {
|
|
133
|
+
if (!filePath) return false;
|
|
134
|
+
const base = path.basename(filePath);
|
|
135
|
+
return base === ".env" || base.startsWith(".env.");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Output helpers ────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
// Build the allow-tag hint lines shown to Claude.
|
|
141
|
+
// showAllTags: when true, always show [allow-secret] and [allow-pii] hints
|
|
142
|
+
// regardless of findings content (used for .env name blocks).
|
|
143
|
+
function buildAllowHints(
|
|
144
|
+
exampleContext: string,
|
|
145
|
+
findings: Finding[],
|
|
146
|
+
showAllTags = false,
|
|
147
|
+
): string[] {
|
|
148
|
+
const hasSecret =
|
|
149
|
+
showAllTags || findings.some((f) => f.category === "secret");
|
|
150
|
+
const hasPii = showAllTags || findings.some((f) => f.category === "pii");
|
|
151
|
+
|
|
152
|
+
const lines: string[] = [];
|
|
153
|
+
if (hasSecret) lines.push(" [allow-secret] — allow secrets");
|
|
154
|
+
if (hasPii) lines.push(" [allow-pii] — allow PII");
|
|
155
|
+
lines.push(" [allow-all] — bypass all sensitive-canary checks");
|
|
156
|
+
lines.push("");
|
|
157
|
+
|
|
158
|
+
const example =
|
|
159
|
+
hasSecret && hasPii
|
|
160
|
+
? "allow-all"
|
|
161
|
+
: hasSecret
|
|
162
|
+
? "allow-secret"
|
|
163
|
+
: hasPii
|
|
164
|
+
? "allow-pii"
|
|
165
|
+
: "allow-all";
|
|
166
|
+
lines.push(`Example: "[${example}] ${exampleContext}"`);
|
|
167
|
+
|
|
168
|
+
return lines;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function block(
|
|
172
|
+
source: string,
|
|
173
|
+
detectionLines: string[],
|
|
174
|
+
allowHints: string[],
|
|
175
|
+
): never {
|
|
176
|
+
const terminalMessage = [
|
|
177
|
+
"",
|
|
178
|
+
`${randomBird()} sensitive-canary: blocked — ${source}`,
|
|
179
|
+
"",
|
|
180
|
+
...detectionLines,
|
|
181
|
+
"",
|
|
182
|
+
].join("\n");
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const fd = fs.openSync("/dev/tty", "w");
|
|
186
|
+
fs.writeSync(fd, terminalMessage);
|
|
187
|
+
fs.closeSync(fd);
|
|
188
|
+
} catch {
|
|
189
|
+
process.stderr.write(terminalMessage);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const bird = randomBird();
|
|
193
|
+
const reasonLines = [
|
|
194
|
+
`${bird} sensitive-canary blocked: ${source}`,
|
|
195
|
+
"",
|
|
196
|
+
...detectionLines,
|
|
197
|
+
"",
|
|
198
|
+
"To allow this, the user must add an allow tag to their next prompt:",
|
|
199
|
+
...allowHints,
|
|
200
|
+
"",
|
|
201
|
+
"Please tell the user about this block and suggest the appropriate tag.",
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
process.stdout.write(
|
|
205
|
+
`${JSON.stringify({
|
|
206
|
+
decision: "block",
|
|
207
|
+
reason: reasonLines.join("\n"),
|
|
208
|
+
})}\n`,
|
|
209
|
+
);
|
|
210
|
+
process.exit(2);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Core scan logic ───────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
function scanFile(filePath: string, allowTags: Set<string>): void {
|
|
216
|
+
if (isBlockedEnvFile(filePath)) {
|
|
217
|
+
if (allowTags.size > 0) return;
|
|
218
|
+
block(
|
|
219
|
+
filePath,
|
|
220
|
+
[
|
|
221
|
+
`${randomBird()} Blocked: .env and .env.* files contain secrets and must not be read into the conversation.`,
|
|
222
|
+
],
|
|
223
|
+
buildAllowHints(`please read ${filePath}`, [], true),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let content: string;
|
|
228
|
+
try {
|
|
229
|
+
content = fs.readFileSync(filePath, "utf8");
|
|
230
|
+
} catch {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const findings = applyAllowTags(dedupeFindings(scan(content)), allowTags);
|
|
235
|
+
if (findings.length === 0) return;
|
|
236
|
+
|
|
237
|
+
block(
|
|
238
|
+
filePath,
|
|
239
|
+
[
|
|
240
|
+
`${randomBird()} Blocked: file contains sensitive data`,
|
|
241
|
+
"",
|
|
242
|
+
...findingsToLines(findings),
|
|
243
|
+
],
|
|
244
|
+
buildAllowHints(`please read ${filePath}`, findings),
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
let raw = "";
|
|
251
|
+
process.stdin.setEncoding("utf8");
|
|
252
|
+
process.stdin.on("data", (chunk: string) => (raw += chunk));
|
|
253
|
+
process.stdin.on("end", () => {
|
|
254
|
+
let data: HookInput;
|
|
255
|
+
try {
|
|
256
|
+
data = JSON.parse(raw) as HookInput;
|
|
257
|
+
} catch {
|
|
258
|
+
process.exit(0);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const tool = data.tool_name ?? "";
|
|
262
|
+
const input = data.tool_input ?? {};
|
|
263
|
+
|
|
264
|
+
const allowTags = data.transcript_path
|
|
265
|
+
? loadAllowTagsFromTranscript(data.transcript_path)
|
|
266
|
+
: new Set<string>();
|
|
267
|
+
|
|
268
|
+
if (tool === "Read") {
|
|
269
|
+
scanFile(input.file_path ?? "", allowTags);
|
|
270
|
+
process.exit(0);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (tool === "Bash") {
|
|
274
|
+
const command = input.command ?? "";
|
|
275
|
+
|
|
276
|
+
for (const varName of extractEnvVarNames(command)) {
|
|
277
|
+
const value = process.env[varName];
|
|
278
|
+
if (!value) continue;
|
|
279
|
+
const findings = applyAllowTags(dedupeFindings(scan(value)), allowTags);
|
|
280
|
+
if (findings.length === 0) continue;
|
|
281
|
+
block(
|
|
282
|
+
`bash command: ${command.slice(0, 80)}`,
|
|
283
|
+
[
|
|
284
|
+
`${randomBird()} Blocked: environment variable $${varName} contains sensitive data`,
|
|
285
|
+
"",
|
|
286
|
+
...findingsToLines(findings),
|
|
287
|
+
],
|
|
288
|
+
buildAllowHints("please run the command", findings),
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const cmdFindings = applyAllowTags(
|
|
293
|
+
dedupeFindings(scan(command)),
|
|
294
|
+
allowTags,
|
|
295
|
+
);
|
|
296
|
+
if (cmdFindings.length > 0) {
|
|
297
|
+
block(
|
|
298
|
+
`bash command: ${command.slice(0, 80)}`,
|
|
299
|
+
[
|
|
300
|
+
`${randomBird()} Blocked: bash command contains sensitive data`,
|
|
301
|
+
"",
|
|
302
|
+
...findingsToLines(cmdFindings),
|
|
303
|
+
],
|
|
304
|
+
buildAllowHints("please run the command", cmdFindings),
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
for (const fp of extractFilePathsFromCommand(command)) {
|
|
309
|
+
scanFile(fp, allowTags);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
process.exit(0);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
process.exit(0);
|
|
316
|
+
});
|