227 lines
5.8 KiB
TypeScript
227 lines
5.8 KiB
TypeScript
|
|
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;
|