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