Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

security-scan.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

security-scan.tsBlame297 lines · 2 contributors
3ef4c9dClaude1/**
2 * Security + secret scanner.
3 * Runs on every push (via post-receive) AND every PR.
4 * Combines fast regex detection for secrets with an AI-powered semantic review
5 * for risky patterns (SSRF, SQL injection, XSS, unsafe deserialisation, etc).
6 */
7
8import { getAnthropic, MODEL_SONNET, extractText, parseJsonResponse, isAiAvailable } from "./ai-client";
9
10export interface SecretFinding {
11 type: string;
12 file: string;
13 line: number;
14 snippet: string;
15 severity: "critical" | "high" | "medium" | "low";
16}
17
18export interface SecurityFinding {
19 type: string;
20 file: string;
21 line?: number;
22 description: string;
23 severity: "critical" | "high" | "medium" | "low";
24 suggestion?: string;
25}
26
27export interface ScanResult {
28 secrets: SecretFinding[];
29 securityIssues: SecurityFinding[];
30 summary: string;
31 passed: boolean;
32}
33
34interface SecretPattern {
35 type: string;
36 regex: RegExp;
37 severity: SecretFinding["severity"];
38}
39
40// High-signal secret detectors. Ordered most-specific first.
41export const SECRET_PATTERNS: SecretPattern[] = [
42 { type: "AWS Access Key", regex: /\b(AKIA|ASIA|AIDA|AROA)[0-9A-Z]{16}\b/, severity: "critical" },
43 { type: "AWS Secret Key", regex: /aws(.{0,20})?(secret|access)?(.{0,20})?['\"]([A-Za-z0-9/+=]{40})['\"]/i, severity: "critical" },
44 { type: "GitHub Token", regex: /\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,251}\b/, severity: "critical" },
45 { type: "Anthropic API Key", regex: /\bsk-ant-(api03|admin01)-[A-Za-z0-9_-]{80,}\b/, severity: "critical" },
46 { type: "OpenAI API Key", regex: /\bsk-(proj-|live-)?[A-Za-z0-9_-]{32,}\b/, severity: "critical" },
47 { type: "Stripe Key", regex: /\b(sk_live_|rk_live_|pk_live_)[A-Za-z0-9]{24,}\b/, severity: "critical" },
48 { type: "Slack Token", regex: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, severity: "high" },
49 { type: "Google API Key", regex: /\bAIza[0-9A-Za-z_-]{35}\b/, severity: "high" },
50 { type: "SendGrid Key", regex: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/, severity: "high" },
51 { type: "Twilio Key", regex: /\bSK[0-9a-fA-F]{32}\b/, severity: "high" },
52 { type: "Generic API Token", regex: /(?:api[_-]?key|apikey|access[_-]?token|secret)["'\s:=]+["']?([A-Za-z0-9_\-]{24,})["']?/i, severity: "medium" },
53 { type: "Private Key (PEM)", regex: /-----BEGIN (RSA |OPENSSH |EC |DSA |PGP )?PRIVATE KEY-----/, severity: "critical" },
54 { type: "JWT", regex: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/, severity: "medium" },
55 { type: "Postgres URL", regex: /\bpostgres(?:ql)?:\/\/[^:\s]+:[^@\s]+@[^/\s]+\/\S+/, severity: "high" },
56 { type: "Mongo URL", regex: /\bmongodb(?:\+srv)?:\/\/[^:\s]+:[^@\s]+@[^/\s]+\/\S+/, severity: "high" },
57];
58
59// Paths we skip entirely (noise, binaries, generated files)
60const SKIP_PATHS = [
61 /(^|\/)\.git\//,
62 /(^|\/)node_modules\//,
63 /(^|\/)vendor\//,
64 /(^|\/)dist\//,
65 /(^|\/)build\//,
66 /(^|\/)\.next\//,
67 /(^|\/)\.cache\//,
68 /\.(png|jpg|jpeg|gif|webp|ico|svg|pdf|mp4|mov|wasm|woff2?|ttf|eot|map)$/i,
69 /(^|\/)bun\.lock(b)?$/,
70 /(^|\/)package-lock\.json$/,
71 /(^|\/)yarn\.lock$/,
72 /(^|\/)pnpm-lock\.yaml$/,
73];
74
75export function shouldSkipPath(path: string): boolean {
76 return SKIP_PATHS.some((re) => re.test(path));
77}
78
79/**
80 * Fast local regex-based secret scanner. No network, no Claude — safe to run
81 * on every push regardless of whether the AI key is configured.
82 */
83export function scanForSecrets(
84 files: Array<{ path: string; content: string }>
85): SecretFinding[] {
86 const findings: SecretFinding[] = [];
87 for (const file of files) {
88 if (shouldSkipPath(file.path)) continue;
89 const lines = file.content.split("\n");
90 for (let i = 0; i < lines.length; i++) {
91 const line = lines[i];
92 // Skip lines that look like placeholders / tests
93 if (
94 /example|placeholder|fake|dummy|your[-_]?api|xxxxx|testkey|changeme/i.test(
95 line
96 )
97 ) {
98 continue;
99 }
100 for (const pattern of SECRET_PATTERNS) {
101 if (pattern.regex.test(line)) {
102 findings.push({
103 type: pattern.type,
104 file: file.path,
105 line: i + 1,
106 snippet: line.trim().slice(0, 200),
107 severity: pattern.severity,
108 });
109 break; // one finding per line
110 }
111 }
112 }
113 }
114 return findings;
115}
116
117/**
118 * Ask Claude to review a diff or snapshot for security issues.
119 * Returns structured findings; safe to call without AI key (returns empty).
120 */
121export async function aiSecurityScan(
122 repoFullName: string,
123 diffOrSnapshot: string
124): Promise<SecurityFinding[]> {
125 if (!isAiAvailable()) return [];
126 const client = getAnthropic();
127
128 try {
129 const message = await client.messages.create({
130 model: MODEL_SONNET,
131 max_tokens: 2048,
132 messages: [
133 {
134 role: "user",
135 content: `You are a security auditor reviewing code on the repository "${repoFullName}".
136
137Analyse the following code for high-signal security issues:
138- Injection (SQL, command, LDAP, XPath)
139- Cross-site scripting (XSS)
140- Insecure deserialisation
141- SSRF / unvalidated redirects
142- Path traversal
143- Broken authentication / authorisation (e.g. missing access checks)
144- Insecure cryptography (weak hashing for passwords, hard-coded IVs)
145- Race conditions with security impact
146- Insufficient input validation at a trust boundary
147
148Do NOT report low-risk style issues, noisy defensive-coding suggestions, or theoretical risks without a plausible trigger.
149
150Respond ONLY with JSON of shape:
151{
152 "findings": [
153 { "type": "SQL Injection", "file": "src/x.ts", "line": 42, "severity": "high", "description": "...", "suggestion": "..." }
154 ]
155}
156
157If the code is clean, return { "findings": [] }.
158
159\`\`\`
160${diffOrSnapshot.slice(0, 80000)}
161\`\`\``,
162 },
163 ],
164 });
165
166 const text = extractText(message);
167 const parsed = parseJsonResponse<{ findings: SecurityFinding[] }>(text);
168 if (!parsed || !Array.isArray(parsed.findings)) return [];
169 // Normalise severity
170 return parsed.findings.map((f) => ({
171 ...f,
172 severity: (["critical", "high", "medium", "low"].includes(f.severity as string)
173 ? f.severity
174 : "medium") as SecurityFinding["severity"],
175 }));
176 } catch (err) {
177 console.error("[security-scan] AI scan failed:", err);
178 return [];
179 }
180}
181
e2206a6Test User182export interface AiSecurityScanOutcome {
183 findings: SecurityFinding[];
184 /** true when the AI provider errored, timed out, or returned unparseable
185 * output — distinct from a genuine clean scan (skipped: false, findings: []). */
186 skipped: boolean;
187 error?: string;
188}
189
190/**
191 * Same AI security review as aiSecurityScan(), but distinguishes "scan
192 * errored" from "scan ran and found nothing" — aiSecurityScan() itself
193 * returns [] for both, which made a provider outage indistinguishable from
194 * a clean scan at every call site (gate.ts reported "No security issues
195 * found" on an Anthropic API error). Mirrors the skip-not-silently-pass
196 * precedent already established for runGateTestScan (gate.ts, commit
197 * 6930df0): don't hard-block a merge gate on a third-party outage, but
198 * never claim "clean" when the scan didn't actually run.
199 */
200export async function aiSecurityScanSafe(
201 repoFullName: string,
202 diffOrSnapshot: string
203): Promise<AiSecurityScanOutcome> {
204 if (!isAiAvailable()) return { findings: [], skipped: true, error: "AI not configured" };
205 const client = getAnthropic();
206
207 try {
208 const message = await client.messages.create({
209 model: MODEL_SONNET,
210 max_tokens: 2048,
211 messages: [
212 {
213 role: "user",
214 content: `You are a security auditor reviewing code on the repository "${repoFullName}".
215
216Analyse the following code for high-signal security issues:
217- Injection (SQL, command, LDAP, XPath)
218- Cross-site scripting (XSS)
219- Insecure deserialisation
220- SSRF / unvalidated redirects
221- Path traversal
222- Broken authentication / authorisation (e.g. missing access checks)
223- Insecure cryptography (weak hashing for passwords, hard-coded IVs)
224- Race conditions with security impact
225- Insufficient input validation at a trust boundary
226
227Do NOT report low-risk style issues, noisy defensive-coding suggestions, or theoretical risks without a plausible trigger.
228
229Respond ONLY with JSON of shape:
230{
231 "findings": [
232 { "type": "SQL Injection", "file": "src/x.ts", "line": 42, "severity": "high", "description": "...", "suggestion": "..." }
233 ]
234}
235
236If the code is clean, return { "findings": [] }.
237
238\`\`\`
239${diffOrSnapshot.slice(0, 80000)}
240\`\`\``,
241 },
242 ],
243 });
244
245 const text = extractText(message);
246 const parsed = parseJsonResponse<{ findings: SecurityFinding[] }>(text);
247 if (!parsed || !Array.isArray(parsed.findings)) {
248 return { findings: [], skipped: true, error: "unparseable AI response" };
249 }
250 return {
251 findings: parsed.findings.map((f) => ({
252 ...f,
253 severity: (["critical", "high", "medium", "low"].includes(f.severity as string)
254 ? f.severity
255 : "medium") as SecurityFinding["severity"],
256 })),
257 skipped: false,
258 };
259 } catch (err) {
260 const message = err instanceof Error ? err.message : String(err);
261 console.error("[security-scan] AI scan failed:", err);
262 return { findings: [], skipped: true, error: message };
263 }
264}
265
3ef4c9dClaude266/**
267 * Run full security scan: regex secrets + (optional) AI security review.
268 */
269export async function runSecurityScan(
270 repoFullName: string,
271 files: Array<{ path: string; content: string }>,
272 diffText?: string
273): Promise<ScanResult> {
274 const secrets = scanForSecrets(files);
275 const securityIssues = diffText
276 ? await aiSecurityScan(repoFullName, diffText)
277 : [];
278
279 const criticalSecrets = secrets.filter((s) => s.severity === "critical").length;
280 const criticalIssues = securityIssues.filter((i) => i.severity === "critical").length;
281 const highIssues = securityIssues.filter((i) => i.severity === "high").length;
282
283 const passed = criticalSecrets === 0 && criticalIssues === 0 && highIssues === 0;
284
285 const parts: string[] = [];
286 if (secrets.length) parts.push(`${secrets.length} secret${secrets.length === 1 ? "" : "s"}`);
287 if (securityIssues.length)
288 parts.push(
289 `${securityIssues.length} security issue${securityIssues.length === 1 ? "" : "s"}`
290 );
291 const summary =
292 parts.length === 0
293 ? "No issues detected"
294 : `Found ${parts.join(" + ")} (${criticalSecrets + criticalIssues} critical, ${highIssues} high)`;
295
296 return { secrets, securityIssues, summary, passed };
297}