skillguard/artifacts/api-server/src/lib/scanEngine.ts
amertensreplit 2236ad179d Add DE/EN/ES multilingual support to SkillGuard (Task #49)
German is source of truth; EN/ES fully translated with no German residue.
Auto-detects browser language (fallback German), persists choice, language
switcher on all pages, localized formats/Clerk/legal. Scans store their language.

Backend (T001-T003): language column on scans, openapi+codegen, ruleCatalogI18n,
language threaded scans route -> analyzeSkill -> runStaticRule -> AI calls.
Route/AI error messages localized via expanded i18n MESSAGES + reqLang(req)
(?lang query -> Accept-Language header -> "de"). No German left in routes.

Frontend (T004-T005): react-i18next framework, LanguageSwitcher, locale-aware
format.ts, Clerk localizations. All page/component strings externalized to
de/en/es locale area files across catalog, education, scan form/report/compare,
history, dashboard, admin, legal pages.

T006 verification + review-fix follow-up (this session):
- Applied formatNumber to all visible metrics in scan-report (risk score,
  severity counts, security/privacy) and scan-compare (risk score, file count,
  diff counts); PDF/HTML export numbers formatted via Intl.NumberFormat(lng).
- Fixed leftover `@workspace/n` import alias in i18n/index.ts -> real package
  `@workspace/api-client-react` (was failing workspace typecheck).
- Verified: full `pnpm run typecheck` green; api-server tests 72/72 pass;
  curl confirms localized error responses (de/en/es) on scans route.

Deviations: AI connection-test prompts left in German intentionally (sent to
the model, not user-facing). proposeFollowUpTasks already created #52.

Replit-Task-Id: 9f137230-db11-45dc-9276-4e5cbcceff03
2026-06-13 09:05:57 +00:00

277 lines
7.2 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,
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<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;
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<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;
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,
};
}