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, generateSkillDescription, type AiRuleConfig, } from "./aiAnalysis"; import { localizeRule, type Lang } from "./ruleCatalogI18n"; import { t } from "./i18n"; export type { ScanCheckpoint } from "@workspace/db"; const SEVERITY_WEIGHT: Record = { 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; export type EngineResult = { findings: RawFinding[]; counts: DbFindingCounts; checkpoints: ScanCheckpoint[]; riskScore: number; verdict: "pass" | "review" | "block"; aiUsed: boolean; aiError: string | null; aiDescription: 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, lang: Lang = "de", onProgress?: ProgressFn, ): Promise { 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; const ruleText = localizeRule(rule.ruleId, lang); if (cfg && !cfg.enabled) { const checkpoint: ScanCheckpoint = { id: rule.ruleId, label: ruleText.title, category: ruleText.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, lang)); } findings.push(...ruleFindings); const checkpoint: ScanCheckpoint = { id: rule.ruleId, label: ruleText.title, category: ruleText.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[] = []; let aiDescription: string | null = null; 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) => { const text = localizeRule(rule.ruleId, lang); return { ruleId: rule.ruleId, title: text.title, description: text.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); const prompts: Prompt[] = await db.select().from(promptsTable); // The skill description is generated independently of the AI findings // rules: it only needs a configured provider with a token, and a failure // here must never break the rest of the scan. if (provider?.apiToken) { aiDescription = await generateSkillDescription( provider, prompts, files, lang, ); } if (!aiRulesEnabled || enabledAiRules.length === 0) { aiError = t("aiRulesDisabled", lang); } else if (!provider) { aiError = t("aiNoProvider", lang); } else if (!provider.apiToken) { aiError = t("aiNoToken", lang, { name: provider.name }); } else { const result = await runAiAnalysis( provider, prompts, files, enabledAiRules, lang, ); 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 aiText = localizeRule(rule.ruleId, lang); const checkpoint: ScanCheckpoint = { id: rule.ruleId, label: aiText.title, category: aiText.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, aiDescription, }; }