diff --git a/.agents/memory/MEMORY.md b/.agents/memory/MEMORY.md index fba198e..5cc2357 100644 --- a/.agents/memory/MEMORY.md +++ b/.agents/memory/MEMORY.md @@ -2,3 +2,4 @@ - [OpenAI gpt-5 temperature](openai-temperature-gpt5.md) — gpt-5* reject `temperature != 1`; omit temperature in OpenAI-compatible clients or AI analysis silently fails. - [NDJSON streaming on Replit](ndjson-streaming-express-replit.md) — use `res.on("close")`+`writableFinished` (NOT `req.on("close")`); persist on disconnect; proxy doesn't buffer; gate fallback to avoid dup rows. - [Skill fingerprint & relation matching](skill-fingerprint-matching.md) — don't put display name in fingerprint path; match modified by file-path Jaccard (hash-Jaccard misses single-file edits), report content-aware similarity. +- [Testing api-server from shell](api-server-local-curl.md) — external `$REPLIT_DEV_DOMAIN/api` curl returns HTTP 000; curl `http://localhost:/api` instead (port from workflow log). diff --git a/.agents/memory/api-server-local-curl.md b/.agents/memory/api-server-local-curl.md new file mode 100644 index 0000000..30c2079 --- /dev/null +++ b/.agents/memory/api-server-local-curl.md @@ -0,0 +1,15 @@ +--- +name: Testing the SkillGuard api-server from the shell +description: How to reach the api-server with curl during development (external proxy fails). +--- + +# Testing the api-server from the shell + +Curling the api-server through the external proxy (`$REPLIT_DEV_DOMAIN/api/...`) +fails during development — it returns HTTP 000 / connection refused (the proxy +routes by the selected artifact + mTLS, so a bare curl does not reach it). + +**How to apply:** curl the api-server directly on its local port instead, e.g. +`http://localhost:8080/api/scans`. Confirm the port from the api-server workflow +log line `Server listening port: ` (it reads `PORT`, defaulting to 8080). +The web artifact's preview, however, reaches the API fine through the proxy. diff --git a/artifacts/api-server/src/routes/scans.ts b/artifacts/api-server/src/routes/scans.ts index 30ff7f9..ddff4de 100644 --- a/artifacts/api-server/src/routes/scans.ts +++ b/artifacts/api-server/src/routes/scans.ts @@ -18,6 +18,7 @@ import { DeleteScanParams, CompareScansParams, CompareScansResponse, + GetScanLineageResponse, } from "@workspace/api-zod"; import { parseZip, @@ -639,6 +640,88 @@ router.get("/scans/:id/compare/:otherId", async (req, res) => { ); }); +router.get("/scans/:id/lineage", 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" }); + + // Load only the columns needed to reconstruct the lineage graph for every + // stored scan, then walk the connected component containing this scan. + const all = await db + .select({ + id: scansTable.id, + name: scansTable.name, + verdict: scansTable.verdict, + riskScore: scansTable.riskScore, + relation: scansTable.relation, + similarity: scansTable.similarity, + comparedScanId: scansTable.comparedScanId, + fingerprint: scansTable.fingerprint, + createdAt: scansTable.createdAt, + }) + .from(scansTable); + + const byId = new Map(all.map((s) => [s.id, s])); + + // Build an undirected graph: scans are linked when one was compared against + // the other (comparedScanId chain) or when they share an identical + // fingerprint. The fingerprint family is the connected component. + const adjacency = new Map>(); + const addEdge = (a: number, b: number) => { + if (!byId.has(a) || !byId.has(b) || a === b) return; + (adjacency.get(a) ?? adjacency.set(a, new Set()).get(a)!).add(b); + (adjacency.get(b) ?? adjacency.set(b, new Set()).get(b)!).add(a); + }; + + const byFingerprint = new Map(); + for (const s of all) { + if (s.comparedScanId != null) addEdge(s.id, s.comparedScanId); + if (s.fingerprint) { + const list = byFingerprint.get(s.fingerprint) ?? []; + list.push(s.id); + byFingerprint.set(s.fingerprint, list); + } + } + for (const ids of byFingerprint.values()) { + for (let i = 1; i < ids.length; i++) addEdge(ids[0], ids[i]); + } + + const family = new Set([scan.id]); + const queue: number[] = [scan.id]; + while (queue.length > 0) { + const cur = queue.shift()!; + for (const next of adjacency.get(cur) ?? []) { + if (!family.has(next)) { + family.add(next); + queue.push(next); + } + } + } + + const entries = Array.from(family) + .map((fid) => byId.get(fid)!) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) + .map((s) => ({ + id: s.id, + name: s.name, + verdict: s.verdict, + riskScore: s.riskScore, + relation: s.relation, + similarity: s.similarity, + comparedScanId: s.comparedScanId, + fingerprint: s.fingerprint, + createdAt: s.createdAt.toISOString(), + })); + + return res.json(GetScanLineageResponse.parse(entries)); +}); + router.delete("/scans/:id", async (req, res) => { const params = DeleteScanParams.safeParse(req.params); if (!params.success) diff --git a/artifacts/skillguard/public/opengraph.jpg b/artifacts/skillguard/public/opengraph.jpg index 38feab2..84f5f15 100644 Binary files a/artifacts/skillguard/public/opengraph.jpg and b/artifacts/skillguard/public/opengraph.jpg differ diff --git a/artifacts/skillguard/src/pages/scan-report.tsx b/artifacts/skillguard/src/pages/scan-report.tsx index 25c33a8..f617e53 100644 --- a/artifacts/skillguard/src/pages/scan-report.tsx +++ b/artifacts/skillguard/src/pages/scan-report.tsx @@ -1,6 +1,11 @@ import { useState, useMemo } from "react"; import { useRoute, Link } from "wouter"; -import { useGetScan, getGetScanQueryKey } from "@workspace/api-client-react"; +import { + useGetScan, + getGetScanQueryKey, + useGetScanLineage, + getGetScanLineageQueryKey, +} from "@workspace/api-client-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; @@ -11,7 +16,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, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown, ListChecks, Fingerprint, GitCompare, History } from "lucide-react"; +import { ShieldQuestion, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown, ListChecks, Fingerprint, GitCompare, History, GitCommitVertical } from "lucide-react"; import type { ScanDetail } from "@workspace/api-client-react"; export default function ScanReport() { @@ -265,6 +270,8 @@ export default function ScanReport() { + + Auffälligkeiten ({data.findings.length}) @@ -469,6 +476,87 @@ export default function ScanReport() { ); } +function VersionTimeline({ scanId }: { scanId: number }) { + const { data, isLoading } = useGetScanLineage(scanId, { + query: { + enabled: Number.isFinite(scanId) && scanId > 0, + queryKey: getGetScanLineageQueryKey(scanId), + }, + }); + + if (isLoading) { + return ; + } + + // Nothing meaningful to show unless this skill has more than one known version. + if (!data || data.length <= 1) return null; + + return ( + + + + Versionsverlauf + + + Alle bekannten Versionen dieses Skills (verknüpft über Fingerprint-Abstammung), neueste zuerst. Wählen Sie eine Version, um den Vergleich anzuzeigen. + + + +
    + {data.map((entry) => { + const isCurrent = entry.id === scanId; + return ( +
  1. + + + +
    +
    +
    + {isCurrent ? ( + {entry.name || `Scan #${entry.id}`} + ) : ( + + {entry.name || `Scan #${entry.id}`} + + )} + + + {entry.relation === "modified" && entry.similarity != null && ( + {entry.similarity}% ähnlich + )} + {isCurrent && ( + Aktuell angezeigt + )} +
    +
    + {formatDate(entry.createdAt)} + · + Risiko {entry.riskScore} / 100 +
    +
    + {!isCurrent && ( + + )} +
    +
  2. + ); + })} +
