main
  1/**
  2 * Secrets Validator Extension - Prevent committing unencrypted secrets
  3 *
  4 * Detects potential secrets in files being committed and warns before
  5 * allowing git operations.
  6 *
  7 * Features:
  8 * - Scans staged files for potential secrets
  9 * - Checks for common secret patterns (API keys, passwords, tokens)
 10 * - Validates agenix secrets are encrypted
 11 * - Prevents commits with exposed secrets
 12 * - Integrates with existing validate-git-push extension
 13 */
 14
 15import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
 16import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
 17
 18// Patterns that might indicate secrets
 19const SECRET_PATTERNS = [
 20	{ name: "API Key", pattern: /api[_-]?key\s*[:=]\s*["']?[a-zA-Z0-9]{20,}["']?/i },
 21	{ name: "Secret Key", pattern: /secret[_-]?key\s*[:=]\s*["']?[a-zA-Z0-9]{20,}["']?/i },
 22	{ name: "Password", pattern: /password\s*[:=]\s*["']?[^\s"']{8,}["']?/i },
 23	{ name: "Token", pattern: /token\s*[:=]\s*["']?[a-zA-Z0-9]{20,}["']?/i },
 24	{ name: "AWS Access Key", pattern: /AKIA[0-9A-Z]{16}/i },
 25	{ name: "Private Key Header", pattern: /-----BEGIN (RSA |EC )?PRIVATE KEY-----/ },
 26];
 27
 28// Files that should always be encrypted with agenix
 29const SHOULD_BE_ENCRYPTED = [
 30	/secrets\/.*\.age$/,
 31	/\.key$/,
 32	/\.pem$/,
 33];
 34
 35// Safe patterns that look like secrets but aren't
 36const FALSE_POSITIVES = [
 37	/password.*example/i,
 38	/password.*placeholder/i,
 39	/password.*dummy/i,
 40	/\bTODO\b/i,
 41	/\bFIXME\b/i,
 42];
 43
 44async function scanFileForSecrets(
 45	filePath: string,
 46	content: string
 47): Promise<{ found: boolean; matches: Array<{ line: number; pattern: string; text: string }> }> {
 48	const lines = content.split("\n");
 49	const matches: Array<{ line: number; pattern: string; text: string }> = [];
 50
 51	for (let i = 0; i < lines.length; i++) {
 52		const line = lines[i];
 53
 54		// Skip false positives
 55		if (FALSE_POSITIVES.some((fp) => fp.test(line))) continue;
 56
 57		// Check each secret pattern
 58		for (const { name, pattern } of SECRET_PATTERNS) {
 59			if (pattern.test(line)) {
 60				matches.push({
 61					line: i + 1,
 62					pattern: name,
 63					text: line.trim().substring(0, 80), // Truncate for display
 64				});
 65			}
 66		}
 67	}
 68
 69	return { found: matches.length > 0, matches };
 70}
 71
 72export default function (pi: ExtensionAPI) {
 73	pi.on("tool_call", async (event, ctx) => {
 74		if (!isToolCallEventType("bash", event)) return;
 75
 76		const command = event.input.command;
 77
 78		// Only check git commits and pushes
 79		if (!command.includes("git commit") && !command.includes("git push")) return;
 80
 81		// Get staged files
 82		const stagedResult = await pi.exec("git", ["diff", "--cached", "--name-only"], {
 83			cwd: ctx.cwd,
 84			timeout: 5,
 85		});
 86
 87		if (stagedResult.code !== 0) return;
 88
 89		const stagedFiles = stagedResult.stdout.trim().split("\n").filter((f) => f);
 90		if (stagedFiles.length === 0) return;
 91
 92		const warnings: string[] = [];
 93
 94		// Check each staged file
 95		for (const file of stagedFiles) {
 96			// Check if file should be encrypted
 97			if (SHOULD_BE_ENCRYPTED.some((pattern) => pattern.test(file))) {
 98				// Verify it's actually encrypted (age files are binary)
 99				const fileResult = await pi.exec("file", [file], {
100					cwd: ctx.cwd,
101					timeout: 5,
102				});
103
104				if (fileResult.code === 0 && !fileResult.stdout.includes("ASCII text")) {
105					// Likely encrypted, skip
106					continue;
107				}
108
109				warnings.push(`⚠️  ${file} should be encrypted with agenix`);
110				continue;
111			}
112
113			// Get file content
114			const contentResult = await pi.exec("git", ["show", `:${file}`], {
115				cwd: ctx.cwd,
116				timeout: 5,
117			});
118
119			if (contentResult.code !== 0) continue;
120
121			// Scan for secrets
122			const { found, matches } = await scanFileForSecrets(file, contentResult.stdout);
123
124			if (found) {
125				warnings.push(`⚠️  Potential secrets found in ${file}:`);
126				for (const match of matches) {
127					warnings.push(`   Line ${match.line}: ${match.pattern}`);
128					warnings.push(`   ${match.text}`);
129				}
130			}
131		}
132
133		if (warnings.length > 0) {
134			ctx.ui.notify(
135				["🔐 Secret Detection Warning:", "", ...warnings].join("\n"),
136				"warning"
137			);
138
139			const confirmed = await ctx.ui.confirm(
140				"Potential secrets detected",
141				"Staged files may contain secrets. Commit anyway?"
142			);
143
144			if (!confirmed) {
145				return { block: true, reason: "Commit cancelled due to potential secrets" };
146			}
147		}
148	});
149
150	// Register a command to manually scan for secrets
151	pi.registerCommand("scan-secrets", {
152		description: "Scan repository for potential secrets",
153		handler: async (_args, ctx) => {
154			ctx.ui.notify("Scanning repository for secrets...", "info");
155
156			// Scan all tracked files
157			const filesResult = await pi.exec("git", ["ls-files"], {
158				cwd: ctx.cwd,
159				timeout: 10,
160			});
161
162			if (filesResult.code !== 0) {
163				ctx.ui.notify("Failed to get tracked files", "error");
164				return;
165			}
166
167			const files = filesResult.stdout.trim().split("\n").filter((f) => f);
168			const findings: Array<{ file: string; matches: Array<{ line: number; pattern: string; text: string }> }> =
169				[];
170
171			for (const file of files) {
172				// Skip binary files and certain directories
173				if (file.includes("node_modules/") || file.includes(".git/")) continue;
174
175				const contentResult = await pi.exec("cat", [file], {
176					cwd: ctx.cwd,
177					timeout: 5,
178				});
179
180				if (contentResult.code !== 0) continue;
181
182				const { found, matches } = await scanFileForSecrets(file, contentResult.stdout);
183
184				if (found) {
185					findings.push({ file, matches });
186				}
187			}
188
189			if (findings.length === 0) {
190				ctx.ui.notify("✓ No potential secrets found", "info");
191			} else {
192				const lines = ["⚠️  Potential secrets found:", ""];
193				for (const { file, matches } of findings) {
194					lines.push(`${file}:`);
195					for (const match of matches) {
196						lines.push(`  Line ${match.line}: ${match.pattern}`);
197					}
198				}
199				ctx.ui.notify(lines.join("\n"), "warning");
200			}
201		},
202	});
203}