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

227 lines
5.8 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 } from "../lib/scanEngine";
import type { ParsedFile } from "../lib/ruleCatalog";
import { logger } from "../lib/logger";
const router: IRouter = Router();
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,
};
}
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;
let files: ParsedFile[] = [];
try {
if (input.source === "zip") {
if (!input.contentBase64)
return res.status(400).json({ message: "ZIP-Inhalt fehlt." });
files = parseZip(Buffer.from(input.contentBase64, "base64"));
} else if (input.source === "file") {
if (!input.contentBase64)
return res.status(400).json({ message: "Dateiinhalt fehlt." });
files = [
parseSingleFile(
input.filename ?? "datei",
Buffer.from(input.contentBase64, "base64"),
),
];
} else {
if (!input.text || !input.text.trim())
return res.status(400).json({ message: "Text fehlt." });
files = [parseText(input.text, input.name ?? "SKILL.md")];
}
} catch (err) {
logger.error({ err }, "Skill-Parsing fehlgeschlagen");
return res.status(400).json({
message:
"Das Skill konnte nicht gelesen werden. Bitte prüfen Sie das Format (gültiges ZIP / Textdatei).",
});
}
if (files.length === 0) {
return res
.status(400)
.json({ message: "Keine analysierbaren Dateien gefunden." });
}
const name = input.name?.trim() || deriveScanName(files, "Unbenanntes Skill");
const result = await analyzeSkill(files, input.useAi);
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,
})
.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();
}
const payload = {
...serializeScan(scan),
files: insertedFiles.map(serializeFile),
findings: insertedFindings
.sort((a, b) => a.id - b.id)
.map(serializeFinding),
};
return res.status(201).json(GetScanResponse.parse(payload));
});
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);
const payload = {
...serializeScan(scan),
files: files.map(serializeFile),
findings: findings.map(serializeFinding),
};
return res.json(GetScanResponse.parse(payload));
});
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;