+
+
+ ); +} + const VERDICT_LABELS: Record = { pass: "Freigabe", review: "Manuelle Prüfung", diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 86dccc8..ff0cf02 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -286,6 +286,52 @@ export type ScanDetail = Scan & ({ comparedScan: ComparedScan | null; }); +export type ScanLineageEntryVerdict = typeof ScanLineageEntryVerdict[keyof typeof ScanLineageEntryVerdict]; + + +export const ScanLineageEntryVerdict = { + pass: 'pass', + review: 'review', + block: 'block', +} as const; + +/** + * Relation of this version to the one it was compared against + * @nullable + */ +export type ScanLineageEntryRelation = typeof ScanLineageEntryRelation[keyof typeof ScanLineageEntryRelation] | null; + + +export const ScanLineageEntryRelation = { + new: 'new', + identical: 'identical', + modified: 'modified', +} as const; + +export interface ScanLineageEntry { + id: number; + name: string; + verdict: ScanLineageEntryVerdict; + riskScore: number; + /** + * Relation of this version to the one it was compared against + * @nullable + */ + relation: ScanLineageEntryRelation; + /** + * Content-aware similarity (0-100) to its compared version + * @nullable + */ + similarity: number | null; + /** + * The prior version this scan was compared against, if any + * @nullable + */ + comparedScanId: number | null; + fingerprint: string; + createdAt: string; +} + export type ScanComparisonSideVerdict = typeof ScanComparisonSideVerdict[keyof typeof ScanComparisonSideVerdict]; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 69a0938..714aded 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -35,6 +35,7 @@ import type { Scan, ScanComparison, ScanDetail, + ScanLineageEntry, SkillScanInput } from './api.schemas'; @@ -438,6 +439,84 @@ export function useCompareScans> +export const getGetScanLineageUrl = (id: number,) => { + + + + + return `/api/scans/${id}/lineage` +} + +/** + * Returns every scan in the same fingerprint lineage as the given scan (linked by an identical fingerprint or by the comparedScanId chain), newest first, so the full version history of a skill can be shown on a timeline without re-scanning. + * @summary Get the version timeline for a skill family + */ +export const getScanLineage = async (id: number, options?: RequestInit): Promise => { + + return customFetch(getGetScanLineageUrl(id), + { + ...options, + method: 'GET' + + + } +);} + + + + + +export const getGetScanLineageQueryKey = (id: number,) => { + return [ + `/api/scans/${id}/lineage` + ] as const; + } + + +export const getGetScanLineageQueryOptions = >, TError = ErrorType>(id: number, options?: { query?:UseQueryOptions>, TError, TData>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetScanLineageQueryKey(id); + + + + const queryFn: QueryFunction>> = ({ signal }) => getScanLineage(id, { signal, ...requestOptions }); + + + + + + return { queryKey, queryFn, enabled: !!(id), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: QueryKey } +} + +export type GetScanLineageQueryResult = NonNullable>> +export type GetScanLineageQueryError = ErrorType + + +/** + * @summary Get the version timeline for a skill family + */ + +export function useGetScanLineage>, TError = ErrorType>( + id: number, options?: { query?:UseQueryOptions>, TError, TData>, request?: SecondParameter} + + ): UseQueryResult & { queryKey: QueryKey } { + + const queryOptions = getGetScanLineageQueryOptions(id,options) + + const query = useQuery(queryOptions) as UseQueryResult & { queryKey: QueryKey }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + + + + + + + export const getGetScanUrl = (id: number,) => { diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index c7eeeb6..3462bfe 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -125,6 +125,38 @@ paths: schema: $ref: "#/components/schemas/ApiError" + /scans/{id}/lineage: + get: + operationId: getScanLineage + tags: [scans] + summary: Get the version timeline for a skill family + description: >- + Returns every scan in the same fingerprint lineage as the given scan + (linked by an identical fingerprint or by the comparedScanId chain), + newest first, so the full version history of a skill can be shown on a + timeline without re-scanning. + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + "200": + description: Version timeline (most recent first) + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ScanLineageEntry" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + /scans/{id}: get: operationId: getScan @@ -598,6 +630,43 @@ components: createdAt: type: string + ScanLineageEntry: + type: object + required: + - id + - name + - verdict + - riskScore + - relation + - similarity + - comparedScanId + - fingerprint + - createdAt + properties: + id: + type: integer + name: + type: string + verdict: + type: string + enum: [pass, review, block] + riskScore: + type: integer + relation: + type: ["string", "null"] + enum: [new, identical, modified, null] + description: Relation of this version to the one it was compared against + similarity: + type: ["integer", "null"] + description: Content-aware similarity (0-100) to its compared version + comparedScanId: + type: ["integer", "null"] + description: The prior version this scan was compared against, if any + fingerprint: + type: string + createdAt: + type: string + ScanComparisonSide: type: object required: [id, name, verdict, riskScore, fileCount, fingerprint, createdAt] diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index aecfec3..860cc52 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -168,6 +168,28 @@ export const CompareScansResponse = zod.object({ }) +/** + * Returns every scan in the same fingerprint lineage as the given scan (linked by an identical fingerprint or by the comparedScanId chain), newest first, so the full version history of a skill can be shown on a timeline without re-scanning. + * @summary Get the version timeline for a skill family + */ +export const GetScanLineageParams = zod.object({ + "id": zod.coerce.number() +}) + +export const GetScanLineageResponseItem = zod.object({ + "id": zod.number(), + "name": zod.string(), + "verdict": zod.enum(['pass', 'review', 'block']), + "riskScore": zod.number(), + "relation": zod.union([zod.literal('new'),zod.literal('identical'),zod.literal('modified'),zod.literal(null)]).nullable().describe('Relation of this version to the one it was compared against'), + "similarity": zod.number().nullable().describe('Content-aware similarity (0-100) to its compared version'), + "comparedScanId": zod.number().nullable().describe('The prior version this scan was compared against, if any'), + "fingerprint": zod.string(), + "createdAt": zod.string() +}) +export const GetScanLineageResponse = zod.array(GetScanLineageResponseItem) + + /** * @summary Get a scan report with findings */ diff --git a/lib/api-zod/src/generated/types/index.ts b/lib/api-zod/src/generated/types/index.ts index 297d4de..160e209 100644 --- a/lib/api-zod/src/generated/types/index.ts +++ b/lib/api-zod/src/generated/types/index.ts @@ -52,6 +52,9 @@ export * from './scanFile'; export * from './scanFileDiff'; export * from './scanFileDiffStatus'; export * from './scanFileKind'; +export * from './scanLineageEntry'; +export * from './scanLineageEntryRelation'; +export * from './scanLineageEntryVerdict'; export * from './scanRelation'; export * from './scanSource'; export * from './scanStatus'; diff --git a/lib/api-zod/src/generated/types/scanLineageEntry.ts b/lib/api-zod/src/generated/types/scanLineageEntry.ts new file mode 100644 index 0000000..74ebf25 --- /dev/null +++ b/lib/api-zod/src/generated/types/scanLineageEntry.ts @@ -0,0 +1,33 @@ +/** + * Generated by orval v8.9.1 🍺 + * Do not edit manually. + * Api + * API specification + * OpenAPI spec version: 0.1.0 + */ +import type { ScanLineageEntryRelation } from './scanLineageEntryRelation'; +import type { ScanLineageEntryVerdict } from './scanLineageEntryVerdict'; + +export interface ScanLineageEntry { + id: number; + name: string; + verdict: ScanLineageEntryVerdict; + riskScore: number; + /** + * Relation of this version to the one it was compared against + * @nullable + */ + relation: ScanLineageEntryRelation; + /** + * Content-aware similarity (0-100) to its compared version + * @nullable + */ + similarity: number | null; + /** + * The prior version this scan was compared against, if any + * @nullable + */ + comparedScanId: number | null; + fingerprint: string; + createdAt: string; +} diff --git a/lib/api-zod/src/generated/types/scanLineageEntryRelation.ts b/lib/api-zod/src/generated/types/scanLineageEntryRelation.ts new file mode 100644 index 0000000..5eb2e06 --- /dev/null +++ b/lib/api-zod/src/generated/types/scanLineageEntryRelation.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v8.9.1 🍺 + * Do not edit manually. + * Api + * API specification + * OpenAPI spec version: 0.1.0 + */ + +/** + * Relation of this version to the one it was compared against + * @nullable + */ +export type ScanLineageEntryRelation = typeof ScanLineageEntryRelation[keyof typeof ScanLineageEntryRelation] | null; + + +export const ScanLineageEntryRelation = { + new: 'new', + identical: 'identical', + modified: 'modified', +} as const; diff --git a/lib/api-zod/src/generated/types/scanLineageEntryVerdict.ts b/lib/api-zod/src/generated/types/scanLineageEntryVerdict.ts new file mode 100644 index 0000000..aa4805c --- /dev/null +++ b/lib/api-zod/src/generated/types/scanLineageEntryVerdict.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v8.9.1 🍺 + * Do not edit manually. + * Api + * API specification + * OpenAPI spec version: 0.1.0 + */ + +export type ScanLineageEntryVerdict = typeof ScanLineageEntryVerdict[keyof typeof ScanLineageEntryVerdict]; + + +export const ScanLineageEntryVerdict = { + pass: 'pass', + review: 'review', + block: 'block', +} as const;