@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.
@@ -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
+ });