import { Router, type IRouter } from "express"; import { db } from "@workspace/db"; import { scansTable, scanFilesTable, findingsTable, type Scan, type ScanFile, type Finding, } from "@workspace/db"; import { eq, desc } from "drizzle-orm"; import { ListScansResponse, CreateScanBody, GetScanParams, GetScanResponse, DeleteScanParams, } from "@workspace/api-zod"; import { parseZip, parseSingleFile, parseText, deriveScanName, } from "../lib/skillParser"; import { analyzeSkill, type EngineResult } from "../lib/scanEngine"; import { STATIC_RULES, AI_RULES, type ParsedFile } from "../lib/ruleCatalog"; import { logger } from "../lib/logger"; const router: IRouter = Router(); type CreateScanInput = ReturnType; export function serializeScan(scan: Scan) { return { id: scan.id, name: scan.name, source: scan.source, status: scan.status, verdict: scan.verdict, riskScore: scan.riskScore, fileCount: scan.fileCount, aiUsed: scan.aiUsed, aiError: scan.aiError, findingCounts: scan.findingCounts, createdAt: scan.createdAt.toISOString(), }; } function serializeFile(f: ScanFile) { return { path: f.path, kind: f.kind, language: f.language, size: f.size, }; } function serializeFinding(f: Finding) { return { id: f.id, ruleId: f.ruleId, axis: f.axis, severity: f.severity, title: f.title, description: f.description, remediation: f.remediation, file: f.file, line: f.line, snippet: f.snippet, detectedBy: f.detectedBy, }; } function serializeScanDetail( scan: Scan, files: ScanFile[], findings: Finding[], ) { return { ...serializeScan(scan), checkpoints: scan.checkpoints ?? [], files: files.map(serializeFile), findings: [...findings].sort((a, b) => a.id - b.id).map(serializeFinding), }; } type ParseResult = | { ok: true; files: ParsedFile[] } | { ok: false; status: number; message: string }; function parseScanInput(input: CreateScanInput): ParseResult { try { let files: ParsedFile[]; if (input.source === "zip") { if (!input.contentBase64) return { ok: false, status: 400, message: "ZIP-Inhalt fehlt." }; files = parseZip(Buffer.from(input.contentBase64, "base64")); } else if (input.source === "file") { if (!input.contentBase64) return { ok: false, status: 400, message: "Dateiinhalt fehlt." }; files = [ parseSingleFile( input.filename ?? "datei", Buffer.from(input.contentBase64, "base64"), ), ]; } else { if (!input.text || !input.text.trim()) return { ok: false, status: 400, message: "Text fehlt." }; files = [parseText(input.text, input.name ?? "SKILL.md")]; } if (files.length === 0) return { ok: false, status: 400, message: "Keine analysierbaren Dateien gefunden.", }; return { ok: true, files }; } catch (err) { logger.error({ err }, "Skill-Parsing fehlgeschlagen"); return { ok: false, status: 400, message: "Das Skill konnte nicht gelesen werden. Bitte prüfen Sie das Format (gültiges ZIP / Textdatei).", }; } } async function persistScan( input: CreateScanInput, name: string, files: ParsedFile[], result: EngineResult, ): Promise<{ scan: Scan; files: ScanFile[]; findings: Finding[] }> { const [scan] = await db .insert(scansTable) .values({ name, source: input.source, status: "completed", verdict: result.verdict, riskScore: result.riskScore, fileCount: files.length, aiUsed: result.aiUsed, aiError: result.aiError, findingCounts: result.counts, checkpoints: result.checkpoints, }) .returning(); let insertedFiles: ScanFile[] = []; if (files.length > 0) { insertedFiles = await db .insert(scanFilesTable) .values( files.map((f) => ({ scanId: scan.id, path: f.path, kind: f.kind, language: f.language, size: f.size, })), ) .returning(); } let insertedFindings: Finding[] = []; if (result.findings.length > 0) { insertedFindings = await db .insert(findingsTable) .values( result.findings.map((f) => ({ scanId: scan.id, ruleId: f.ruleId, axis: f.axis, severity: f.severity, title: f.title, description: f.description, remediation: f.remediation, file: f.file, line: f.line, snippet: f.snippet, detectedBy: f.detectedBy, })), ) .returning(); } return { scan, files: insertedFiles, findings: insertedFindings }; } router.get("/scans", async (_req, res) => { const rows = await db .select() .from(scansTable) .orderBy(desc(scansTable.createdAt)); res.json(ListScansResponse.parse(rows.map(serializeScan))); }); router.post("/scans", async (req, res) => { const parsed = CreateScanBody.safeParse(req.body); if (!parsed.success) { return res .status(400) .json({ message: "Ungültige Eingabe", details: parsed.error.issues }); } const input = parsed.data; const parseResult = parseScanInput(input); if (!parseResult.ok) { return res.status(parseResult.status).json({ message: parseResult.message }); } const files = parseResult.files; const name = input.name?.trim() || deriveScanName(files, "Unbenanntes Skill"); const result = await analyzeSkill(files, input.useAi); const { scan, files: insertedFiles, findings } = await persistScan( input, name, files, result, ); return res .status(201) .json(GetScanResponse.parse(serializeScanDetail(scan, insertedFiles, findings))); }); const STREAM_PACING_MS = 80; const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); router.post("/scans/stream", async (req, res) => { const parsed = CreateScanBody.safeParse(req.body); if (!parsed.success) { res .status(400) .json({ message: "Ungültige Eingabe", details: parsed.error.issues }); return; } const input = parsed.data; const parseResult = parseScanInput(input); if (!parseResult.ok) { res.status(parseResult.status).json({ message: parseResult.message }); return; } const files = parseResult.files; const name = input.name?.trim() || deriveScanName(files, "Unbenanntes Skill"); res.status(200); res.setHeader("Content-Type", "application/x-ndjson; charset=utf-8"); res.setHeader("Cache-Control", "no-cache, no-transform"); res.setHeader("X-Accel-Buffering", "no"); res.setHeader("Connection", "keep-alive"); res.flushHeaders(); // Detect a genuine client disconnect. NOTE: do NOT use req.on("close") here — // for a POST it fires as soon as the request body is consumed, not on abort. // res "close" before writableFinished means the client went away. let aborted = false; res.on("close", () => { if (!res.writableFinished) aborted = true; }); const write = (obj: unknown) => { if (aborted || res.writableEnded) return; res.write(JSON.stringify(obj) + "\n"); }; write({ type: "start", name, fileCount: files.length, totalChecks: STATIC_RULES.length + (input.useAi ? AI_RULES.length : 0), }); let cumulative = 0; try { const result = await analyzeSkill(files, input.useAi, async (event) => { if (event.type === "ai-start") { write({ type: "ai-start" }); return; } cumulative += event.checkpoint.scoreDelta; write({ type: "checkpoint", checkpoint: event.checkpoint, runningScore: Math.min(100, cumulative), }); if (!aborted) await delay(STREAM_PACING_MS); }); const { scan } = await persistScan(input, name, files, result); write({ type: "done", scanId: scan.id, riskScore: result.riskScore, verdict: result.verdict, findingCounts: result.counts, aiUsed: result.aiUsed, aiError: result.aiError, }); if (!aborted && !res.writableEnded) res.end(); } catch (err) { logger.error({ err }, "Streaming-Scan fehlgeschlagen"); write({ type: "error", message: "Die Analyse ist fehlgeschlagen." }); if (!aborted && !res.writableEnded) res.end(); } }); router.get("/scans/:id", async (req, res) => { const params = GetScanParams.safeParse(req.params); if (!params.success) return res.status(400).json({ message: "Ungültige ID" }); const [scan] = await db .select() .from(scansTable) .where(eq(scansTable.id, params.data.id)); if (!scan) return res.status(404).json({ message: "Scan nicht gefunden" }); const files = await db .select() .from(scanFilesTable) .where(eq(scanFilesTable.scanId, scan.id)); const findings = await db .select() .from(findingsTable) .where(eq(findingsTable.scanId, scan.id)) .orderBy(findingsTable.id); return res.json(GetScanResponse.parse(serializeScanDetail(scan, files, findings))); }); router.delete("/scans/:id", async (req, res) => { const params = DeleteScanParams.safeParse(req.params); if (!params.success) return res.status(400).json({ message: "Ungültige ID" }); await db.delete(scansTable).where(eq(scansTable.id, params.data.id)); return res.status(204).send(); }); export default router;