skillguard/artifacts/api-server/src/routes/scans.ts

346 lines
9.3 KiB
TypeScript
Raw Normal View History

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<typeof CreateScanBody.parse>;
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<void>((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;