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}