skillguard/artifacts/api-server/src/lib/scanEngine.ts
Replit Agent 434ec07885 Add live progress updates and detailed scan checkpoints to scan results
Introduce streaming endpoint for NDJSON scan progress, incorporate scan checkpoints into scan details, and update UI components to display this new information.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 0d01f99a-ea6a-447d-82fd-311715434a39
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 2852b526-3bf8-4a93-a62a-a50e26291074
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/e32d2b99-1721-47dd-833c-98b372f48008/0d01f99a-ea6a-447d-82fd-311715434a39/8MCgDZm
Replit-Helium-Checkpoint-Created: true
2026-06-10 18:53:17 +00:00

248 lines
6.5 KiB
TypeScript

import { db } from "@workspace/db";
import {
rulesTable,
promptsTable,
aiProvidersTable,
type Prompt,
} from "@workspace/db";
import { eq } from "drizzle-orm";
import {
STATIC_RULES,
AI_RULES,
runStaticRule,
type ParsedFile,
type RawFinding,
type Severity,
type Axis,
} from "./ruleCatalog";
import type {
FindingCounts as DbFindingCounts,
ScanCheckpoint,
} from "@workspace/db";
import { runAiAnalysis, type AiRuleConfig } from "./aiAnalysis";
export type { ScanCheckpoint } from "@workspace/db";
const SEVERITY_WEIGHT: Record<Severity, number> = {
critical: 50,
high: 18,
medium: 7,
low: 2,
info: 0,
};
export type ScanProgressEvent =
| { type: "ai-start" }
| { type: "checkpoint"; checkpoint: ScanCheckpoint };
export type ProgressFn = (
event: ScanProgressEvent,
) => void | Promise<void>;
export type EngineResult = {
findings: RawFinding[];
counts: DbFindingCounts;
checkpoints: ScanCheckpoint[];
riskScore: number;
verdict: "pass" | "review" | "block";
aiUsed: boolean;
aiError: string | null;
};
export function computeCounts(findings: RawFinding[]): DbFindingCounts {
const counts: DbFindingCounts = {
critical: 0,
high: 0,
medium: 0,
low: 0,
info: 0,
security: 0,
privacy: 0,
total: findings.length,
};
for (const f of findings) {
counts[f.severity] += 1;
counts[f.axis] += 1;
}
return counts;
}
export function computeScore(findings: RawFinding[]): number {
let score = 0;
for (const f of findings) score += SEVERITY_WEIGHT[f.severity];
return Math.min(100, score);
}
export function computeVerdict(
findings: RawFinding[],
score: number,
): "pass" | "review" | "block" {
const hasCritical = findings.some((f) => f.severity === "critical");
const hasHigh = findings.some((f) => f.severity === "high");
if (hasCritical || score >= 70) return "block";
if (hasHigh || score >= 20) return "review";
return "pass";
}
function scoreOf(findings: RawFinding[]): number {
return findings.reduce((s, f) => s + SEVERITY_WEIGHT[f.severity], 0);
}
export async function analyzeSkill(
files: ParsedFile[],
useAi: boolean,
onProgress?: ProgressFn,
): Promise<EngineResult> {
const dbRules = await db.select().from(rulesTable);
const ruleConfig = new Map(
dbRules.map((r) => [
r.ruleId,
{ enabled: r.enabled, severity: r.severity as Severity },
]),
);
const findings: RawFinding[] = [];
const checkpoints: ScanCheckpoint[] = [];
for (const rule of STATIC_RULES) {
const cfg = ruleConfig.get(rule.ruleId);
const severity = cfg?.severity ?? rule.defaultSeverity;
if (cfg && !cfg.enabled) {
const checkpoint: ScanCheckpoint = {
id: rule.ruleId,
label: rule.title,
category: rule.category,
axis: rule.axis,
severity,
status: "skipped",
findingCount: 0,
scoreDelta: 0,
detectedBy: "static",
};
checkpoints.push(checkpoint);
await onProgress?.({ type: "checkpoint", checkpoint });
continue;
}
const ruleFindings: RawFinding[] = [];
for (const file of files) {
ruleFindings.push(...runStaticRule(rule, file, severity));
}
findings.push(...ruleFindings);
const checkpoint: ScanCheckpoint = {
id: rule.ruleId,
label: rule.title,
category: rule.category,
axis: rule.axis,
severity,
status: ruleFindings.length > 0 ? "flagged" : "pass",
findingCount: ruleFindings.length,
scoreDelta: scoreOf(ruleFindings),
detectedBy: "static",
};
checkpoints.push(checkpoint);
await onProgress?.({ type: "checkpoint", checkpoint });
}
let aiUsed = false;
let aiError: string | null = null;
let aiFindings: RawFinding[] = [];
if (useAi) {
await onProgress?.({ type: "ai-start" });
const aiRuleIds = new Set(AI_RULES.map((r) => r.ruleId));
const enabledAiRules: AiRuleConfig[] = AI_RULES.filter((rule) => {
const cfg = ruleConfig.get(rule.ruleId);
return cfg ? cfg.enabled : true;
}).map((rule) => ({
ruleId: rule.ruleId,
title: rule.title,
description: rule.description,
axis: rule.axis as Axis,
severity: ruleConfig.get(rule.ruleId)?.severity ?? rule.defaultSeverity,
}));
const aiRulesEnabled = dbRules
.filter((r) => r.detectionType === "ai" || aiRuleIds.has(r.ruleId))
.some((r) => r.enabled);
const [provider] = await db
.select()
.from(aiProvidersTable)
.where(eq(aiProvidersTable.enabled, true))
.limit(1);
if (!aiRulesEnabled || enabledAiRules.length === 0) {
aiError = "KI-Regeln sind im Regelwerk deaktiviert.";
} else if (!provider) {
aiError =
"Kein aktiver KI-Provider konfiguriert. Bitte im Admin-Bereich einrichten.";
} else if (!provider.apiToken) {
aiError = `Für den Provider "${provider.name}" ist kein API-Token hinterlegt.`;
} else {
const prompts: Prompt[] = await db.select().from(promptsTable);
const result = await runAiAnalysis(
provider,
prompts,
files,
enabledAiRules,
);
aiError = result.error;
if (!result.error) {
aiUsed = true;
aiFindings = result.findings;
findings.push(...result.findings);
}
}
for (const rule of AI_RULES) {
const cfg = ruleConfig.get(rule.ruleId);
const severity = cfg?.severity ?? rule.defaultSeverity;
const enabled = cfg ? cfg.enabled : true;
let status: ScanCheckpoint["status"];
let findingCount = 0;
let scoreDelta = 0;
if (!enabled) {
status = "skipped";
} else if (!aiUsed) {
status = "error";
} else {
const ruleFindings = aiFindings.filter((f) => f.ruleId === rule.ruleId);
findingCount = ruleFindings.length;
scoreDelta = scoreOf(ruleFindings);
status = findingCount > 0 ? "flagged" : "pass";
}
const checkpoint: ScanCheckpoint = {
id: rule.ruleId,
label: rule.title,
category: rule.category,
axis: rule.axis,
severity,
status,
findingCount,
scoreDelta,
detectedBy: "ai",
};
checkpoints.push(checkpoint);
await onProgress?.({ type: "checkpoint", checkpoint });
}
}
const riskScore = computeScore(findings);
const counts = computeCounts(findings);
const verdict = computeVerdict(findings, riskScore);
return {
findings,
counts,
checkpoints,
riskScore,
verdict,
aiUsed,
aiError,
};
}