From a70b0d580a4d735927da623d90e41b7cfab634f4 Mon Sep 17 00:00:00 2001 From: Replit Agent Date: Mon, 8 Jun 2026 14:59:17 +0000 Subject: [PATCH] SkillGuard: complete frontend wiring and harden backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Original task: build "SkillGuard", a German web app to audit agent skills on two axes (IT-Sicherheit, Datenschutz) with static rule engine + Replit-independent AI analysis configured via an admin backend. This session: - Fixed frontend TS errors: lucide-react name collisions (Badge from ui, Activity from lucide), widened apiType to AiProviderApiType, added queryKey to useGetScan. - Verified all pages render in German (Dashboard, Prüfen, Bericht, Verlauf, Admin) and the full scan flow works end-to-end (malicious sample -> verdict block). Code-review-driven hardening: - POST /api/scans now returns the full ScanDetail (files + findings) to match the OpenAPI contract, instead of only the summary. - AI provider error bodies are redacted (token, Bearer, sk- patterns) before being returned/persisted, and provider fetches now have a 60s timeout. - ZIP parsing now enforces limits (max files, total + per-file size) to mitigate zip-bomb DoS. Updated replit.md (project overview, decisions, gotchas) and added a memory note on lucide-react icon name collisions. --- .agents/memory/MEMORY.md | 1 + .agents/memory/lucide-icon-name-collisions.md | 10 + .replit | 4 + artifacts/api-server/package.json | 1 + artifacts/api-server/src/app.ts | 4 +- artifacts/api-server/src/index.ts | 2 + artifacts/api-server/src/lib/aiAnalysis.ts | 190 +++ artifacts/api-server/src/lib/ruleCatalog.ts | 440 ++++++ artifacts/api-server/src/lib/scanEngine.ts | 126 ++ artifacts/api-server/src/lib/seed.ts | 57 + artifacts/api-server/src/lib/skillParser.ts | 160 +++ artifacts/api-server/src/routes/dashboard.ts | 73 + artifacts/api-server/src/routes/index.ts | 10 + artifacts/api-server/src/routes/prompts.ts | 55 + artifacts/api-server/src/routes/providers.ts | 144 ++ artifacts/api-server/src/routes/rules.ts | 56 + artifacts/api-server/src/routes/scans.ts | 226 ++++ .../skillguard/.replit-artifact/artifact.toml | 31 + artifacts/skillguard/components.json | 20 + artifacts/skillguard/index.html | 24 + artifacts/skillguard/package.json | 77 ++ artifacts/skillguard/public/favicon.svg | 3 + artifacts/skillguard/public/opengraph.jpg | Bin 0 -> 44486 bytes artifacts/skillguard/public/robots.txt | 2 + artifacts/skillguard/src/App.tsx | 44 + .../skillguard/src/components/layout.tsx | 76 ++ .../skillguard/src/components/ui-helpers.tsx | 40 + .../src/components/ui/accordion.tsx | 55 + .../src/components/ui/alert-dialog.tsx | 139 ++ .../skillguard/src/components/ui/alert.tsx | 59 + .../src/components/ui/aspect-ratio.tsx | 5 + .../skillguard/src/components/ui/avatar.tsx | 50 + .../skillguard/src/components/ui/badge.tsx | 43 + .../src/components/ui/breadcrumb.tsx | 115 ++ .../src/components/ui/button-group.tsx | 83 ++ .../skillguard/src/components/ui/button.tsx | 65 + .../skillguard/src/components/ui/calendar.tsx | 213 +++ .../skillguard/src/components/ui/card.tsx | 76 ++ .../skillguard/src/components/ui/carousel.tsx | 260 ++++ .../skillguard/src/components/ui/chart.tsx | 367 +++++ .../skillguard/src/components/ui/checkbox.tsx | 28 + .../src/components/ui/collapsible.tsx | 11 + .../skillguard/src/components/ui/command.tsx | 153 +++ .../src/components/ui/context-menu.tsx | 198 +++ .../skillguard/src/components/ui/dialog.tsx | 120 ++ .../skillguard/src/components/ui/drawer.tsx | 116 ++ .../src/components/ui/dropdown-menu.tsx | 201 +++ .../skillguard/src/components/ui/empty.tsx | 104 ++ .../skillguard/src/components/ui/field.tsx | 244 ++++ .../skillguard/src/components/ui/form.tsx | 176 +++ .../src/components/ui/hover-card.tsx | 27 + .../src/components/ui/input-group.tsx | 168 +++ .../src/components/ui/input-otp.tsx | 69 + .../skillguard/src/components/ui/input.tsx | 22 + .../skillguard/src/components/ui/item.tsx | 193 +++ .../skillguard/src/components/ui/kbd.tsx | 28 + .../skillguard/src/components/ui/label.tsx | 26 + .../skillguard/src/components/ui/menubar.tsx | 254 ++++ .../src/components/ui/navigation-menu.tsx | 128 ++ .../src/components/ui/pagination.tsx | 117 ++ .../skillguard/src/components/ui/popover.tsx | 31 + .../skillguard/src/components/ui/progress.tsx | 28 + .../src/components/ui/radio-group.tsx | 42 + .../src/components/ui/resizable.tsx | 45 + .../src/components/ui/scroll-area.tsx | 46 + .../skillguard/src/components/ui/select.tsx | 159 +++ .../src/components/ui/separator.tsx | 29 + .../skillguard/src/components/ui/sheet.tsx | 140 ++ .../skillguard/src/components/ui/sidebar.tsx | 727 ++++++++++ .../skillguard/src/components/ui/skeleton.tsx | 15 + .../skillguard/src/components/ui/slider.tsx | 26 + .../skillguard/src/components/ui/sonner.tsx | 31 + .../skillguard/src/components/ui/spinner.tsx | 16 + .../skillguard/src/components/ui/switch.tsx | 27 + .../skillguard/src/components/ui/table.tsx | 120 ++ .../skillguard/src/components/ui/tabs.tsx | 53 + .../skillguard/src/components/ui/textarea.tsx | 22 + .../skillguard/src/components/ui/toast.tsx | 127 ++ .../skillguard/src/components/ui/toaster.tsx | 33 + .../src/components/ui/toggle-group.tsx | 61 + .../skillguard/src/components/ui/toggle.tsx | 43 + .../skillguard/src/components/ui/tooltip.tsx | 32 + artifacts/skillguard/src/hooks/use-mobile.tsx | 19 + artifacts/skillguard/src/hooks/use-toast.ts | 191 +++ artifacts/skillguard/src/index.css | 139 ++ artifacts/skillguard/src/lib/format.ts | 7 + artifacts/skillguard/src/lib/utils.ts | 6 + artifacts/skillguard/src/main.tsx | 5 + artifacts/skillguard/src/pages/admin.tsx | 454 +++++++ artifacts/skillguard/src/pages/dashboard.tsx | 146 ++ artifacts/skillguard/src/pages/not-found.tsx | 21 + artifacts/skillguard/src/pages/scan-form.tsx | 169 +++ .../skillguard/src/pages/scan-history.tsx | 134 ++ .../skillguard/src/pages/scan-report.tsx | 339 +++++ artifacts/skillguard/tsconfig.json | 24 + artifacts/skillguard/vite.config.ts | 75 ++ .../src/generated/api.schemas.ts | 347 ++++- lib/api-client-react/src/generated/api.ts | 1181 ++++++++++++++++- lib/api-spec/openapi.yaml | 663 +++++++++ lib/api-zod/src/generated/api.ts | 336 ++++- lib/api-zod/src/generated/types/aiProvider.ts | 21 + .../src/generated/types/aiProviderApiType.ts | 16 + .../src/generated/types/aiProviderInput.ts | 20 + .../generated/types/aiProviderInputApiType.ts | 16 + .../src/generated/types/aiProviderUpdate.ts | 21 + .../types/aiProviderUpdateApiType.ts | 16 + lib/api-zod/src/generated/types/apiError.ts | 11 + lib/api-zod/src/generated/types/axisTotals.ts | 12 + .../src/generated/types/dashboardSummary.ts | 22 + lib/api-zod/src/generated/types/finding.ts | 28 + .../src/generated/types/findingAxis.ts | 15 + .../src/generated/types/findingCounts.ts | 18 + .../src/generated/types/findingDetectedBy.ts | 15 + .../src/generated/types/findingSeverity.ts | 18 + .../src/generated/types/healthStatus.ts | 2 +- lib/api-zod/src/generated/types/index.ts | 40 +- lib/api-zod/src/generated/types/prompt.ts | 15 + .../src/generated/types/promptUpdate.ts | 14 + .../src/generated/types/providerTestResult.ts | 13 + lib/api-zod/src/generated/types/rule.ts | 22 + lib/api-zod/src/generated/types/ruleAxis.ts | 15 + .../src/generated/types/ruleDetectionType.ts | 16 + .../src/generated/types/ruleSeverity.ts | 18 + lib/api-zod/src/generated/types/ruleStat.ts | 15 + .../src/generated/types/ruleStatAxis.ts | 15 + lib/api-zod/src/generated/types/ruleUpdate.ts | 13 + .../src/generated/types/ruleUpdateSeverity.ts | 18 + lib/api-zod/src/generated/types/scan.ts | 26 + lib/api-zod/src/generated/types/scanDetail.ts | 15 + lib/api-zod/src/generated/types/scanFile.ts | 16 + .../src/generated/types/scanFileKind.ts | 16 + lib/api-zod/src/generated/types/scanSource.ts | 16 + lib/api-zod/src/generated/types/scanStatus.ts | 15 + .../src/generated/types/scanVerdict.ts | 16 + .../src/generated/types/severityTotals.ts | 15 + .../src/generated/types/skillScanInput.ts | 34 + .../generated/types/skillScanInputSource.ts | 16 + .../src/generated/types/verdictCounts.ts | 13 + lib/db/src/schema/aiProviders.ts | 23 + lib/db/src/schema/findings.ts | 22 + lib/db/src/schema/index.ts | 26 +- lib/db/src/schema/prompts.ts | 14 + lib/db/src/schema/rules.ts | 16 + lib/db/src/schema/scanFiles.ts | 16 + lib/db/src/schema/scans.ts | 39 + pnpm-lock.yaml | 277 +++- replit.md | 50 +- 147 files changed, 12937 insertions(+), 107 deletions(-) create mode 100644 .agents/memory/MEMORY.md create mode 100644 .agents/memory/lucide-icon-name-collisions.md create mode 100644 artifacts/api-server/src/lib/aiAnalysis.ts create mode 100644 artifacts/api-server/src/lib/ruleCatalog.ts create mode 100644 artifacts/api-server/src/lib/scanEngine.ts create mode 100644 artifacts/api-server/src/lib/seed.ts create mode 100644 artifacts/api-server/src/lib/skillParser.ts create mode 100644 artifacts/api-server/src/routes/dashboard.ts create mode 100644 artifacts/api-server/src/routes/prompts.ts create mode 100644 artifacts/api-server/src/routes/providers.ts create mode 100644 artifacts/api-server/src/routes/rules.ts create mode 100644 artifacts/api-server/src/routes/scans.ts create mode 100644 artifacts/skillguard/.replit-artifact/artifact.toml create mode 100644 artifacts/skillguard/components.json create mode 100644 artifacts/skillguard/index.html create mode 100644 artifacts/skillguard/package.json create mode 100644 artifacts/skillguard/public/favicon.svg create mode 100644 artifacts/skillguard/public/opengraph.jpg create mode 100644 artifacts/skillguard/public/robots.txt create mode 100644 artifacts/skillguard/src/App.tsx create mode 100644 artifacts/skillguard/src/components/layout.tsx create mode 100644 artifacts/skillguard/src/components/ui-helpers.tsx create mode 100644 artifacts/skillguard/src/components/ui/accordion.tsx create mode 100644 artifacts/skillguard/src/components/ui/alert-dialog.tsx create mode 100644 artifacts/skillguard/src/components/ui/alert.tsx create mode 100644 artifacts/skillguard/src/components/ui/aspect-ratio.tsx create mode 100644 artifacts/skillguard/src/components/ui/avatar.tsx create mode 100644 artifacts/skillguard/src/components/ui/badge.tsx create mode 100644 artifacts/skillguard/src/components/ui/breadcrumb.tsx create mode 100644 artifacts/skillguard/src/components/ui/button-group.tsx create mode 100644 artifacts/skillguard/src/components/ui/button.tsx create mode 100644 artifacts/skillguard/src/components/ui/calendar.tsx create mode 100644 artifacts/skillguard/src/components/ui/card.tsx create mode 100644 artifacts/skillguard/src/components/ui/carousel.tsx create mode 100644 artifacts/skillguard/src/components/ui/chart.tsx create mode 100644 artifacts/skillguard/src/components/ui/checkbox.tsx create mode 100644 artifacts/skillguard/src/components/ui/collapsible.tsx create mode 100644 artifacts/skillguard/src/components/ui/command.tsx create mode 100644 artifacts/skillguard/src/components/ui/context-menu.tsx create mode 100644 artifacts/skillguard/src/components/ui/dialog.tsx create mode 100644 artifacts/skillguard/src/components/ui/drawer.tsx create mode 100644 artifacts/skillguard/src/components/ui/dropdown-menu.tsx create mode 100644 artifacts/skillguard/src/components/ui/empty.tsx create mode 100644 artifacts/skillguard/src/components/ui/field.tsx create mode 100644 artifacts/skillguard/src/components/ui/form.tsx create mode 100644 artifacts/skillguard/src/components/ui/hover-card.tsx create mode 100644 artifacts/skillguard/src/components/ui/input-group.tsx create mode 100644 artifacts/skillguard/src/components/ui/input-otp.tsx create mode 100644 artifacts/skillguard/src/components/ui/input.tsx create mode 100644 artifacts/skillguard/src/components/ui/item.tsx create mode 100644 artifacts/skillguard/src/components/ui/kbd.tsx create mode 100644 artifacts/skillguard/src/components/ui/label.tsx create mode 100644 artifacts/skillguard/src/components/ui/menubar.tsx create mode 100644 artifacts/skillguard/src/components/ui/navigation-menu.tsx create mode 100644 artifacts/skillguard/src/components/ui/pagination.tsx create mode 100644 artifacts/skillguard/src/components/ui/popover.tsx create mode 100644 artifacts/skillguard/src/components/ui/progress.tsx create mode 100644 artifacts/skillguard/src/components/ui/radio-group.tsx create mode 100644 artifacts/skillguard/src/components/ui/resizable.tsx create mode 100644 artifacts/skillguard/src/components/ui/scroll-area.tsx create mode 100644 artifacts/skillguard/src/components/ui/select.tsx create mode 100644 artifacts/skillguard/src/components/ui/separator.tsx create mode 100644 artifacts/skillguard/src/components/ui/sheet.tsx create mode 100644 artifacts/skillguard/src/components/ui/sidebar.tsx create mode 100644 artifacts/skillguard/src/components/ui/skeleton.tsx create mode 100644 artifacts/skillguard/src/components/ui/slider.tsx create mode 100644 artifacts/skillguard/src/components/ui/sonner.tsx create mode 100644 artifacts/skillguard/src/components/ui/spinner.tsx create mode 100644 artifacts/skillguard/src/components/ui/switch.tsx create mode 100644 artifacts/skillguard/src/components/ui/table.tsx create mode 100644 artifacts/skillguard/src/components/ui/tabs.tsx create mode 100644 artifacts/skillguard/src/components/ui/textarea.tsx create mode 100644 artifacts/skillguard/src/components/ui/toast.tsx create mode 100644 artifacts/skillguard/src/components/ui/toaster.tsx create mode 100644 artifacts/skillguard/src/components/ui/toggle-group.tsx create mode 100644 artifacts/skillguard/src/components/ui/toggle.tsx create mode 100644 artifacts/skillguard/src/components/ui/tooltip.tsx create mode 100644 artifacts/skillguard/src/hooks/use-mobile.tsx create mode 100644 artifacts/skillguard/src/hooks/use-toast.ts create mode 100644 artifacts/skillguard/src/index.css create mode 100644 artifacts/skillguard/src/lib/format.ts create mode 100644 artifacts/skillguard/src/lib/utils.ts create mode 100644 artifacts/skillguard/src/main.tsx create mode 100644 artifacts/skillguard/src/pages/admin.tsx create mode 100644 artifacts/skillguard/src/pages/dashboard.tsx create mode 100644 artifacts/skillguard/src/pages/not-found.tsx create mode 100644 artifacts/skillguard/src/pages/scan-form.tsx create mode 100644 artifacts/skillguard/src/pages/scan-history.tsx create mode 100644 artifacts/skillguard/src/pages/scan-report.tsx create mode 100644 artifacts/skillguard/tsconfig.json create mode 100644 artifacts/skillguard/vite.config.ts create mode 100644 lib/api-zod/src/generated/types/aiProvider.ts create mode 100644 lib/api-zod/src/generated/types/aiProviderApiType.ts create mode 100644 lib/api-zod/src/generated/types/aiProviderInput.ts create mode 100644 lib/api-zod/src/generated/types/aiProviderInputApiType.ts create mode 100644 lib/api-zod/src/generated/types/aiProviderUpdate.ts create mode 100644 lib/api-zod/src/generated/types/aiProviderUpdateApiType.ts create mode 100644 lib/api-zod/src/generated/types/apiError.ts create mode 100644 lib/api-zod/src/generated/types/axisTotals.ts create mode 100644 lib/api-zod/src/generated/types/dashboardSummary.ts create mode 100644 lib/api-zod/src/generated/types/finding.ts create mode 100644 lib/api-zod/src/generated/types/findingAxis.ts create mode 100644 lib/api-zod/src/generated/types/findingCounts.ts create mode 100644 lib/api-zod/src/generated/types/findingDetectedBy.ts create mode 100644 lib/api-zod/src/generated/types/findingSeverity.ts create mode 100644 lib/api-zod/src/generated/types/prompt.ts create mode 100644 lib/api-zod/src/generated/types/promptUpdate.ts create mode 100644 lib/api-zod/src/generated/types/providerTestResult.ts create mode 100644 lib/api-zod/src/generated/types/rule.ts create mode 100644 lib/api-zod/src/generated/types/ruleAxis.ts create mode 100644 lib/api-zod/src/generated/types/ruleDetectionType.ts create mode 100644 lib/api-zod/src/generated/types/ruleSeverity.ts create mode 100644 lib/api-zod/src/generated/types/ruleStat.ts create mode 100644 lib/api-zod/src/generated/types/ruleStatAxis.ts create mode 100644 lib/api-zod/src/generated/types/ruleUpdate.ts create mode 100644 lib/api-zod/src/generated/types/ruleUpdateSeverity.ts create mode 100644 lib/api-zod/src/generated/types/scan.ts create mode 100644 lib/api-zod/src/generated/types/scanDetail.ts create mode 100644 lib/api-zod/src/generated/types/scanFile.ts create mode 100644 lib/api-zod/src/generated/types/scanFileKind.ts create mode 100644 lib/api-zod/src/generated/types/scanSource.ts create mode 100644 lib/api-zod/src/generated/types/scanStatus.ts create mode 100644 lib/api-zod/src/generated/types/scanVerdict.ts create mode 100644 lib/api-zod/src/generated/types/severityTotals.ts create mode 100644 lib/api-zod/src/generated/types/skillScanInput.ts create mode 100644 lib/api-zod/src/generated/types/skillScanInputSource.ts create mode 100644 lib/api-zod/src/generated/types/verdictCounts.ts create mode 100644 lib/db/src/schema/aiProviders.ts create mode 100644 lib/db/src/schema/findings.ts create mode 100644 lib/db/src/schema/prompts.ts create mode 100644 lib/db/src/schema/rules.ts create mode 100644 lib/db/src/schema/scanFiles.ts create mode 100644 lib/db/src/schema/scans.ts diff --git a/.agents/memory/MEMORY.md b/.agents/memory/MEMORY.md new file mode 100644 index 0000000..55b5f35 --- /dev/null +++ b/.agents/memory/MEMORY.md @@ -0,0 +1 @@ +- [lucide-react icon name collisions](lucide-icon-name-collisions.md) — `Badge`/`Activity` from lucide collide with shadcn/ui Badge and React 19 Activity; import Badge from ui, Activity from lucide. diff --git a/.agents/memory/lucide-icon-name-collisions.md b/.agents/memory/lucide-icon-name-collisions.md new file mode 100644 index 0000000..ee99d99 --- /dev/null +++ b/.agents/memory/lucide-icon-name-collisions.md @@ -0,0 +1,10 @@ +--- +name: lucide-react icon name collisions +description: lucide-react exports icons whose names collide with UI components and React 19 built-ins, causing silent JSX type errors +--- + +When importing from `lucide-react`, some icon names collide with other symbols and the wrong one silently wins, producing confusing JSX prop type errors (e.g. "variant does not exist on LucideProps" or "className does not exist on ActivityProps"). + +**Why:** `lucide-react` exports `Badge` (collides with shadcn/ui `Badge`) and `Activity` (collides with React 19's built-in `Activity` component). If `Badge`/`Activity` is imported from `lucide-react` alongside, or the UI component is NOT imported, TS resolves the JSX tag to the wrong type. `` then fails because the lucide icon has no `variant`, and `` fails against React's `ActivityProps`. + +**How to apply:** Import `Badge` from `@/components/ui/badge`, never from `lucide-react`. Import `Activity` from `lucide-react` explicitly (not from `react`). When a JSX element reports props missing that clearly belong to a different component, check the import source for a name collision first. diff --git a/.replit b/.replit index df803ae..41a5423 100644 --- a/.replit +++ b/.replit @@ -26,3 +26,7 @@ externalPort = 80 [[ports]] localPort = 8081 externalPort = 8081 + +[[ports]] +localPort = 20892 +externalPort = 3000 diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json index 6916f27..45518dc 100644 --- a/artifacts/api-server/package.json +++ b/artifacts/api-server/package.json @@ -16,6 +16,7 @@ "cors": "^2.8.6", "drizzle-orm": "catalog:", "express": "^5.2.1", + "fflate": "^0.8.3", "pino": "^9.14.0", "pino-http": "^10.5.0" }, diff --git a/artifacts/api-server/src/app.ts b/artifacts/api-server/src/app.ts index f32f71e..41eae81 100644 --- a/artifacts/api-server/src/app.ts +++ b/artifacts/api-server/src/app.ts @@ -26,8 +26,8 @@ app.use( }), ); app.use(cors()); -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); +app.use(express.json({ limit: "25mb" })); +app.use(express.urlencoded({ extended: true, limit: "25mb" })); app.use("/api", router); diff --git a/artifacts/api-server/src/index.ts b/artifacts/api-server/src/index.ts index b1f024d..d8f5c74 100644 --- a/artifacts/api-server/src/index.ts +++ b/artifacts/api-server/src/index.ts @@ -1,5 +1,6 @@ import app from "./app"; import { logger } from "./lib/logger"; +import { seedDefaults } from "./lib/seed"; const rawPort = process.env["PORT"]; @@ -22,4 +23,5 @@ app.listen(port, (err) => { } logger.info({ port }, "Server listening"); + void seedDefaults(); }); diff --git a/artifacts/api-server/src/lib/aiAnalysis.ts b/artifacts/api-server/src/lib/aiAnalysis.ts new file mode 100644 index 0000000..f355afd --- /dev/null +++ b/artifacts/api-server/src/lib/aiAnalysis.ts @@ -0,0 +1,190 @@ +import type { AiProvider, Prompt } from "@workspace/db"; +import type { ParsedFile, RawFinding, Severity, Axis } from "./ruleCatalog"; + +const SEVERITIES: Severity[] = ["critical", "high", "medium", "low", "info"]; +const AXES: Axis[] = ["security", "privacy"]; + +export type AiResult = { + findings: RawFinding[]; + error: string | null; +}; + +const FETCH_TIMEOUT_MS = 60000; + +function redactSecrets(text: string, token: string | null | undefined): string { + let out = text; + if (token && token.length >= 4) { + out = out.split(token).join("[REDACTED]"); + } + out = out.replace(/(Bearer\s+)[A-Za-z0-9._\-]+/gi, "$1[REDACTED]"); + out = out.replace(/\bsk-[A-Za-z0-9._\-]{8,}\b/g, "[REDACTED]"); + return out; +} + +async function fetchWithTimeout( + url: string, + init: RequestInit, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +function buildSkillPayload(files: ParsedFile[]): string { + const parts: string[] = []; + let budget = 60000; + for (const f of files) { + if (f.content === "") continue; + const header = `\n===== DATEI: ${f.path} (${f.kind}) =====\n`; + const body = f.content.slice(0, 16000); + if (header.length + body.length > budget) { + parts.push(header + body.slice(0, Math.max(0, budget - header.length))); + break; + } + parts.push(header + body); + budget -= header.length + body.length; + } + return parts.join("\n"); +} + +function coerceFinding(raw: unknown): RawFinding | null { + if (!raw || typeof raw !== "object") return null; + const o = raw as Record; + const axis = AXES.includes(o.axis as Axis) ? (o.axis as Axis) : "security"; + const severity = SEVERITIES.includes(o.severity as Severity) + ? (o.severity as Severity) + : "medium"; + const title = typeof o.title === "string" ? o.title.slice(0, 200) : null; + if (!title) return null; + return { + ruleId: typeof o.ruleId === "string" ? o.ruleId : "AI-FINDING", + axis, + severity, + title, + description: + typeof o.description === "string" ? o.description.slice(0, 2000) : title, + remediation: + typeof o.remediation === "string" ? o.remediation.slice(0, 2000) : null, + file: typeof o.file === "string" ? o.file.slice(0, 400) : null, + line: typeof o.line === "number" ? o.line : null, + snippet: typeof o.snippet === "string" ? o.snippet.slice(0, 400) : null, + detectedBy: "ai", + }; +} + +function extractJson(text: string): unknown { + const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i); + const candidate = fence ? fence[1] : text; + const start = candidate.indexOf("{"); + const end = candidate.lastIndexOf("}"); + if (start === -1 || end === -1 || end <= start) { + throw new Error("Keine JSON-Antwort von der KI erhalten."); + } + return JSON.parse(candidate.slice(start, end + 1)); +} + +async function callOpenAiCompatible( + provider: AiProvider, + system: string, + user: string, +): Promise { + const base = provider.baseUrl.replace(/\/$/, ""); + const url = `${base}/chat/completions`; + const res = await fetchWithTimeout(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${provider.apiToken ?? ""}`, + }, + body: JSON.stringify({ + model: provider.model, + messages: [ + { role: "system", content: system }, + { role: "user", content: user }, + ], + temperature: 0.1, + }), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error( + `HTTP ${res.status}: ${redactSecrets(body.slice(0, 300), provider.apiToken)}`, + ); + } + const data = (await res.json()) as { + choices?: { message?: { content?: string } }[]; + }; + return data.choices?.[0]?.message?.content ?? ""; +} + +async function callAnthropic( + provider: AiProvider, + system: string, + user: string, +): Promise { + const base = provider.baseUrl.replace(/\/$/, ""); + const url = `${base}/messages`; + const res = await fetchWithTimeout(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": provider.apiToken ?? "", + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: provider.model, + max_tokens: 4096, + system, + messages: [{ role: "user", content: user }], + }), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error( + `HTTP ${res.status}: ${redactSecrets(body.slice(0, 300), provider.apiToken)}`, + ); + } + const data = (await res.json()) as { content?: { text?: string }[] }; + return data.content?.[0]?.text ?? ""; +} + +export async function callProvider( + provider: AiProvider, + system: string, + user: string, +): Promise { + if (provider.apiType === "anthropic") { + return callAnthropic(provider, system, user); + } + return callOpenAiCompatible(provider, system, user); +} + +export async function runAiAnalysis( + provider: AiProvider, + prompts: Prompt[], + files: ParsedFile[], +): Promise { + const systemPrompt = prompts.find((p) => p.key === "system")?.content ?? ""; + const analysisPrompt = + prompts.find((p) => p.key === "analysis")?.content ?? ""; + const payload = buildSkillPayload(files); + const user = `${analysisPrompt}\n\nHier ist das zu prüfende Skill:\n${payload}`; + try { + const content = await callProvider(provider, systemPrompt, user); + const parsed = extractJson(content) as { findings?: unknown[] }; + const findingsRaw = Array.isArray(parsed.findings) ? parsed.findings : []; + const findings = findingsRaw + .map(coerceFinding) + .filter((f): f is RawFinding => f !== null); + return { findings, error: null }; + } catch (err) { + return { + findings: [], + error: err instanceof Error ? err.message : "Unbekannter KI-Fehler", + }; + } +} diff --git a/artifacts/api-server/src/lib/ruleCatalog.ts b/artifacts/api-server/src/lib/ruleCatalog.ts new file mode 100644 index 0000000..7f9ebc5 --- /dev/null +++ b/artifacts/api-server/src/lib/ruleCatalog.ts @@ -0,0 +1,440 @@ +export type Severity = "critical" | "high" | "medium" | "low" | "info"; +export type Axis = "security" | "privacy"; +export type FileKind = "instruction" | "script" | "resource"; + +export type ParsedFile = { + path: string; + kind: FileKind; + language: string | null; + content: string; + size: number; +}; + +export type RawFinding = { + ruleId: string; + axis: Axis; + severity: Severity; + title: string; + description: string; + remediation: string | null; + file: string | null; + line: number | null; + snippet: string | null; + detectedBy: "static" | "ai"; +}; + +export type RuleDefinition = { + ruleId: string; + axis: Axis; + category: string; + title: string; + description: string; + defaultSeverity: Severity; + detectionType: "regex" | "heuristic" | "ai"; + remediation: string; + appliesTo: FileKind[]; + patterns?: RegExp[]; + heuristic?: (file: ParsedFile) => { line: number; snippet: string }[]; +}; + +const ALL: FileKind[] = ["instruction", "script", "resource"]; +const TEXT: FileKind[] = ["instruction", "resource"]; + +function scanLines( + file: ParsedFile, + patterns: RegExp[], +): { line: number; snippet: string }[] { + const hits: { line: number; snippet: string }[] = []; + const lines = file.content.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const text = lines[i]; + for (const re of patterns) { + re.lastIndex = 0; + if (re.test(text)) { + hits.push({ line: i + 1, snippet: text.trim().slice(0, 240) }); + break; + } + } + } + return hits; +} + +export const RULE_CATALOG: RuleDefinition[] = [ + { + ruleId: "SEC-REVERSE-SHELL", + axis: "security", + category: "Code-Ausführung", + title: "Reverse-Shell / interaktive Shell", + description: + "Das Skill enthält Muster, die typisch für Reverse-Shells oder den Aufbau interaktiver Shell-Verbindungen zu einem entfernten Host sind.", + defaultSeverity: "critical", + detectionType: "regex", + remediation: + "Entfernen Sie jeglichen Code, der Shell-Verbindungen zu externen Hosts aufbaut. Solche Muster sind in legitimen Skills praktisch nie erforderlich.", + appliesTo: ALL, + patterns: [ + /\/dev\/(tcp|udp)\//i, + /\bnc\b[^\n]*\s-e\b/i, + /\bncat\b[^\n]*--exec/i, + /\b(bash|sh|zsh)\b\s+-i\b/i, + /socat\b[^\n]*exec/i, + /python[0-9]?\b[^\n]*pty\.spawn/i, + ], + }, + { + ruleId: "SEC-REMOTE-EXEC", + axis: "security", + category: "Code-Ausführung", + title: "Download-und-Ausführen aus dem Netz", + description: + "Inhalte werden aus dem Internet geladen und direkt an einen Interpreter weitergegeben (z. B. curl | bash). Dies ermöglicht das unkontrollierte Ausführen von Fremdcode.", + defaultSeverity: "critical", + detectionType: "regex", + remediation: + "Laden Sie Code niemals direkt in eine Shell. Prüfen und versionieren Sie heruntergeladene Artefakte vor der Ausführung.", + appliesTo: ALL, + patterns: [ + /(curl|wget)\b[^\n|]*\|\s*(sudo\s+)?(bash|sh|zsh|python[0-9]?|node|perl|ruby)\b/i, + /(bash|sh)\b\s*<\(\s*(curl|wget)\b/i, + /(iwr|invoke-webrequest|iex)\b[^\n]*\|\s*iex/i, + ], + }, + { + ruleId: "SEC-DESTRUCTIVE", + axis: "security", + category: "Destruktive Operationen", + title: "Destruktive Datei- oder Datenträgeroperation", + description: + "Es wurden potenziell zerstörerische Befehle erkannt (rekursives Löschen, Überschreiben von Datenträgern, Formatieren, Fork-Bomb).", + defaultSeverity: "critical", + detectionType: "regex", + remediation: + "Beschränken Sie Löschoperationen auf klar abgegrenzte Pfade und vermeiden Sie Operationen auf Root-, Home- oder Geräteebene.", + appliesTo: ALL, + patterns: [ + /rm\s+-[a-z]*r[a-z]*f?\b[^\n]*(\s\/(\s|$)|\s~|\s\$HOME|\s\*)/i, + /dd\s+if=[^\n]*of=\/dev\/(sd|hd|nvme|disk|vd)/i, + /\bmkfs(\.|\b)/i, + /\bshred\b/i, + /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;/, + ], + }, + { + ruleId: "SEC-PRIV-ESC", + axis: "security", + category: "Rechteausweitung", + title: "Rechteausweitung / unsichere Berechtigungen", + description: + "Das Skill versucht, erhöhte Rechte zu erlangen oder setzt unsichere Dateiberechtigungen (sudo, chmod 777, setuid, chown root).", + defaultSeverity: "high", + detectionType: "regex", + remediation: + "Vermeiden Sie sudo und überbreite Berechtigungen. Vergeben Sie nur die minimal notwendigen Rechte.", + appliesTo: ALL, + patterns: [ + /\bsudo\b/i, + /chmod\s+(-[a-z]*\s+)*0?777\b/i, + /chmod\s+(-[a-z]*\s+)*[ugoa]*\+s\b/i, + /chown\s+(-[a-z]*\s+)*root\b/i, + ], + }, + { + ruleId: "SEC-PERSISTENCE", + axis: "security", + category: "Persistenz", + title: "Persistenz-Mechanismus", + description: + "Das Skill richtet möglicherweise dauerhafte Mechanismen ein (Cronjobs, systemd-Dienste, Shell-Profil-Hooks, SSH-Schlüssel).", + defaultSeverity: "high", + detectionType: "regex", + remediation: + "Persistenz sollte nur mit ausdrücklicher Zustimmung erfolgen. Prüfen Sie, ob das Anlegen von Autostart-Einträgen wirklich notwendig ist.", + appliesTo: ALL, + patterns: [ + /\bcrontab\b/i, + /\/etc\/cron/i, + /systemctl\b[^\n]*(enable|start)/i, + />>?\s*~?\/?\.(bashrc|bash_profile|profile|zshrc|zprofile)\b/i, + /authorized_keys\b/i, + /launchctl\b[^\n]*load/i, + ], + }, + { + ruleId: "SEC-OBFUSCATION", + axis: "security", + category: "Verschleierung", + title: "Verschleierter oder dynamisch ausgeführter Code", + description: + "Es wurden Hinweise auf verschleierten Code oder dynamische Ausführung gefunden (base64-Dekodierung mit Ausführung, eval/exec, Hex-Escapes).", + defaultSeverity: "high", + detectionType: "regex", + remediation: + "Vermeiden Sie dynamische Code-Ausführung und Verschleierung. Code sollte im Klartext nachvollziehbar sein.", + appliesTo: ALL, + patterns: [ + /base64\s+(-d|--decode|-D)\b/i, + /echo\b[^\n]*\|\s*base64\s+(-d|--decode)/i, + /\beval\s*[("'`]/i, + /\bexec\s*\(/i, + /xxd\s+-r\b/i, + /(\\x[0-9a-f]{2}){6,}/i, + ], + }, + { + ruleId: "SEC-SUPPLY-CHAIN", + axis: "security", + category: "Lieferkette", + title: "Unsichere Paket- oder Quellinstallation", + description: + "Pakete werden aus nicht vertrauenswürdigen Quellen installiert (direkte URLs, git+-Quellen, externe Apt-Repositories oder Schlüssel).", + defaultSeverity: "medium", + detectionType: "regex", + remediation: + "Installieren Sie Pakete nur aus vertrauenswürdigen, versionierten Quellen und vermeiden Sie Installationen direkt von URLs.", + appliesTo: ALL, + patterns: [ + /(pip|pip3)\s+install\b[^\n]*(https?:\/\/|git\+)/i, + /npm\s+(install|i)\b[^\n]*(https?:\/\/|git\+|github:)/i, + /add-apt-repository\b/i, + /(curl|wget)\b[^\n]*\|\s*(sudo\s+)?apt-key\b/i, + ], + }, + { + ruleId: "SEC-NETWORK", + axis: "security", + category: "Netzwerk", + title: "Ausgehender Netzwerkzugriff", + description: + "Das Skill stellt ausgehende Netzwerkverbindungen her. Dies ist nicht zwingend bösartig, sollte aber bewertet werden.", + defaultSeverity: "low", + detectionType: "regex", + remediation: + "Stellen Sie sicher, dass die kontaktierten Endpunkte erwartet und vertrauenswürdig sind.", + appliesTo: ALL, + patterns: [ + /\b(curl|wget|nc|netcat|telnet)\b/i, + /\b(requests\.(get|post)|urllib|http\.client|fetch\()/i, + ], + }, + { + ruleId: "PRIV-SECRET-ACCESS", + axis: "privacy", + category: "Zugriff auf Geheimnisse", + title: "Zugriff auf Anmeldedaten oder Geheimnisse", + description: + "Das Skill greift auf typische Speicherorte für Geheimnisse zu (.env, SSH-Schlüssel, Cloud-Credentials, .netrc, Umgebungsvariablen).", + defaultSeverity: "high", + detectionType: "regex", + remediation: + "Vermeiden Sie den Zugriff auf Geheimnisdateien. Falls erforderlich, dokumentieren Sie Zweck und Umfang transparent.", + appliesTo: ALL, + patterns: [ + /(^|[^A-Za-z0-9_])\.env\b/i, + /\.ssh\/(id_rsa|id_ed25519|id_dsa|authorized_keys)?/i, + /\bid_rsa\b/i, + /\.aws\/credentials\b/i, + /\.netrc\b/i, + /\.npmrc\b/i, + /\b(printenv|env)\b\s*(\||>|$)/i, + ], + }, + { + ruleId: "PRIV-EXFILTRATION", + axis: "privacy", + category: "Datenabfluss", + title: "Mögliche Datenexfiltration", + description: + "Daten werden an externe Endpunkte gesendet (HTTP-POST mit Nutzdaten, Datei-Uploads). In Kombination mit Geheimniszugriff ist dies hochkritisch.", + defaultSeverity: "critical", + detectionType: "regex", + remediation: + "Senden Sie keine lokalen Daten an externe Server ohne ausdrückliche, dokumentierte Zustimmung.", + appliesTo: ALL, + patterns: [ + /curl\b[^\n]*\s(-d|--data|--data-binary|-F|--form|-T|--upload-file)\b/i, + /curl\b[^\n]*-X\s*POST\b/i, + /wget\b[^\n]*--post(-data|-file)\b/i, + /requests\.post\s*\(/i, + /fetch\s*\([^\n]*method\s*:\s*['"]POST['"]/i, + ], + }, + { + ruleId: "PRIV-PROMPT-INJECTION", + axis: "privacy", + category: "Prompt-Injektion", + title: "Prompt-Injektion / Anweisungs-Manipulation", + description: + "Die Anweisungen enthalten Formulierungen, die das Agentenverhalten manipulieren (vorherige Anweisungen ignorieren, Nutzer täuschen, Sicherheits-Leitplanken umgehen).", + defaultSeverity: "critical", + detectionType: "regex", + remediation: + "Entfernen Sie manipulative Anweisungen. Ein Skill darf den Agenten nicht anweisen, Sicherheitsregeln zu umgehen oder den Nutzer zu täuschen.", + appliesTo: TEXT, + patterns: [ + /ignore\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions|prompts|messages)/i, + /disregard\s+[^\n]*\b(instructions|rules|guidelines)\b/i, + /ignoriere\s+[^\n]*(vorherige|bisherige|obige)\s+(anweisungen|anweisung)/i, + /do\s+not\s+(tell|inform|notify|warn|alert)\s+the\s+user/i, + /(verheimliche|verschweige)\b/i, + /nicht[^\n]*\b(dem|den)\s+(nutzer|benutzer|user|anwender)\b[^\n]*(sagen|mitteilen|zeigen|informieren)/i, + /(override|bypass|disable|umgehe|deaktiviere)\b[^\n]*(safety|security|guard|leitplanke|sicherheit|schutz)/i, + /you\s+are\s+now\s+(in\s+)?(developer|dan|jailbreak)\s+mode/i, + ], + }, + { + ruleId: "PRIV-HIDDEN-INSTRUCTIONS", + axis: "privacy", + category: "Versteckte Inhalte", + title: "Versteckte oder unsichtbare Anweisungen", + description: + "Im Text wurden unsichtbare Unicode-Zeichen oder versteckte Kommentare gefunden, die Anweisungen vor dem Menschen verbergen könnten.", + defaultSeverity: "high", + detectionType: "heuristic", + remediation: + "Entfernen Sie unsichtbare Steuerzeichen und versteckte Kommentare. Alle Anweisungen müssen für Menschen sichtbar sein.", + appliesTo: TEXT, + heuristic: (file) => { + const hits: { line: number; snippet: string }[] = []; + const lines = file.content.split(/\r?\n/); + const invisible = + /[\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF\u00AD]/; + for (let i = 0; i < lines.length; i++) { + if (invisible.test(lines[i])) { + hits.push({ + line: i + 1, + snippet: "Unsichtbares Steuerzeichen in dieser Zeile erkannt.", + }); + } else if (//.test(lines[i])) { + hits.push({ line: i + 1, snippet: lines[i].trim().slice(0, 240) }); + } + } + return hits; + }, + }, + { + ruleId: "PRIV-PII", + axis: "privacy", + category: "Datenschutz / DSGVO", + title: "Erhebung personenbezogener Daten", + description: + "Das Skill verweist auf das Erfassen oder Verarbeiten personenbezogener bzw. sensibler Daten (Passwörter, Kreditkarten, Ausweis, Geburtsdatum).", + defaultSeverity: "medium", + detectionType: "regex", + remediation: + "Erheben Sie personenbezogene Daten nur mit Rechtsgrundlage und dokumentieren Sie Zweck, Umfang und Speicherung gemäß DSGVO.", + appliesTo: ALL, + patterns: [ + /\b(passwort|password|kennwort)\b/i, + /\b(kreditkart|credit\s*card|kreditkarten)\b/i, + /\b(social\s*security|sozialversicherung)\b/i, + /\b(personalausweis|reisepass|ausweisnummer)\b/i, + /\b(geburtsdatum|date\s*of\s*birth)\b/i, + /\b(bankverbindung|iban)\b/i, + ], + }, + { + ruleId: "PRIV-AGENT-TAMPERING", + axis: "privacy", + category: "Systemkompromittierung", + title: "Manipulation von Agent oder anderen Skills", + description: + "Das Skill versucht möglicherweise, den Agenten, dessen Speicher oder andere Skills/Konfigurationen zu verändern oder zu löschen.", + defaultSeverity: "high", + detectionType: "regex", + remediation: + "Ein Skill darf weder den Agenten noch andere Skills, Speicher oder Konfigurationsdateien verändern.", + appliesTo: ALL, + patterns: [ + /(modify|edit|delete|overwrite|change|überschreib|lösch|ändere|bearbeite)[^\n]*\b(SKILL\.md|skill|system\s*prompt|agent|memory|\.agents|konfiguration|config)\b/i, + /rm\b[^\n]*\.(md|json|ya?ml)\b/i, + /\b(\.agents|MEMORY\.md|replit\.md)\b/i, + ], + }, + { + ruleId: "PRIV-OVERREACH", + axis: "privacy", + category: "Berechtigungen", + title: "Übermäßige Berechtigungsanforderung", + description: + "Das Skill fordert sehr weitreichende oder uneingeschränkte Berechtigungen an.", + defaultSeverity: "low", + detectionType: "regex", + remediation: + "Fordern Sie nur die minimal notwendigen Berechtigungen an (Least-Privilege-Prinzip).", + appliesTo: TEXT, + patterns: [ + /\b(full|root|admin|unrestricted|vollständig|uneingeschränkt|voller)\s+(access|zugriff|permission|rechte|berechtigung)/i, + /\ball\s+(files|data|permissions)\b/i, + ], + }, + { + ruleId: "AI-PROMPT-INJECTION", + axis: "privacy", + category: "KI-Analyse", + title: "KI: Verdeckte Prompt-Injektion", + description: + "Semantische KI-Analyse auf verdeckte oder subtile Versuche, das Agentenverhalten zu manipulieren, die von statischen Regeln nicht erfasst werden.", + defaultSeverity: "high", + detectionType: "ai", + remediation: + "Bewerten Sie die von der KI markierten Stellen manuell und entfernen Sie manipulative Inhalte.", + appliesTo: TEXT, + }, + { + ruleId: "AI-MALICIOUS-INTENT", + axis: "security", + category: "KI-Analyse", + title: "KI: Schädliche Absicht im Code", + description: + "Semantische KI-Analyse auf bösartige oder versteckte Funktionalität im Code, die über reine Mustererkennung hinausgeht.", + defaultSeverity: "high", + detectionType: "ai", + remediation: + "Prüfen Sie die markierten Codeabschnitte manuell auf schädliche Absicht.", + appliesTo: ALL, + }, + { + ruleId: "AI-DATA-PRIVACY", + axis: "privacy", + category: "KI-Analyse", + title: "KI: Datenschutzrisiko", + description: + "Semantische KI-Analyse auf Datenschutzrisiken und möglichen Abfluss personenbezogener Daten.", + defaultSeverity: "medium", + detectionType: "ai", + remediation: + "Bewerten Sie die markierten Datenschutzrisiken und stellen Sie DSGVO-Konformität sicher.", + appliesTo: ALL, + }, +]; + +export const STATIC_RULES = RULE_CATALOG.filter( + (r) => r.detectionType !== "ai", +); +export const AI_RULES = RULE_CATALOG.filter((r) => r.detectionType === "ai"); + +export function runStaticRule( + rule: RuleDefinition, + file: ParsedFile, + severity: Severity, +): RawFinding[] { + if (!rule.appliesTo.includes(file.kind)) return []; + let hits: { line: number; snippet: string }[] = []; + if (rule.detectionType === "regex" && rule.patterns) { + hits = scanLines(file, rule.patterns); + } else if (rule.detectionType === "heuristic" && rule.heuristic) { + hits = rule.heuristic(file); + } + return hits.map((h) => ({ + ruleId: rule.ruleId, + axis: rule.axis, + severity, + title: rule.title, + description: rule.description, + remediation: rule.remediation, + file: file.path, + line: h.line, + snippet: h.snippet, + detectedBy: "static" as const, + })); +} diff --git a/artifacts/api-server/src/lib/scanEngine.ts b/artifacts/api-server/src/lib/scanEngine.ts new file mode 100644 index 0000000..f3ebb5f --- /dev/null +++ b/artifacts/api-server/src/lib/scanEngine.ts @@ -0,0 +1,126 @@ +import { db } from "@workspace/db"; +import { + rulesTable, + promptsTable, + aiProvidersTable, + type Prompt, +} from "@workspace/db"; +import { eq } from "drizzle-orm"; +import { + STATIC_RULES, + runStaticRule, + type ParsedFile, + type RawFinding, + type Severity, +} from "./ruleCatalog"; +import type { FindingCounts as DbFindingCounts } from "@workspace/db"; +import { runAiAnalysis } from "./aiAnalysis"; + +const SEVERITY_WEIGHT: Record = { + critical: 50, + high: 18, + medium: 7, + low: 2, + info: 0, +}; + +export type EngineResult = { + findings: RawFinding[]; + counts: DbFindingCounts; + 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"; +} + +export async function analyzeSkill( + files: ParsedFile[], + useAi: boolean, +): 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[] = []; + for (const rule of STATIC_RULES) { + const cfg = ruleConfig.get(rule.ruleId); + if (cfg && !cfg.enabled) continue; + const severity = cfg?.severity ?? rule.defaultSeverity; + for (const file of files) { + findings.push(...runStaticRule(rule, file, severity)); + } + } + + let aiUsed = false; + let aiError: string | null = null; + + if (useAi) { + const aiRulesEnabled = dbRules + .filter((r) => r.detectionType === "ai") + .some((r) => r.enabled); + const [provider] = await db + .select() + .from(aiProvidersTable) + .where(eq(aiProvidersTable.enabled, true)) + .limit(1); + + if (!aiRulesEnabled) { + 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); + aiError = result.error; + if (!result.error) { + aiUsed = true; + findings.push(...result.findings); + } + } + } + + const riskScore = computeScore(findings); + const counts = computeCounts(findings); + const verdict = computeVerdict(findings, riskScore); + + return { findings, counts, riskScore, verdict, aiUsed, aiError }; +} diff --git a/artifacts/api-server/src/lib/seed.ts b/artifacts/api-server/src/lib/seed.ts new file mode 100644 index 0000000..1ede8cc --- /dev/null +++ b/artifacts/api-server/src/lib/seed.ts @@ -0,0 +1,57 @@ +import { db, rulesTable, promptsTable } from "@workspace/db"; +import { RULE_CATALOG } from "./ruleCatalog"; +import { logger } from "./logger"; + +const DEFAULT_PROMPTS = [ + { + key: "system", + name: "System-Anweisung", + content: + "Du bist ein erfahrener Sicherheits- und Datenschutz-Auditor für KI-Agenten-Skills. Ein Skill besteht aus Anweisungsdateien (z. B. SKILL.md), Skripten und Ressourcen. Du prüfst auf zwei Achsen: (a) IT-Sicherheit (security) und (b) Datenschutz/Systemkompromittierung (privacy). Du antwortest ausschließlich mit gültigem JSON, ohne erläuternden Text davor oder danach.", + }, + { + key: "analysis", + name: "Analyse-Anweisung", + content: + 'Analysiere das folgende Skill auf verdeckte oder subtile Risiken, die einer reinen Mustererkennung entgehen: versteckte Prompt-Injektionen, manipulative Anweisungen, Täuschung des Nutzers, schädliche Code-Absichten, Datenabfluss und Datenschutzverstöße (DSGVO). Gib das Ergebnis als JSON in genau diesem Format zurück: {"findings": [{"axis": "security|privacy", "severity": "critical|high|medium|low|info", "title": "kurzer Titel", "description": "Beschreibung des Risikos", "remediation": "Empfehlung", "file": "Dateipfad oder null", "line": Zeilennummer oder null, "snippet": "relevanter Ausschnitt oder null"}]}. Wenn keine Risiken gefunden werden, gib {"findings": []} zurück. Antworte auf Deutsch.', + }, +]; + +export async function seedDefaults(): Promise { + try { + for (const rule of RULE_CATALOG) { + await db + .insert(rulesTable) + .values({ + ruleId: rule.ruleId, + axis: rule.axis, + category: rule.category, + title: rule.title, + description: rule.description, + severity: rule.defaultSeverity, + detectionType: rule.detectionType, + enabled: true, + }) + .onConflictDoUpdate({ + target: rulesTable.ruleId, + set: { + axis: rule.axis, + category: rule.category, + title: rule.title, + description: rule.description, + detectionType: rule.detectionType, + }, + }); + } + + for (const prompt of DEFAULT_PROMPTS) { + await db + .insert(promptsTable) + .values(prompt) + .onConflictDoNothing({ target: promptsTable.key }); + } + logger.info("SkillGuard: Standard-Regeln und Prompts initialisiert."); + } catch (err) { + logger.error({ err }, "SkillGuard: Seeding fehlgeschlagen."); + } +} diff --git a/artifacts/api-server/src/lib/skillParser.ts b/artifacts/api-server/src/lib/skillParser.ts new file mode 100644 index 0000000..8d98376 --- /dev/null +++ b/artifacts/api-server/src/lib/skillParser.ts @@ -0,0 +1,160 @@ +import { unzipSync, strFromU8 } from "fflate"; +import type { FileKind, ParsedFile } from "./ruleCatalog"; + +const LANG_BY_EXT: Record = { + sh: "shell", + bash: "shell", + zsh: "shell", + py: "python", + js: "javascript", + mjs: "javascript", + cjs: "javascript", + ts: "typescript", + rb: "ruby", + pl: "perl", + php: "php", + ps1: "powershell", + go: "go", + rs: "rust", + md: "markdown", + txt: "text", + json: "json", + yaml: "yaml", + yml: "yaml", + toml: "toml", + env: "dotenv", +}; + +const SCRIPT_EXTS = new Set([ + "sh", + "bash", + "zsh", + "py", + "js", + "mjs", + "cjs", + "ts", + "rb", + "pl", + "php", + "ps1", + "go", + "rs", +]); + +const SKIP_DIRS = ["__macosx/", ".git/", "node_modules/"]; + +const MAX_ZIP_FILES = 2000; +const MAX_ZIP_TOTAL_BYTES = 60 * 1024 * 1024; +const MAX_ZIP_FILE_BYTES = 5 * 1024 * 1024; + +function extOf(path: string): string { + const base = path.split("/").pop() ?? path; + const dot = base.lastIndexOf("."); + return dot >= 0 ? base.slice(dot + 1).toLowerCase() : ""; +} + +function classify(path: string): FileKind { + const base = (path.split("/").pop() ?? path).toLowerCase(); + const ext = extOf(path); + if (base === "skill.md") return "instruction"; + if (SCRIPT_EXTS.has(ext)) return "script"; + if (ext === "md" || ext === "txt") return "instruction"; + return "resource"; +} + +function isProbablyBinary(bytes: Uint8Array): boolean { + const len = Math.min(bytes.length, 4000); + let nontext = 0; + for (let i = 0; i < len; i++) { + const b = bytes[i]; + if (b === 0) return true; + if (b < 9 || (b > 13 && b < 32)) nontext++; + } + return len > 0 && nontext / len > 0.3; +} + +export function parseZip(buffer: Buffer): ParsedFile[] { + const files = unzipSync(new Uint8Array(buffer)); + const result: ParsedFile[] = []; + let totalBytes = 0; + let processed = 0; + for (const [rawPath, bytes] of Object.entries(files)) { + const path = rawPath.replace(/\\/g, "/"); + if (path.endsWith("/")) continue; + const lower = path.toLowerCase(); + if (SKIP_DIRS.some((d) => lower.includes(d))) continue; + if (bytes.length === 0) continue; + if (bytes.length > MAX_ZIP_FILE_BYTES) continue; + totalBytes += bytes.length; + if (totalBytes > MAX_ZIP_TOTAL_BYTES) { + throw new Error("ZIP-Archiv ist zu groß (entpackt)."); + } + processed += 1; + if (processed > MAX_ZIP_FILES) { + throw new Error("ZIP-Archiv enthält zu viele Dateien."); + } + if (isProbablyBinary(bytes)) { + result.push({ + path, + kind: "resource", + language: null, + content: "", + size: bytes.length, + }); + continue; + } + result.push({ + path, + kind: classify(path), + language: LANG_BY_EXT[extOf(path)] ?? null, + content: strFromU8(bytes), + size: bytes.length, + }); + } + return result; +} + +export function parseSingleFile(filename: string, buffer: Buffer): ParsedFile { + const path = filename.replace(/\\/g, "/").split("/").pop() ?? filename; + if (isProbablyBinary(new Uint8Array(buffer))) { + return { + path, + kind: "resource", + language: null, + content: "", + size: buffer.length, + }; + } + return { + path, + kind: classify(path), + language: LANG_BY_EXT[extOf(path)] ?? null, + content: buffer.toString("utf-8"), + size: buffer.length, + }; +} + +export function parseText(text: string, name: string): ParsedFile { + return { + path: name || "SKILL.md", + kind: "instruction", + language: "markdown", + content: text, + size: Buffer.byteLength(text, "utf-8"), + }; +} + +export function deriveScanName(files: ParsedFile[], fallback: string): string { + const skillMd = files.find( + (f) => (f.path.split("/").pop() ?? "").toLowerCase() === "skill.md", + ); + if (skillMd) { + const m = skillMd.content.match(/^#\s+(.+)$/m); + if (m) return m[1].trim().slice(0, 120); + const nameMatch = skillMd.content.match(/^name:\s*(.+)$/im); + if (nameMatch) return nameMatch[1].trim().replace(/^["']|["']$/g, "").slice(0, 120); + } + const top = files[0]?.path.split("/")[0]; + return (top || fallback).slice(0, 120); +} diff --git a/artifacts/api-server/src/routes/dashboard.ts b/artifacts/api-server/src/routes/dashboard.ts new file mode 100644 index 0000000..f41ed50 --- /dev/null +++ b/artifacts/api-server/src/routes/dashboard.ts @@ -0,0 +1,73 @@ +import { Router, type IRouter } from "express"; +import { db } from "@workspace/db"; +import { scansTable, findingsTable } from "@workspace/db"; +import { desc } from "drizzle-orm"; +import { GetDashboardResponse } from "@workspace/api-zod"; +import { serializeScan } from "./scans"; + +const router: IRouter = Router(); + +router.get("/dashboard", async (_req, res) => { + const scans = await db + .select() + .from(scansTable) + .orderBy(desc(scansTable.createdAt)); + const findings = await db.select().from(findingsTable); + + const totalScans = scans.length; + const avgRiskScore = + totalScans === 0 + ? 0 + : Math.round( + scans.reduce((s, x) => s + x.riskScore, 0) / totalScans, + ); + + const verdictCounts = { pass: 0, review: 0, block: 0 }; + for (const s of scans) { + if (s.verdict in verdictCounts) { + verdictCounts[s.verdict as keyof typeof verdictCounts] += 1; + } + } + + const severityTotals = { critical: 0, high: 0, medium: 0, low: 0, info: 0 }; + const axisTotals = { security: 0, privacy: 0 }; + const ruleAgg = new Map< + string, + { ruleId: string; title: string; axis: "security" | "privacy"; count: number } + >(); + for (const f of findings) { + if (f.severity in severityTotals) { + severityTotals[f.severity as keyof typeof severityTotals] += 1; + } + if (f.axis === "security" || f.axis === "privacy") { + axisTotals[f.axis] += 1; + } + const existing = ruleAgg.get(f.ruleId); + if (existing) existing.count += 1; + else + ruleAgg.set(f.ruleId, { + ruleId: f.ruleId, + title: f.title, + axis: f.axis as "security" | "privacy", + count: 1, + }); + } + + const topRules = [...ruleAgg.values()] + .sort((a, b) => b.count - a.count) + .slice(0, 8); + + const payload = { + totalScans, + avgRiskScore, + verdictCounts, + severityTotals, + axisTotals, + recentScans: scans.slice(0, 6).map(serializeScan), + topRules, + }; + + res.json(GetDashboardResponse.parse(payload)); +}); + +export default router; diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 5a1f77a..580e827 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -1,8 +1,18 @@ import { Router, type IRouter } from "express"; import healthRouter from "./health"; +import dashboardRouter from "./dashboard"; +import scansRouter from "./scans"; +import providersRouter from "./providers"; +import promptsRouter from "./prompts"; +import rulesRouter from "./rules"; const router: IRouter = Router(); router.use(healthRouter); +router.use(dashboardRouter); +router.use(scansRouter); +router.use(providersRouter); +router.use(promptsRouter); +router.use(rulesRouter); export default router; diff --git a/artifacts/api-server/src/routes/prompts.ts b/artifacts/api-server/src/routes/prompts.ts new file mode 100644 index 0000000..0fb09fe --- /dev/null +++ b/artifacts/api-server/src/routes/prompts.ts @@ -0,0 +1,55 @@ +import { Router, type IRouter } from "express"; +import { db } from "@workspace/db"; +import { promptsTable, type Prompt } from "@workspace/db"; +import { eq } from "drizzle-orm"; +import { + ListPromptsResponse, + UpdatePromptParams, + UpdatePromptBody, + UpdatePromptResponse, +} from "@workspace/api-zod"; + +const router: IRouter = Router(); + +function serializePrompt(p: Prompt) { + return { + id: p.id, + key: p.key, + name: p.name, + content: p.content, + updatedAt: p.updatedAt.toISOString(), + }; +} + +router.get("/prompts", async (_req, res) => { + const rows = await db.select().from(promptsTable).orderBy(promptsTable.id); + res.json(ListPromptsResponse.parse(rows.map(serializePrompt))); +}); + +router.patch("/prompts/:id", async (req, res) => { + const params = UpdatePromptParams.safeParse(req.params); + if (!params.success) return res.status(400).json({ message: "Ungültige ID" }); + const parsed = UpdatePromptBody.safeParse(req.body); + if (!parsed.success) + return res + .status(400) + .json({ message: "Ungültige Eingabe", details: parsed.error.issues }); + const d = parsed.data; + + const update: Partial = { + updatedAt: new Date(), + }; + if (d.name !== undefined) update.name = d.name; + if (d.content !== undefined) update.content = d.content; + + const [updated] = await db + .update(promptsTable) + .set(update) + .where(eq(promptsTable.id, params.data.id)) + .returning(); + if (!updated) + return res.status(404).json({ message: "Prompt nicht gefunden" }); + return res.json(UpdatePromptResponse.parse(serializePrompt(updated))); +}); + +export default router; diff --git a/artifacts/api-server/src/routes/providers.ts b/artifacts/api-server/src/routes/providers.ts new file mode 100644 index 0000000..f795542 --- /dev/null +++ b/artifacts/api-server/src/routes/providers.ts @@ -0,0 +1,144 @@ +import { Router, type IRouter } from "express"; +import { db } from "@workspace/db"; +import { aiProvidersTable, type AiProvider } from "@workspace/db"; +import { eq } from "drizzle-orm"; +import { + ListProvidersResponse, + CreateProviderBody, + UpdateProviderParams, + UpdateProviderBody, + UpdateProviderResponse, + DeleteProviderParams, + TestProviderParams, + TestProviderResponse, +} from "@workspace/api-zod"; +import { callProvider } from "../lib/aiAnalysis"; + +const router: IRouter = Router(); + +function maskToken(token: string | null): string { + if (!token) return ""; + if (token.length <= 8) return "••••"; + return `${token.slice(0, 3)}…${token.slice(-4)}`; +} + +function serializeProvider(p: AiProvider) { + return { + id: p.id, + name: p.name, + apiType: p.apiType, + baseUrl: p.baseUrl, + model: p.model, + enabled: p.enabled, + hasToken: !!p.apiToken, + tokenPreview: maskToken(p.apiToken), + createdAt: p.createdAt.toISOString(), + }; +} + +router.get("/providers", async (_req, res) => { + const rows = await db.select().from(aiProvidersTable).orderBy(aiProvidersTable.id); + res.json(ListProvidersResponse.parse(rows.map(serializeProvider))); +}); + +router.post("/providers", async (req, res) => { + const parsed = CreateProviderBody.safeParse(req.body); + if (!parsed.success) + return res + .status(400) + .json({ message: "Ungültige Eingabe", details: parsed.error.issues }); + const d = parsed.data; + const [created] = await db + .insert(aiProvidersTable) + .values({ + name: d.name, + apiType: d.apiType, + baseUrl: d.baseUrl, + model: d.model, + apiToken: d.apiToken ?? null, + enabled: d.enabled ?? true, + }) + .returning(); + return res + .status(201) + .json(UpdateProviderResponse.parse(serializeProvider(created))); +}); + +router.patch("/providers/:id", async (req, res) => { + const params = UpdateProviderParams.safeParse(req.params); + if (!params.success) return res.status(400).json({ message: "Ungültige ID" }); + const parsed = UpdateProviderBody.safeParse(req.body); + if (!parsed.success) + return res + .status(400) + .json({ message: "Ungültige Eingabe", details: parsed.error.issues }); + const d = parsed.data; + + const update: Partial = {}; + if (d.name !== undefined) update.name = d.name; + if (d.apiType !== undefined) update.apiType = d.apiType; + if (d.baseUrl !== undefined) update.baseUrl = d.baseUrl; + if (d.model !== undefined) update.model = d.model; + if (d.enabled !== undefined) update.enabled = d.enabled; + if (d.apiToken !== undefined && d.apiToken !== "") + update.apiToken = d.apiToken; + + const [updated] = await db + .update(aiProvidersTable) + .set(update) + .where(eq(aiProvidersTable.id, params.data.id)) + .returning(); + if (!updated) + return res.status(404).json({ message: "Provider nicht gefunden" }); + return res.json(UpdateProviderResponse.parse(serializeProvider(updated))); +}); + +router.delete("/providers/:id", async (req, res) => { + const params = DeleteProviderParams.safeParse(req.params); + if (!params.success) return res.status(400).json({ message: "Ungültige ID" }); + await db + .delete(aiProvidersTable) + .where(eq(aiProvidersTable.id, params.data.id)); + return res.status(204).send(); +}); + +router.post("/providers/:id/test", async (req, res) => { + const params = TestProviderParams.safeParse(req.params); + if (!params.success) return res.status(400).json({ message: "Ungültige ID" }); + const [provider] = await db + .select() + .from(aiProvidersTable) + .where(eq(aiProvidersTable.id, params.data.id)); + if (!provider) + return res.status(404).json({ message: "Provider nicht gefunden" }); + if (!provider.apiToken) { + return res.json( + TestProviderResponse.parse({ + ok: false, + message: "Kein API-Token hinterlegt.", + }), + ); + } + try { + const reply = await callProvider( + provider, + "Du bist ein Verbindungstest.", + 'Antworte mit dem einzelnen Wort "OK".', + ); + return res.json( + TestProviderResponse.parse({ + ok: true, + message: `Verbindung erfolgreich. Antwort: ${reply.trim().slice(0, 80) || "(leer)"}`, + }), + ); + } catch (err) { + return res.json( + TestProviderResponse.parse({ + ok: false, + message: err instanceof Error ? err.message : "Verbindung fehlgeschlagen.", + }), + ); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/rules.ts b/artifacts/api-server/src/routes/rules.ts new file mode 100644 index 0000000..d330254 --- /dev/null +++ b/artifacts/api-server/src/routes/rules.ts @@ -0,0 +1,56 @@ +import { Router, type IRouter } from "express"; +import { db } from "@workspace/db"; +import { rulesTable, type Rule } from "@workspace/db"; +import { eq } from "drizzle-orm"; +import { + ListRulesResponse, + UpdateRuleParams, + UpdateRuleBody, + UpdateRuleResponse, +} from "@workspace/api-zod"; + +const router: IRouter = Router(); + +function serializeRule(r: Rule) { + return { + id: r.id, + ruleId: r.ruleId, + axis: r.axis, + category: r.category, + title: r.title, + description: r.description, + severity: r.severity, + detectionType: r.detectionType, + enabled: r.enabled, + }; +} + +router.get("/rules", async (_req, res) => { + const rows = await db.select().from(rulesTable).orderBy(rulesTable.id); + res.json(ListRulesResponse.parse(rows.map(serializeRule))); +}); + +router.patch("/rules/:id", async (req, res) => { + const params = UpdateRuleParams.safeParse(req.params); + if (!params.success) return res.status(400).json({ message: "Ungültige ID" }); + const parsed = UpdateRuleBody.safeParse(req.body); + if (!parsed.success) + return res + .status(400) + .json({ message: "Ungültige Eingabe", details: parsed.error.issues }); + const d = parsed.data; + + const update: Partial = {}; + if (d.severity !== undefined) update.severity = d.severity; + if (d.enabled !== undefined) update.enabled = d.enabled; + + const [updated] = await db + .update(rulesTable) + .set(update) + .where(eq(rulesTable.id, params.data.id)) + .returning(); + if (!updated) return res.status(404).json({ message: "Regel nicht gefunden" }); + return res.json(UpdateRuleResponse.parse(serializeRule(updated))); +}); + +export default router; diff --git a/artifacts/api-server/src/routes/scans.ts b/artifacts/api-server/src/routes/scans.ts new file mode 100644 index 0000000..6627569 --- /dev/null +++ b/artifacts/api-server/src/routes/scans.ts @@ -0,0 +1,226 @@ +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; diff --git a/artifacts/skillguard/.replit-artifact/artifact.toml b/artifacts/skillguard/.replit-artifact/artifact.toml new file mode 100644 index 0000000..44020fe --- /dev/null +++ b/artifacts/skillguard/.replit-artifact/artifact.toml @@ -0,0 +1,31 @@ +kind = "web" +previewPath = "/" +title = "SkillGuard" +version = "1.0.0" +id = "artifacts/skillguard" +router = "path" + +[[integratedSkills]] +name = "react-vite" +version = "1.0.0" + +[[services]] +name = "web" +paths = [ "/" ] +localPort = 20892 + +[services.development] +run = "pnpm --filter @workspace/skillguard run dev" + +[services.production] +build = [ "pnpm", "--filter", "@workspace/skillguard", "run", "build" ] +publicDir = "artifacts/skillguard/dist/public" +serve = "static" + +[[services.production.rewrites]] +from = "/*" +to = "/index.html" + +[services.env] +PORT = "20892" +BASE_PATH = "/" diff --git a/artifacts/skillguard/components.json b/artifacts/skillguard/components.json new file mode 100644 index 0000000..3ff62cf --- /dev/null +++ b/artifacts/skillguard/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} \ No newline at end of file diff --git a/artifacts/skillguard/index.html b/artifacts/skillguard/index.html new file mode 100644 index 0000000..c0e6a3c --- /dev/null +++ b/artifacts/skillguard/index.html @@ -0,0 +1,24 @@ + + + + + + SkillGuard + + + + + + + + + + + + + + +
+ + + diff --git a/artifacts/skillguard/package.json b/artifacts/skillguard/package.json new file mode 100644 index 0000000..182f223 --- /dev/null +++ b/artifacts/skillguard/package.json @@ -0,0 +1,77 @@ +{ + "name": "@workspace/skillguard", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --config vite.config.ts --host 0.0.0.0", + "build": "vite build --config vite.config.ts", + "serve": "vite preview --config vite.config.ts --host 0.0.0.0", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@hookform/resolvers": "^3.10.0", + "@radix-ui/react-accordion": "^1.2.4", + "@radix-ui/react-alert-dialog": "^1.1.7", + "@radix-ui/react-aspect-ratio": "^1.1.3", + "@radix-ui/react-avatar": "^1.1.4", + "@radix-ui/react-checkbox": "^1.1.5", + "@radix-ui/react-collapsible": "^1.1.4", + "@radix-ui/react-context-menu": "^2.2.7", + "@radix-ui/react-dialog": "^1.1.7", + "@radix-ui/react-dropdown-menu": "^2.1.7", + "@radix-ui/react-hover-card": "^1.1.7", + "@radix-ui/react-label": "^2.1.3", + "@radix-ui/react-menubar": "^1.1.7", + "@radix-ui/react-navigation-menu": "^1.2.6", + "@radix-ui/react-popover": "^1.1.7", + "@radix-ui/react-progress": "^1.1.3", + "@radix-ui/react-radio-group": "^1.2.4", + "@radix-ui/react-scroll-area": "^1.2.4", + "@radix-ui/react-select": "^2.1.7", + "@radix-ui/react-separator": "^1.1.3", + "@radix-ui/react-slider": "^1.2.4", + "@radix-ui/react-slot": "^1.2.0", + "@radix-ui/react-switch": "^1.1.4", + "@radix-ui/react-tabs": "^1.1.4", + "@radix-ui/react-toast": "^1.2.7", + "@radix-ui/react-toggle": "^1.1.3", + "@radix-ui/react-toggle-group": "^1.1.3", + "@radix-ui/react-tooltip": "^1.2.0", + "@replit/vite-plugin-cartographer": "catalog:", + "@replit/vite-plugin-dev-banner": "catalog:", + "@replit/vite-plugin-runtime-error-modal": "catalog:", + "@tailwindcss/typography": "^0.5.15", + "@tailwindcss/vite": "catalog:", + "@tanstack/react-query": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "@workspace/api-client-react": "workspace:*", + "class-variance-authority": "catalog:", + "clsx": "catalog:", + "cmdk": "^1.1.1", + "date-fns": "^3.6.0", + "embla-carousel-react": "^8.6.0", + "framer-motion": "catalog:", + "input-otp": "^1.4.2", + "lucide-react": "catalog:", + "next-themes": "^0.4.6", + "react": "catalog:", + "react-day-picker": "^9.11.1", + "react-dom": "catalog:", + "react-hook-form": "^7.55.0", + "react-icons": "^5.4.0", + "react-resizable-panels": "^2.1.7", + "recharts": "^2.15.2", + "sonner": "^2.0.7", + "tailwind-merge": "catalog:", + "tailwindcss": "catalog:", + "tw-animate-css": "^1.4.0", + "vaul": "^1.1.2", + "vite": "catalog:", + "wouter": "^3.3.5", + "zod": "catalog:" + } +} diff --git a/artifacts/skillguard/public/favicon.svg b/artifacts/skillguard/public/favicon.svg new file mode 100644 index 0000000..4373d3c --- /dev/null +++ b/artifacts/skillguard/public/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/artifacts/skillguard/public/opengraph.jpg b/artifacts/skillguard/public/opengraph.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2828bf9a921ee6370ff0599b924e960ea0d456bb GIT binary patch literal 44486 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 z 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 + + + + + + + + + + ); +} + +function App() { + return ( + + + + + + + + + ); +} + +export default App; diff --git a/artifacts/skillguard/src/components/layout.tsx b/artifacts/skillguard/src/components/layout.tsx new file mode 100644 index 0000000..bc4d299 --- /dev/null +++ b/artifacts/skillguard/src/components/layout.tsx @@ -0,0 +1,76 @@ +import { Link, useLocation } from "wouter"; +import { Shield, LayoutDashboard, Search, History, Settings } from "lucide-react"; +import { Sidebar, SidebarContent, SidebarHeader, SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarProvider, SidebarGroup, SidebarGroupContent, SidebarGroupLabel } from "@/components/ui/sidebar"; + +export function AppLayout({ children }: { children: React.ReactNode }) { + const [location] = useLocation(); + + return ( + +
+ + + + SkillGuard + + + + Navigation + + + + + + + Dashboard + + + + + + + + Skill Prüfen + + + + + + + + Verlauf + + + + + + + + + + + + + + + Administration + + + + + + + + + +
+
+
+ {children} +
+
+
+
+
+ ); +} diff --git a/artifacts/skillguard/src/components/ui-helpers.tsx b/artifacts/skillguard/src/components/ui-helpers.tsx new file mode 100644 index 0000000..2e7b773 --- /dev/null +++ b/artifacts/skillguard/src/components/ui-helpers.tsx @@ -0,0 +1,40 @@ +import { Badge } from "@/components/ui/badge"; +import { ShieldCheck, ShieldAlert, Shield, AlertTriangle, Info, AlertCircle, AlertOctagon } from "lucide-react"; + +export function VerdictBadge({ verdict, className }: { verdict: string, className?: string }) { + switch (verdict) { + case "pass": + return Freigabe; + case "review": + return Manuelle Prüfung; + case "block": + return Blockieren; + default: + return {verdict}; + } +} + +export function SeverityBadge({ severity, className }: { severity: string, className?: string }) { + switch (severity) { + case "critical": + return Kritisch; + case "high": + return Hoch; + case "medium": + return Mittel; + case "low": + return Niedrig; + case "info": + return Info; + default: + return {severity}; + } +} + +export function AxisBadge({ axis, className }: { axis: string, className?: string }) { + return axis === "security" ? ( + IT-Sicherheit + ) : ( + Datenschutz + ); +} diff --git a/artifacts/skillguard/src/components/ui/accordion.tsx b/artifacts/skillguard/src/components/ui/accordion.tsx new file mode 100644 index 0000000..e1797c9 --- /dev/null +++ b/artifacts/skillguard/src/components/ui/accordion.tsx @@ -0,0 +1,55 @@ +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/artifacts/skillguard/src/components/ui/alert-dialog.tsx b/artifacts/skillguard/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..fa2b442 --- /dev/null +++ b/artifacts/skillguard/src/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/artifacts/skillguard/src/components/ui/alert.tsx b/artifacts/skillguard/src/components/ui/alert.tsx new file mode 100644 index 0000000..5afd41d --- /dev/null +++ b/artifacts/skillguard/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/artifacts/skillguard/src/components/ui/aspect-ratio.tsx b/artifacts/skillguard/src/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..c4abbf3 --- /dev/null +++ b/artifacts/skillguard/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/artifacts/skillguard/src/components/ui/avatar.tsx b/artifacts/skillguard/src/components/ui/avatar.tsx new file mode 100644 index 0000000..51e507b --- /dev/null +++ b/artifacts/skillguard/src/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/artifacts/skillguard/src/components/ui/badge.tsx b/artifacts/skillguard/src/components/ui/badge.tsx new file mode 100644 index 0000000..3f03665 --- /dev/null +++ b/artifacts/skillguard/src/components/ui/badge.tsx @@ -0,0 +1,43 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + // @replit + // Whitespace-nowrap: Badges should never wrap. + "whitespace-nowrap inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" + + " hover-elevate ", + { + variants: { + variant: { + default: + // @replit shadow-xs instead of shadow, no hover because we use hover-elevate + "border-transparent bg-primary text-primary-foreground shadow-xs", + secondary: + // @replit no hover because we use hover-elevate + "border-transparent bg-secondary text-secondary-foreground", + destructive: + // @replit shadow-xs instead of shadow, no hover because we use hover-elevate + "border-transparent bg-destructive text-destructive-foreground shadow-xs", + // @replit shadow-xs" - use badge outline variable + outline: "text-foreground border [border-color:var(--badge-outline)]", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/artifacts/skillguard/src/components/ui/breadcrumb.tsx b/artifacts/skillguard/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..60e6c96 --- /dev/null +++ b/artifacts/skillguard/src/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>