Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commite2206a6

Add feature

Test User committed on July 16, 2026Parent: a54e588
7 files changed+33412e2206a6d991b500e95bee787a4f6b314583b1ce4
7 changed files+334−12
Addedsrc/__tests__/gate-security-scan-availability.test.ts+155−0View fileUnifiedSplit
1/**
2 * aiSecurityScanSafe() / gate.ts's runSecretAndSecurityScan — availability
3 * vs verdict, mirroring gate-gatetest-availability.test.ts's pattern for
4 * the exact same class of bug in a different gate.
5 *
6 * Before this fix, aiSecurityScan() returned `[]` on an AI-provider error
7 * (timeout, rate limit, unparseable response) — indistinguishable from a
8 * genuinely clean scan. gate.ts then reported "No security issues found",
9 * silently converting a merge-blocking security gate into an automatic
10 * pass on any Anthropic outage. Fixed the same way runGateTestScan's
11 * outage bug was fixed (commit 6930df0): don't hard-block on a third-party
12 * outage, but never claim "clean" when the scan didn't actually run.
13 */
14
15import { afterEach, beforeEach, describe, expect, it } from "bun:test";
16import { runSecretAndSecurityScan } from "../lib/gate";
17import { config } from "../lib/config";
18import { __resetAnthropicClientForTests } from "../lib/ai-client";
19
20const origFetch = globalThis.fetch;
21const origKey = process.env.ANTHROPIC_API_KEY;
22
23function mockFetch(responder: () => Response | Promise<Response>): void {
24 // @ts-expect-error — override global fetch for the test
25 globalThis.fetch = async (): Promise<Response> => responder();
26}
27
28function anthropicResponse(text: string): Response {
29 return new Response(
30 JSON.stringify({
31 id: "msg_test",
32 type: "message",
33 role: "assistant",
34 model: "claude-sonnet-4-6",
35 content: [{ type: "text", text }],
36 stop_reason: "end_turn",
37 stop_sequence: null,
38 usage: { input_tokens: 10, output_tokens: 10 },
39 }),
40 { status: 200, headers: { "content-type": "application/json" } }
41 );
42}
43
44beforeEach(() => {
45 process.env.ANTHROPIC_API_KEY = "sk-ant-test-key";
46 // The Anthropic SDK client is a module-level singleton (ai-client.ts's
47 // `_client`) that captures a fetch reference at construction time rather
48 // than reading globalThis.fetch fresh per call — without resetting it,
49 // only the FIRST test's globalThis.fetch mock would ever actually be
50 // used, and every later test would silently reuse it (confirmed: this
51 // leaked a 429-mock response into an unrelated later test before this
52 // reset was added).
53 __resetAnthropicClientForTests();
54});
55
56afterEach(() => {
57 globalThis.fetch = origFetch;
58 if (origKey === undefined) delete process.env.ANTHROPIC_API_KEY;
59 else process.env.ANTHROPIC_API_KEY = origKey;
60 __resetAnthropicClientForTests();
61});
62
63describe("runSecretAndSecurityScan — security scan availability vs verdict", () => {
64 it("is skipped (not blocking) when the AI provider errors — was previously silently 'clean'", async () => {
65 mockFetch(() => new Response("rate limited", { status: 429 }));
66 const result = await runSecretAndSecurityScan("o", "r", "refs/heads/main", "a".repeat(40), {
67 scanSecrets: false,
68 scanSecurity: true,
69 diffText: "diff --git a/x.ts b/x.ts\n+ eval(userInput)",
70 });
71 expect(result.securityResult.skipped).toBe(true);
72 expect(result.securityResult.passed).toBe(true);
73 expect(result.securityResult.details).not.toBe("No security issues found");
74 expect(result.securityResult.details).toContain("unavailable");
75 });
76
77 it("is skipped (not blocking) when the request throws — network error / timeout", async () => {
78 mockFetch(() => {
79 throw new Error("ETIMEDOUT");
80 });
81 const result = await runSecretAndSecurityScan("o", "r", "refs/heads/main", "a".repeat(40), {
82 scanSecrets: false,
83 scanSecurity: true,
84 diffText: "some diff",
85 });
86 expect(result.securityResult.skipped).toBe(true);
87 expect(result.securityResult.passed).toBe(true);
88 // The Anthropic SDK wraps a thrown-during-fetch error into its own
89 // connection/timeout error type rather than preserving the original
90 // message verbatim — assert on the outcome (skipped, not the clean
91 // string), not the SDK's exact wording.
92 expect(result.securityResult.details).not.toBe("No security issues found");
93 expect(result.securityResult.details).toContain("unavailable");
94 });
95
96 it("is skipped (not blocking) when the AI response is unparseable", async () => {
97 mockFetch(() => anthropicResponse("not json at all, just prose"));
98 const result = await runSecretAndSecurityScan("o", "r", "refs/heads/main", "a".repeat(40), {
99 scanSecrets: false,
100 scanSecurity: true,
101 diffText: "some diff",
102 });
103 expect(result.securityResult.skipped).toBe(true);
104 expect(result.securityResult.passed).toBe(true);
105 expect(result.securityResult.details).not.toBe("No security issues found");
106 });
107
108 // Regression guard: proves the two states are now actually distinguishable
109 // — a genuine clean scan must still say "No security issues found" and
110 // must NOT be marked skipped.
111 it("is NOT skipped and reports a genuine clean verdict on success with zero findings", async () => {
112 mockFetch(() => anthropicResponse('```json\n{"findings": []}\n```'));
113 const result = await runSecretAndSecurityScan("o", "r", "refs/heads/main", "a".repeat(40), {
114 scanSecrets: false,
115 scanSecurity: true,
116 diffText: "some diff",
117 });
118 expect(result.securityResult.skipped).toBe(false);
119 expect(result.securityResult.passed).toBe(true);
120 expect(result.securityResult.details).toBe("No security issues found");
121 });
122
123 it("blocks on a genuine finding — real verdict, not an outage", async () => {
124 mockFetch(() =>
125 anthropicResponse(
126 '```json\n{"findings": [{"type": "Command Injection", "file": "x.ts", "line": 1, "severity": "critical", "description": "eval on user input"}]}\n```'
127 )
128 );
129 const result = await runSecretAndSecurityScan("o", "r", "refs/heads/main", "a".repeat(40), {
130 scanSecrets: false,
131 scanSecurity: true,
132 diffText: "some diff",
133 });
134 expect(result.securityResult.skipped).toBe(false);
135 expect(result.securityResult.passed).toBe(false);
136 expect(result.securityIssues).toHaveLength(1);
137 });
138
139 it("is skipped with 'no diff provided' when scanSecurity is requested but no diffText is given", async () => {
140 const result = await runSecretAndSecurityScan("o", "r", "refs/heads/main", "a".repeat(40), {
141 scanSecrets: false,
142 scanSecurity: true,
143 });
144 expect(result.securityResult.skipped).toBe(true);
145 expect(result.securityResult.passed).toBe(true);
146 expect(result.securityResult.details).toBe("Skipped — no diff provided");
147 });
148});
149
150// Keep config import referenced — sanity that config.anthropicApiKey reflects env for this test file.
151describe("config.anthropicApiKey reflects ANTHROPIC_API_KEY for this suite", () => {
152 it("resolves to the mocked value while set", () => {
153 expect(config.anthropicApiKey).toBe("sk-ant-test-key");
154 });
155});
Modifiedsrc/__tests__/webhook-delivery.test.ts+17−0View fileUnifiedSplit
247247 expect(row.lastStatusCode).toBe(503);
248248 expect(row.nextAttemptAt).toBeNull();
249249 expect(row.succeededAt).toBeNull();
250
251 // Owner alert — previously the only signal on a permanently-dead
252 // delivery was a console.error nobody reads. notifyWebhookDead()
253 // is fire-and-forget (not awaited by attemptDelivery), so poll
254 // briefly rather than assume it landed synchronously.
255 const { notifications } = await import("../db/schema");
256 let notif: { kind: string; repositoryId: string | null } | undefined;
257 for (let i = 0; i < 20 && !notif; i++) {
258 const rows = await db
259 .select({ kind: notifications.kind, repositoryId: notifications.repositoryId })
260 .from(notifications)
261 .where(eq(notifications.userId, fx.userId));
262 notif = rows.find((r) => r.kind === "integration_dead");
263 if (!notif) await new Promise((res) => setTimeout(res, 50));
264 }
265 expect(notif).toBeDefined();
266 expect(notif!.repositoryId).toBe(fx.repoId);
250267 } finally {
251268 await fx.cleanup();
252269 server.stop(true);
Modifiedsrc/lib/ai-client.ts+11−0View fileUnifiedSplit
2222 return !!config.anthropicApiKey;
2323}
2424
25/**
26 * Test-only: drop the cached client so the next getAnthropic() call
27 * constructs a fresh one. Needed because the SDK captures a fetch
28 * reference at construction time rather than reading globalThis.fetch
29 * fresh per call — without this, a test's `globalThis.fetch = mock`
30 * done after the first getAnthropic() call in a test run has no effect.
31 */
32export function __resetAnthropicClientForTests(): void {
33 _client = null;
34}
35
2536/** Primary model for all AI features — code understanding, review, generation */
2637export const MODEL_SONNET = "claude-sonnet-4-6";
2738/** Light-task model — never reference directly; route through modelForTask() */
Modifiedsrc/lib/gate.ts+23−10View fileUnifiedSplit
1717import { db } from "../db";
1818import { gateRuns, repoSettings, repositories, users } from "../db/schema";
1919import { getOrCreateSettings } from "./repo-bootstrap";
20import { scanForSecrets, aiSecurityScan } from "./security-scan";
20import { scanForSecrets, aiSecurityScanSafe } from "./security-scan";
2121import type { SecretFinding, SecurityFinding } from "./security-scan";
2222import { repairSecrets, repairSecurityIssues } from "./auto-repair";
2323import { readFile } from "fs/promises";
345345 }
346346
347347 const secrets = opts.scanSecrets ? scanForSecrets(files) : [];
348 const securityIssues =
349 opts.scanSecurity && opts.diffText ? await aiSecurityScan(`${owner}/${repo}`, opts.diffText) : [];
348 const aiOutcome =
349 opts.scanSecurity && opts.diffText
350 ? await aiSecurityScanSafe(`${owner}/${repo}`, opts.diffText)
351 : { findings: [], skipped: true as const };
352 const securityIssues = aiOutcome.findings;
350353
351354 const criticalSecrets = secrets.filter((s) => s.severity === "critical").length;
352355 const criticalSec = securityIssues.filter((i) => i.severity === "critical" || i.severity === "high").length;
353356
357 // Three distinguishable states, not two: "not configured" (no scanSecurity/
358 // diffText), "configured but the AI provider errored/timed out" (aiOutcome.
359 // skipped, with an error we don't silently swallow), and "ran and returned
360 // a real verdict" (skipped: false — the only case allowed to say "No
361 // security issues found", since that string previously also covered a
362 // provider outage indistinguishably).
363 const notConfigured = !opts.scanSecurity || !opts.diffText;
364 const securityDetails = notConfigured
365 ? "Skipped — no diff provided"
366 : aiOutcome.skipped
367 ? `AI security scan unavailable — scan skipped, not blocking: ${aiOutcome.error ?? "unknown error"}`
368 : securityIssues.length === 0
369 ? "No security issues found"
370 : `Found ${securityIssues.length} issue${securityIssues.length === 1 ? "" : "s"} (${criticalSec} high/critical)`;
371
354372 return {
355373 secretResult: {
356374 name: "Secret scan",
363381 securityResult: {
364382 name: "Security scan",
365383 passed: criticalSec === 0,
366 skipped: !opts.scanSecurity || !opts.diffText,
367 details:
368 securityIssues.length === 0
369 ? opts.scanSecurity && opts.diffText
370 ? "No security issues found"
371 : "Skipped — no diff provided"
372 : `Found ${securityIssues.length} issue${securityIssues.length === 1 ? "" : "s"} (${criticalSec} high/critical)`,
384 skipped: notConfigured || aiOutcome.skipped,
385 details: securityDetails,
373386 },
374387 secrets,
375388 securityIssues,
Modifiedsrc/lib/notify.ts+2−1View fileUnifiedSplit
115115 | "release_published"
116116 | "repo_archived"
117117 | "pr_stale"
118 | "issue_stale";
118 | "issue_stale"
119 | "integration_dead";
119120
120121/** Kinds that can trigger email delivery. Keep this list conservative — any
121122 * kind here must map to a user preference column on the users table. */
Modifiedsrc/lib/security-scan.ts+84−0View fileUnifiedSplit
179179 }
180180}
181181
182export 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
182266/**
183267 * Run full security scan: regex secrets + (optional) AI security review.
184268 */
Modifiedsrc/lib/webhook-delivery.ts+42−1View fileUnifiedSplit
3030
3131import { and, asc, eq, lte, sql } from "drizzle-orm";
3232import { db } from "../db";
33import { webhookDeliveries, webhooks } from "../db/schema";
33import { webhookDeliveries, webhooks, repositories, users } from "../db/schema";
3434import { assertPublicUrl } from "./ssrf-guard";
35import { notify } from "./notify";
3536
3637// ---------------------------------------------------------------------------
3738// Tunables
141142 }
142143}
143144
145/**
146 * Alert the repo owner that a webhook has permanently stopped delivering —
147 * previously the only signal was a console.error nobody reads (chat-
148 * notifier.ts) or a "no deliveries yet" pill on a settings page nobody was
149 * visiting. Fire-and-forget, in-app notification only; never throws — a bug
150 * here must not affect delivery bookkeeping.
151 */
152async function notifyWebhookDead(webhookId: string, reason: string): Promise<void> {
153 try {
154 const [row] = await db
155 .select({
156 url: webhooks.url,
157 repoName: repositories.name,
158 repoId: repositories.id,
159 ownerId: repositories.ownerId,
160 ownerUsername: users.username,
161 })
162 .from(webhooks)
163 .innerJoin(repositories, eq(repositories.id, webhooks.repositoryId))
164 .innerJoin(users, eq(users.id, repositories.ownerId))
165 .where(eq(webhooks.id, webhookId))
166 .limit(1);
167 if (!row) return;
168
169 await notify(row.ownerId, {
170 kind: "integration_dead",
171 title: `Webhook delivery failed permanently — ${row.repoName}`,
172 body: `A webhook to ${row.url} has stopped retrying (${reason}). It will not be attempted again until you re-check its configuration.`,
173 url: `/${row.ownerUsername}/${row.repoName}/settings/webhooks`,
174 repositoryId: row.repoId,
175 });
176 } catch (err) {
177 console.error("[webhook-delivery] notifyWebhookDead failed:", err);
178 }
179}
180
144181// ---------------------------------------------------------------------------
145182// One attempt
146183// ---------------------------------------------------------------------------
213250 /* swallow */
214251 }
215252
253 void notifyWebhookDead(d.webhookId, `blocked: ${guard.reason}`);
254
216255 return "dead";
217256 }
218257
301340 /* swallow */
302341 }
303342
343 void notifyWebhookDead(d.webhookId, errorMessage ?? `HTTP ${statusCode ?? "?"} after ${MAX_ATTEMPTS} attempts`);
344
304345 return "dead";
305346 }
306347
307348