diff --git a/artifacts/api-server/src/routes/scans.ts b/artifacts/api-server/src/routes/scans.ts index 80dd13c..2f9eee3 100644 --- a/artifacts/api-server/src/routes/scans.ts +++ b/artifacts/api-server/src/routes/scans.ts @@ -8,6 +8,9 @@ import { type ScanFile, type Finding, type ScanRelation, + aiProvidersTable, + promptsTable, + type Prompt, } from "@workspace/db"; import { eq, desc, count } from "drizzle-orm"; import { @@ -28,6 +31,7 @@ import { } from "../lib/skillParser"; import { analyzeSkill, type EngineResult } from "../lib/scanEngine"; import { STATIC_RULES, AI_RULES, type ParsedFile } from "../lib/ruleCatalog"; +import { generateSkillDescription } from "../lib/aiAnalysis"; import { computeFingerprint } from "../lib/skillFingerprint"; import { lineDiff, lineSimilarity } from "../lib/lineDiff"; import { logger } from "../lib/logger"; @@ -732,4 +736,81 @@ router.delete("/scans/:id", async (req, res) => { return res.status(204).send(); }); +// Generate the AI description for an existing scan that has none yet (older +// scans were created before description generation existed). Reuses the same +// generateSkillDescription() helper and the configured provider. A failure must +// never alter the stored scan. +router.post("/scans/:id/description", async (req, res) => { + const params = GetScanParams.safeParse(req.params); + if (!params.success) + return res.status(400).json({ error: "Ungültige ID" }); + + const [scan] = await db + .select() + .from(scansTable) + .where(eq(scansTable.id, params.data.id)); + if (!scan) return res.status(404).json({ error: "Scan nicht gefunden" }); + + const storedFiles = await db + .select() + .from(scanFilesTable) + .where(eq(scanFilesTable.scanId, scan.id)); + + const [provider] = await db + .select() + .from(aiProvidersTable) + .where(eq(aiProvidersTable.enabled, true)) + .limit(1); + + if (!provider) { + return res.status(422).json({ + error: + "Kein aktiver KI-Provider konfiguriert. Bitte im Admin-Bereich einrichten.", + }); + } + if (!provider.apiToken) { + return res.status(422).json({ + error: `Für den Provider "${provider.name}" ist kein API-Token hinterlegt.`, + }); + } + + const prompts: Prompt[] = await db.select().from(promptsTable); + + // Reconstruct ParsedFile inputs from the stored scan files. Binary files have + // no stored content; generateSkillDescription skips empty content anyway. + const files: ParsedFile[] = storedFiles.map((f) => ({ + path: f.path, + kind: f.kind as ParsedFile["kind"], + language: f.language, + content: f.content ?? "", + size: f.size, + hash: f.hash, + isBinary: f.content === null, + })); + + const description = await generateSkillDescription(provider, prompts, files); + if (!description) { + return res.status(422).json({ + error: + "Die Beschreibung konnte nicht erzeugt werden. Bitte Provider-Konfiguration und KI-Prompts prüfen.", + }); + } + + const [updated] = await db + .update(scansTable) + .set({ description }) + .where(eq(scansTable.id, scan.id)) + .returning(); + + const findings = await db + .select() + .from(findingsTable) + .where(eq(findingsTable.scanId, scan.id)) + .orderBy(findingsTable.id); + + return res.json( + GetScanResponse.parse(await buildScanDetail(updated, storedFiles, findings)), + ); +}); + export default router; diff --git a/artifacts/skillguard/src/pages/scan-report.tsx b/artifacts/skillguard/src/pages/scan-report.tsx index 059286d..25aaee8 100644 --- a/artifacts/skillguard/src/pages/scan-report.tsx +++ b/artifacts/skillguard/src/pages/scan-report.tsx @@ -1,11 +1,14 @@ import { useState, useMemo } from "react"; import { useRoute, Link } from "wouter"; +import { useQueryClient } from "@tanstack/react-query"; import { useGetScan, getGetScanQueryKey, useGetScanLineage, getGetScanLineageQueryKey, + useGenerateScanDescription, } from "@workspace/api-client-react"; +import { useToast } from "@/hooks/use-toast"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; @@ -16,7 +19,7 @@ import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; import { VerdictBadge, SeverityBadge, AxisBadge, CheckpointStatusBadge, CHECKPOINT_STATUS_LABELS, RelationBadge } from "@/components/ui-helpers"; import { formatDate } from "@/lib/format"; -import { ShieldQuestion, ShieldAlert, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown, ListChecks, Fingerprint, GitCompare, History, GitCommitVertical, Sparkles } from "lucide-react"; +import { ShieldQuestion, ShieldAlert, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown, ListChecks, Fingerprint, GitCompare, History, GitCommitVertical, Sparkles, Loader2 } from "lucide-react"; import type { ScanDetail } from "@workspace/api-client-react"; export default function ScanReport() { @@ -33,6 +36,27 @@ export default function ScanReport() { const [filterAxis, setFilterAxis] = useState("all"); const [filterSeverity, setFilterSeverity] = useState("all"); + const queryClient = useQueryClient(); + const { toast } = useToast(); + const generateDescription = useGenerateScanDescription({ + mutation: { + onSuccess: (updated) => { + queryClient.setQueryData(getGetScanQueryKey(updated.id), updated); + toast({ title: "Beschreibung erzeugt" }); + }, + onError: (err) => { + const message = + (err as { data?: { error?: string } })?.data?.error ?? + "Die Beschreibung konnte nicht erzeugt werden."; + toast({ + title: "Fehler", + description: message, + variant: "destructive", + }); + }, + }, + }); + const filteredFindings = useMemo(() => { if (!data?.findings) return []; return data.findings.filter(f => { @@ -143,20 +167,39 @@ export default function ScanReport() { )} - {data.description && ( - - - - - Was macht dieser Skill? - - KI-generierte Beschreibung des Zwecks und der Funktionsweise. - - + + + + + Was macht dieser Skill? + + KI-generierte Beschreibung des Zwecks und der Funktionsweise. + + + {data.description ? (

{data.description}

-
-
- )} + ) : ( +
+

+ Für diesen Scan wurde noch keine Beschreibung erzeugt. Sie können sie jetzt + mit dem konfigurierten KI-Provider nachträglich anfordern. +

+ +
+ )} +
+
diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 88713d8..bb09241 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -666,6 +666,76 @@ export const useDeleteScan = , return useMutation(getDeleteScanMutationOptions(options)); } +export const getGenerateScanDescriptionUrl = (id: number,) => { + + + + + return `/api/scans/${id}/description` +} + +/** + * @summary Generate the AI description for an existing scan + */ +export const generateScanDescription = async (id: number, options?: RequestInit): Promise => { + + return customFetch(getGenerateScanDescriptionUrl(id), + { + ...options, + method: 'POST' + + + } +);} + + + + +export const getGenerateScanDescriptionMutationOptions = , + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,{id: number}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{id: number}, TContext> => { + +const mutationKey = ['generateScanDescription']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {id: number}> = (props) => { + const {id} = props ?? {}; + + return generateScanDescription(id,requestOptions) + } + + + + + + + return { mutationFn, ...mutationOptions }} + + export type GenerateScanDescriptionMutationResult = NonNullable>> + + export type GenerateScanDescriptionMutationError = ErrorType + + /** + * @summary Generate the AI description for an existing scan + */ +export const useGenerateScanDescription = , + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,{id: number}, TContext>, request?: SecondParameter} + ): UseMutationResult< + Awaited>, + TError, + {id: number}, + TContext + > => { + return useMutation(getGenerateScanDescriptionMutationOptions(options)); + } + export const getListProvidersUrl = () => { diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 0e4361d..a2a2513 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -195,6 +195,37 @@ paths: "204": description: Deleted + /scans/{id}/description: + post: + operationId: generateScanDescription + tags: [scans] + summary: Generate the AI description for an existing scan + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + "200": + description: Scan report with the newly generated description + content: + application/json: + schema: + $ref: "#/components/schemas/ScanDetail" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + "422": + description: Description could not be generated + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + /providers: get: operationId: listProviders diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 0f66f2a..30639d7 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -277,6 +277,83 @@ export const DeleteScanParams = zod.object({ }) +/** + * @summary Generate the AI description for an existing scan + */ +export const GenerateScanDescriptionParams = zod.object({ + "id": zod.coerce.number() +}) + +export const GenerateScanDescriptionResponse = zod.object({ + "id": zod.number(), + "name": zod.string(), + "description": zod.string().nullish().describe('AI-generated summary of the skill\'s purpose (null when no AI description is available)'), + "source": zod.enum(['zip', 'file', 'text']), + "status": zod.enum(['completed', 'failed']), + "verdict": zod.enum(['pass', 'review', 'block']), + "riskScore": zod.number(), + "fileCount": zod.number(), + "aiUsed": zod.boolean(), + "aiError": zod.string().nullish(), + "findingCounts": zod.object({ + "critical": zod.number(), + "high": zod.number(), + "medium": zod.number(), + "low": zod.number(), + "info": zod.number(), + "security": zod.number(), + "privacy": zod.number(), + "total": zod.number() +}), + "fingerprint": zod.string().describe('Deterministic hash over all files (path + per-file hash)'), + "relation": zod.union([zod.literal('new'),zod.literal('identical'),zod.literal('modified'),zod.literal(null)]).nullable().describe('Relation to previously stored skills'), + "similarity": zod.number().nullable().describe('Content-aware similarity (0-100) to the compared skill (identical files count fully, changed text files use line-level similarity)'), + "comparedScanId": zod.number().nullable().describe('The scan this one was compared against, if any'), + "createdAt": zod.string() +}).and(zod.object({ + "files": zod.array(zod.object({ + "path": zod.string(), + "kind": zod.enum(['instruction', 'script', 'resource']), + "language": zod.string().nullish(), + "size": zod.number(), + "hash": zod.string().describe('SHA-256 hash of the file content'), + "hasContent": zod.boolean().describe('Whether the text content was stored (false for binary files)') +})), + "findings": zod.array(zod.object({ + "id": zod.number(), + "ruleId": zod.string(), + "axis": zod.enum(['security', 'privacy']), + "severity": zod.enum(['critical', 'high', 'medium', 'low', 'info']), + "title": zod.string(), + "description": zod.string(), + "remediation": zod.string().nullish(), + "file": zod.string().nullish(), + "line": zod.number().nullish(), + "snippet": zod.string().nullish(), + "detectedBy": zod.enum(['static', 'ai']) +})), + "checkpoints": zod.array(zod.object({ + "id": zod.string(), + "label": zod.string(), + "category": zod.string(), + "axis": zod.union([zod.literal('security'),zod.literal('privacy'),zod.literal(null)]).nullish(), + "severity": zod.union([zod.literal('critical'),zod.literal('high'),zod.literal('medium'),zod.literal('low'),zod.literal('info'),zod.literal(null)]).nullish(), + "status": zod.enum(['pass', 'flagged', 'skipped', 'error']), + "findingCount": zod.number(), + "scoreDelta": zod.number(), + "detectedBy": zod.union([zod.literal('static'),zod.literal('ai'),zod.literal(null)]).nullish() +}).describe('A single inspection step (Prüfschritt) with its partial assessment (Teilbewertung).')), + "checkCount": zod.number().describe('How often a skill with this exact fingerprint was scanned'), + "comparedScan": zod.union([zod.object({ + "id": zod.number(), + "name": zod.string(), + "verdict": zod.enum(['pass', 'review', 'block']), + "riskScore": zod.number(), + "createdAt": zod.string() +}),zod.null()]) +})) + + /** * @summary List configured AI providers */