Add Skill-Fingerprint database & report comparison
Each scan gets a deterministic overall fingerprint (SHA-256 over sorted path+fileHash pairs) plus per-file SHA-256 hashes and stored text content (binary: hash+size only). On upload the skill is always re-scanned and classified vs prior scans as new / identical / modified, with a per-fingerprint check counter, a "most similar known skill" link, and a file-level diff view. Deviations from the plan: - Relation matching keys off shared file *paths* (Jaccard over paths, tie-break on hashes), not hash-Jaccard alone, which is always 0 for single-file edits (text paste = one SKILL.md) and would mis-class every edited single-file skill as "new". Similarity is content-aware: identical files = 1.0, changed text files use line-level LCS ratio, added/removed/changed-binary = 0. - parseText no longer uses the display name as the file path (fixed "SKILL.md") so identical pastes with different names are "identical", not "modified". Backend: skillFingerprint.ts, lineDiff.ts (+lineSimilarity), skillParser.ts (per-file hash+isBinary), routes/scans.ts (computeRelation, content similarity, checkCount, comparedScan, GET /scans/:id/compare/:otherId). DB: scans fingerprint/relation/similarity/comparedScanId (+index), scan_files hash/content. API spec + orval codegen regenerated. UI: fingerprint card + compare link on report, relation badges in history, new /vergleich/:id/:otherId page with side-by-side summaries and expandable line diff. German UI, no emojis. Verified end-to-end against the running API and screenshotted both UI pages; test data cleaned up afterward. Code-review fix: relation classification no longer relies on path-Jaccard (every text paste shares path SKILL.md, so unrelated pastes were falsely linked as "modified"). computeRelation now selects the candidate by content-aware similarity and only returns "modified" when similarity >= 40 or a file is byte-identical; otherwise "new". Updated OpenAPI similarity description; removed now-unused jaccard import. Replit-Task-Id: 79a8e472-6635-493c-8995-3233ba7df75c
This commit is contained in:
parent
543fd96afd
commit
ba9788a93c
32 changed files with 1518 additions and 40 deletions
|
|
@ -1,3 +1,4 @@
|
|||
- [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.
|
||||
- [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.
|
||||
|
|
|
|||
37
.agents/memory/skill-fingerprint-matching.md
Normal file
37
.agents/memory/skill-fingerprint-matching.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
name: Skill fingerprint & relation matching
|
||||
description: How SkillGuard decides new/identical/modified between scans, and two traps that break it.
|
||||
---
|
||||
|
||||
# Skill fingerprint & relation matching
|
||||
|
||||
The overall fingerprint is a SHA-256 over sorted `path\u0000fileHash` pairs. Relation
|
||||
detection: exact fingerprint match → `identical`; else best file-tree overlap → `modified`;
|
||||
else `new`.
|
||||
|
||||
## Trap 1 — display name must not leak into the fingerprint
|
||||
Text-pasted skills are parsed into a single file. That file's `path` must be a **stable
|
||||
constant** (`SKILL.md`), NOT the user-supplied scan name. If the name is used as the path,
|
||||
two byte-identical pastes with different names get different fingerprints and are
|
||||
mis-classified as `modified` (sim 100) instead of `identical`.
|
||||
**Why:** the fingerprint is meant to identify content/structure, not the display label.
|
||||
|
||||
## Trap 2 — Jaccard over file *hashes* can't detect single-file modifications
|
||||
For a single-file skill, any content edit changes the one hash completely, so Jaccard over
|
||||
the hash set is 0 → wrongly classified `new`, and the compare/diff view (the whole point of
|
||||
the feature) never links the two versions.
|
||||
**Fix / how to apply:** match candidate scans by Jaccard over file **paths** (tie-break by
|
||||
hash overlap), then report `similarity` as a content-aware score: identical files (same hash)
|
||||
count 1.0, changed text files use line-level LCS ratio (`lineSimilarity` = 2·LCS/(a+b)),
|
||||
added/removed or changed-binary files count 0. This yields a meaningful % for single-file
|
||||
edits (e.g. one added line ≈ 90%) and still reduces to hash-equality for unchanged files.
|
||||
|
||||
## Trap 3 — path overlap alone falsely links unrelated single-file skills
|
||||
Because every text paste uses the constant path `SKILL.md` (Trap 1), path-Jaccard is always 1
|
||||
between any two text skills — so selecting/classifying `modified` by path overlap links totally
|
||||
unrelated pastes (sim could be ~0). **Fix / how to apply:** select the candidate by the
|
||||
content-aware similarity score itself (not path overlap), and only return `modified` when
|
||||
`bestSimilarity >= MODIFIED_SIMILARITY_THRESHOLD` (40) OR at least one file is byte-identical
|
||||
(hash overlap). Otherwise return `new`. Skip scoring candidates with no shared path AND no
|
||||
shared hash (similarity would be 0). **Why:** classification must reflect actual content
|
||||
overlap, not a coincidentally-shared file path.
|
||||
115
artifacts/api-server/src/lib/lineDiff.ts
Normal file
115
artifacts/api-server/src/lib/lineDiff.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
export type DiffLineType = "context" | "add" | "remove";
|
||||
|
||||
export type DiffLine = {
|
||||
type: DiffLineType;
|
||||
text: string;
|
||||
previousLine: number | null;
|
||||
currentLine: number | null;
|
||||
};
|
||||
|
||||
const MAX_DIFF_LINES = 2000;
|
||||
|
||||
/**
|
||||
* Line-based similarity ratio in [0, 1] using the longest common subsequence
|
||||
* of lines: 2 * LCS / (linesA + linesB). 1 means identical, 0 means nothing in
|
||||
* common. Used to give a meaningful "modified" similarity for changed files.
|
||||
*/
|
||||
export function lineSimilarity(previous: string, current: string): number {
|
||||
const a = previous.split(/\r?\n/);
|
||||
const b = current.split(/\r?\n/);
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
if (n === 0 && m === 0) return 1;
|
||||
|
||||
let prev = new Array<number>(m + 1).fill(0);
|
||||
let curr = new Array<number>(m + 1).fill(0);
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
curr[j] =
|
||||
a[i] === b[j] ? prev[j + 1] + 1 : Math.max(prev[j], curr[j + 1]);
|
||||
}
|
||||
[prev, curr] = [curr, prev];
|
||||
}
|
||||
const lcs = prev[0];
|
||||
return (2 * lcs) / (n + m);
|
||||
}
|
||||
|
||||
/**
|
||||
* Line-based diff using a longest-common-subsequence backtrace. Returns null
|
||||
* when either side is too large to diff reasonably (caller should fall back to
|
||||
* a plain "modified" indication).
|
||||
*/
|
||||
export function lineDiff(previous: string, current: string): DiffLine[] | null {
|
||||
const a = previous.split(/\r?\n/);
|
||||
const b = current.split(/\r?\n/);
|
||||
|
||||
if (a.length > MAX_DIFF_LINES || b.length > MAX_DIFF_LINES) return null;
|
||||
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
|
||||
// dp[i][j] = LCS length of a[i:] and b[j:]
|
||||
const dp: number[][] = Array.from({ length: n + 1 }, () =>
|
||||
new Array<number>(m + 1).fill(0),
|
||||
);
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
dp[i][j] =
|
||||
a[i] === b[j]
|
||||
? dp[i + 1][j + 1] + 1
|
||||
: Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
const result: DiffLine[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) {
|
||||
result.push({
|
||||
type: "context",
|
||||
text: a[i],
|
||||
previousLine: i + 1,
|
||||
currentLine: j + 1,
|
||||
});
|
||||
i++;
|
||||
j++;
|
||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
result.push({
|
||||
type: "remove",
|
||||
text: a[i],
|
||||
previousLine: i + 1,
|
||||
currentLine: null,
|
||||
});
|
||||
i++;
|
||||
} else {
|
||||
result.push({
|
||||
type: "add",
|
||||
text: b[j],
|
||||
previousLine: null,
|
||||
currentLine: j + 1,
|
||||
});
|
||||
j++;
|
||||
}
|
||||
}
|
||||
while (i < n) {
|
||||
result.push({
|
||||
type: "remove",
|
||||
text: a[i],
|
||||
previousLine: i + 1,
|
||||
currentLine: null,
|
||||
});
|
||||
i++;
|
||||
}
|
||||
while (j < m) {
|
||||
result.push({
|
||||
type: "add",
|
||||
text: b[j],
|
||||
previousLine: null,
|
||||
currentLine: j + 1,
|
||||
});
|
||||
j++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ export type ParsedFile = {
|
|||
language: string | null;
|
||||
content: string;
|
||||
size: number;
|
||||
hash: string;
|
||||
isBinary: boolean;
|
||||
};
|
||||
|
||||
export type RawFinding = {
|
||||
|
|
|
|||
30
artifacts/api-server/src/lib/skillFingerprint.ts
Normal file
30
artifacts/api-server/src/lib/skillFingerprint.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { createHash } from "node:crypto";
|
||||
|
||||
export function hashBytes(bytes: Uint8Array | Buffer): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
export function hashText(text: string): string {
|
||||
return createHash("sha256").update(Buffer.from(text, "utf-8")).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic overall fingerprint over the sorted pairs of (path, fileHash).
|
||||
* Any change to a file's content (its hash) or its path changes the result.
|
||||
*/
|
||||
export function computeFingerprint(
|
||||
files: { path: string; hash: string }[],
|
||||
): string {
|
||||
const pairs = files
|
||||
.map((f) => `${f.path}\u0000${f.hash}`)
|
||||
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
return createHash("sha256").update(pairs.join("\n")).digest("hex");
|
||||
}
|
||||
|
||||
export function jaccard(a: Set<string>, b: Set<string>): number {
|
||||
if (a.size === 0 && b.size === 0) return 0;
|
||||
let inter = 0;
|
||||
for (const x of a) if (b.has(x)) inter++;
|
||||
const union = a.size + b.size - inter;
|
||||
return union === 0 ? 0 : inter / union;
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { Unzip, UnzipInflate, strFromU8 } from "fflate";
|
||||
import type { FileKind, ParsedFile } from "./ruleCatalog";
|
||||
import { hashBytes } from "./skillFingerprint";
|
||||
|
||||
const LANG_BY_EXT: Record<string, string> = {
|
||||
sh: "shell",
|
||||
|
|
@ -152,6 +153,7 @@ export function parseZip(buffer: Buffer): ParsedFile[] {
|
|||
const bytes = concatChunks(chunks, fileBytes);
|
||||
chunks.length = 0;
|
||||
if (bytes.length === 0) return;
|
||||
const hash = hashBytes(bytes);
|
||||
if (isProbablyBinary(bytes)) {
|
||||
result.push({
|
||||
path,
|
||||
|
|
@ -159,6 +161,8 @@ export function parseZip(buffer: Buffer): ParsedFile[] {
|
|||
language: null,
|
||||
content: "",
|
||||
size: bytes.length,
|
||||
hash,
|
||||
isBinary: true,
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
|
|
@ -167,6 +171,8 @@ export function parseZip(buffer: Buffer): ParsedFile[] {
|
|||
language: LANG_BY_EXT[extOf(path)] ?? null,
|
||||
content: strFromU8(bytes),
|
||||
size: bytes.length,
|
||||
hash,
|
||||
isBinary: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -192,6 +198,7 @@ export function parseZip(buffer: Buffer): ParsedFile[] {
|
|||
|
||||
export function parseSingleFile(filename: string, buffer: Buffer): ParsedFile {
|
||||
const path = filename.replace(/\\/g, "/").split("/").pop() ?? filename;
|
||||
const hash = hashBytes(buffer);
|
||||
if (isProbablyBinary(new Uint8Array(buffer))) {
|
||||
return {
|
||||
path,
|
||||
|
|
@ -199,6 +206,8 @@ export function parseSingleFile(filename: string, buffer: Buffer): ParsedFile {
|
|||
language: null,
|
||||
content: "",
|
||||
size: buffer.length,
|
||||
hash,
|
||||
isBinary: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
|
@ -207,16 +216,20 @@ export function parseSingleFile(filename: string, buffer: Buffer): ParsedFile {
|
|||
language: LANG_BY_EXT[extOf(path)] ?? null,
|
||||
content: buffer.toString("utf-8"),
|
||||
size: buffer.length,
|
||||
hash,
|
||||
isBinary: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseText(text: string, name: string): ParsedFile {
|
||||
export function parseText(text: string): ParsedFile {
|
||||
return {
|
||||
path: name || "SKILL.md",
|
||||
path: "SKILL.md",
|
||||
kind: "instruction",
|
||||
language: "markdown",
|
||||
content: text,
|
||||
size: Buffer.byteLength(text, "utf-8"),
|
||||
hash: hashBytes(Buffer.from(text, "utf-8")),
|
||||
isBinary: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,14 +7,17 @@ import {
|
|||
type Scan,
|
||||
type ScanFile,
|
||||
type Finding,
|
||||
type ScanRelation,
|
||||
} from "@workspace/db";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { eq, desc, count } from "drizzle-orm";
|
||||
import {
|
||||
ListScansResponse,
|
||||
CreateScanBody,
|
||||
GetScanParams,
|
||||
GetScanResponse,
|
||||
DeleteScanParams,
|
||||
CompareScansParams,
|
||||
CompareScansResponse,
|
||||
} from "@workspace/api-zod";
|
||||
import {
|
||||
parseZip,
|
||||
|
|
@ -24,6 +27,8 @@ import {
|
|||
} from "../lib/skillParser";
|
||||
import { analyzeSkill, type EngineResult } from "../lib/scanEngine";
|
||||
import { STATIC_RULES, AI_RULES, type ParsedFile } from "../lib/ruleCatalog";
|
||||
import { computeFingerprint } from "../lib/skillFingerprint";
|
||||
import { lineDiff, lineSimilarity } from "../lib/lineDiff";
|
||||
import { logger } from "../lib/logger";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
|
@ -42,6 +47,10 @@ export function serializeScan(scan: Scan) {
|
|||
aiUsed: scan.aiUsed,
|
||||
aiError: scan.aiError,
|
||||
findingCounts: scan.findingCounts,
|
||||
fingerprint: scan.fingerprint,
|
||||
relation: scan.relation,
|
||||
similarity: scan.similarity,
|
||||
comparedScanId: scan.comparedScanId,
|
||||
createdAt: scan.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
|
@ -52,9 +61,43 @@ function serializeFile(f: ScanFile) {
|
|||
kind: f.kind,
|
||||
language: f.language,
|
||||
size: f.size,
|
||||
hash: f.hash,
|
||||
hasContent: f.content !== null,
|
||||
};
|
||||
}
|
||||
|
||||
type ComparedScan = {
|
||||
id: number;
|
||||
name: string;
|
||||
verdict: string;
|
||||
riskScore: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
async function resolveComparedScan(
|
||||
id: number | null,
|
||||
): Promise<ComparedScan | null> {
|
||||
if (id == null) return null;
|
||||
const [s] = await db.select().from(scansTable).where(eq(scansTable.id, id));
|
||||
if (!s) return null;
|
||||
return {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
verdict: s.verdict,
|
||||
riskScore: s.riskScore,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function countFingerprint(fingerprint: string): Promise<number> {
|
||||
if (!fingerprint) return 1;
|
||||
const [row] = await db
|
||||
.select({ c: count() })
|
||||
.from(scansTable)
|
||||
.where(eq(scansTable.fingerprint, fingerprint));
|
||||
return Number(row?.c ?? 1);
|
||||
}
|
||||
|
||||
function serializeFinding(f: Finding) {
|
||||
return {
|
||||
id: f.id,
|
||||
|
|
@ -75,15 +118,173 @@ function serializeScanDetail(
|
|||
scan: Scan,
|
||||
files: ScanFile[],
|
||||
findings: Finding[],
|
||||
checkCount: number,
|
||||
comparedScan: ComparedScan | null,
|
||||
) {
|
||||
return {
|
||||
...serializeScan(scan),
|
||||
checkpoints: scan.checkpoints ?? [],
|
||||
files: files.map(serializeFile),
|
||||
findings: [...findings].sort((a, b) => a.id - b.id).map(serializeFinding),
|
||||
checkCount,
|
||||
comparedScan,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildScanDetail(
|
||||
scan: Scan,
|
||||
files: ScanFile[],
|
||||
findings: Finding[],
|
||||
) {
|
||||
const [checkCount, comparedScan] = await Promise.all([
|
||||
countFingerprint(scan.fingerprint),
|
||||
resolveComparedScan(scan.comparedScanId),
|
||||
]);
|
||||
return serializeScanDetail(scan, files, findings, checkCount, comparedScan);
|
||||
}
|
||||
|
||||
type RelationInfo = {
|
||||
relation: ScanRelation;
|
||||
similarity: number | null;
|
||||
comparedScanId: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine how the freshly parsed skill relates to the scans already stored.
|
||||
* Exact fingerprint match -> identical; otherwise the most content-similar prior
|
||||
* skill (when it overlaps enough or shares a byte-identical file) -> modified;
|
||||
* nothing meaningfully in common -> new.
|
||||
*/
|
||||
async function computeRelation(
|
||||
fingerprint: string,
|
||||
files: ParsedFile[],
|
||||
): Promise<RelationInfo> {
|
||||
if (fingerprint) {
|
||||
const identical = await db
|
||||
.select({ id: scansTable.id })
|
||||
.from(scansTable)
|
||||
.where(eq(scansTable.fingerprint, fingerprint))
|
||||
.orderBy(desc(scansTable.createdAt))
|
||||
.limit(1);
|
||||
if (identical.length > 0) {
|
||||
return { relation: "identical", similarity: 100, comparedScanId: identical[0].id };
|
||||
}
|
||||
}
|
||||
|
||||
// Group every prior scan's files so we can measure how much of the file tree
|
||||
// overlaps. We match on file *paths* (so single-file skills whose content
|
||||
// changed are still recognised as a modified version of the same skill) and
|
||||
// fall back to hash overlap to disambiguate equally-good path matches.
|
||||
const priorFiles = await db
|
||||
.select({
|
||||
scanId: scanFilesTable.scanId,
|
||||
path: scanFilesTable.path,
|
||||
hash: scanFilesTable.hash,
|
||||
content: scanFilesTable.content,
|
||||
})
|
||||
.from(scanFilesTable);
|
||||
|
||||
const byScan = new Map<number, Map<string, { hash: string; content: string | null }>>();
|
||||
for (const row of priorFiles) {
|
||||
if (!row.path) continue;
|
||||
let map = byScan.get(row.scanId);
|
||||
if (!map) {
|
||||
map = new Map();
|
||||
byScan.set(row.scanId, map);
|
||||
}
|
||||
map.set(row.path, { hash: row.hash, content: row.content });
|
||||
}
|
||||
|
||||
const newPaths = new Set(files.map((f) => f.path));
|
||||
const newHashes = new Set(
|
||||
files.map((f) => f.hash).filter((h): h is string => Boolean(h)),
|
||||
);
|
||||
|
||||
// Score every prior scan by content-aware similarity (not just path overlap).
|
||||
// Path overlap alone is misleading: single-file text skills always share the
|
||||
// path "SKILL.md", so unrelated pastes would otherwise look related. We pick
|
||||
// the most similar prior scan and only call it a modified version when the
|
||||
// content actually overlaps enough OR at least one file is byte-identical.
|
||||
let bestId: number | null = null;
|
||||
let bestSimilarity = -1;
|
||||
let bestHasHashOverlap = false;
|
||||
for (const [scanId, map] of byScan) {
|
||||
const priorHashes = new Set(
|
||||
Array.from(map.values())
|
||||
.map((v) => v.hash)
|
||||
.filter(Boolean),
|
||||
);
|
||||
const sharesPath = Array.from(map.keys()).some((p) => newPaths.has(p));
|
||||
const hashOverlap = Array.from(priorHashes).some((h) => newHashes.has(h));
|
||||
// Nothing in common at all -> cannot be a version of this skill.
|
||||
if (!sharesPath && !hashOverlap) continue;
|
||||
|
||||
const similarity = computeContentSimilarity(files, map);
|
||||
if (
|
||||
similarity > bestSimilarity ||
|
||||
(similarity === bestSimilarity && hashOverlap && !bestHasHashOverlap)
|
||||
) {
|
||||
bestSimilarity = similarity;
|
||||
bestId = scanId;
|
||||
bestHasHashOverlap = hashOverlap;
|
||||
}
|
||||
}
|
||||
|
||||
// Treat as a modified version only with a meaningful content overlap or a
|
||||
// shared byte-identical file; otherwise it is a genuinely new skill that just
|
||||
// happens to reuse a common file path.
|
||||
if (
|
||||
bestId !== null &&
|
||||
(bestHasHashOverlap || bestSimilarity >= MODIFIED_SIMILARITY_THRESHOLD)
|
||||
) {
|
||||
return {
|
||||
relation: "modified",
|
||||
similarity: bestSimilarity,
|
||||
comparedScanId: bestId,
|
||||
};
|
||||
}
|
||||
|
||||
return { relation: "new", similarity: null, comparedScanId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum content similarity (0-100) for a non-identical upload to count as a
|
||||
* modified version of a prior scan rather than a brand-new skill. Keeps
|
||||
* unrelated single-file pastes (which always share the "SKILL.md" path) from
|
||||
* being falsely linked together.
|
||||
*/
|
||||
const MODIFIED_SIMILARITY_THRESHOLD = 40;
|
||||
|
||||
/**
|
||||
* Content-aware similarity (0-100) between the new files and a matched prior
|
||||
* scan. Identical files (same hash) count fully; changed text files use the
|
||||
* line-level similarity; added/removed or changed binary files count as 0.
|
||||
*/
|
||||
function computeContentSimilarity(
|
||||
newFiles: ParsedFile[],
|
||||
prior: Map<string, { hash: string; content: string | null }>,
|
||||
): number {
|
||||
const newByPath = new Map(newFiles.map((f) => [f.path, f]));
|
||||
const paths = new Set<string>([...newByPath.keys(), ...prior.keys()]);
|
||||
if (paths.size === 0) return 0;
|
||||
|
||||
let total = 0;
|
||||
for (const path of paths) {
|
||||
const cur = newByPath.get(path);
|
||||
const prev = prior.get(path);
|
||||
if (!cur || !prev) continue; // added or removed -> 0
|
||||
if (cur.hash && cur.hash === prev.hash) {
|
||||
total += 1;
|
||||
continue;
|
||||
}
|
||||
if (!cur.isBinary && prev.content !== null) {
|
||||
total += lineSimilarity(prev.content, cur.content);
|
||||
}
|
||||
// changed binary -> 0
|
||||
}
|
||||
return Math.round((total / paths.size) * 100);
|
||||
}
|
||||
|
||||
type ParseResult =
|
||||
| { ok: true; files: ParsedFile[] }
|
||||
| { ok: false; status: number; message: string };
|
||||
|
|
@ -107,7 +308,7 @@ function parseScanInput(input: CreateScanInput): ParseResult {
|
|||
} else {
|
||||
if (!input.text || !input.text.trim())
|
||||
return { ok: false, status: 400, message: "Text fehlt." };
|
||||
files = [parseText(input.text, input.name ?? "SKILL.md")];
|
||||
files = [parseText(input.text)];
|
||||
}
|
||||
if (files.length === 0)
|
||||
return {
|
||||
|
|
@ -133,6 +334,14 @@ async function persistScan(
|
|||
files: ParsedFile[],
|
||||
result: EngineResult,
|
||||
): Promise<{ scan: Scan; files: ScanFile[]; findings: Finding[] }> {
|
||||
const fingerprint = computeFingerprint(
|
||||
files.map((f) => ({ path: f.path, hash: f.hash })),
|
||||
);
|
||||
// Determine relation against the existing database BEFORE inserting the new
|
||||
// scan so the comparison excludes this scan itself. The skill is always
|
||||
// re-scanned; identical uploads are stored as duplicates.
|
||||
const relationInfo = await computeRelation(fingerprint, files);
|
||||
|
||||
const [scan] = await db
|
||||
.insert(scansTable)
|
||||
.values({
|
||||
|
|
@ -146,6 +355,10 @@ async function persistScan(
|
|||
aiError: result.aiError,
|
||||
findingCounts: result.counts,
|
||||
checkpoints: result.checkpoints,
|
||||
fingerprint,
|
||||
relation: relationInfo.relation,
|
||||
similarity: relationInfo.similarity,
|
||||
comparedScanId: relationInfo.comparedScanId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
|
@ -160,6 +373,8 @@ async function persistScan(
|
|||
kind: f.kind,
|
||||
language: f.language,
|
||||
size: f.size,
|
||||
hash: f.hash,
|
||||
content: f.isBinary ? null : f.content,
|
||||
})),
|
||||
)
|
||||
.returning();
|
||||
|
|
@ -224,7 +439,7 @@ router.post("/scans", async (req, res) => {
|
|||
|
||||
return res
|
||||
.status(201)
|
||||
.json(GetScanResponse.parse(serializeScanDetail(scan, insertedFiles, findings)));
|
||||
.json(GetScanResponse.parse(await buildScanDetail(scan, insertedFiles, findings)));
|
||||
});
|
||||
|
||||
const STREAM_PACING_MS = 80;
|
||||
|
|
@ -331,7 +546,97 @@ router.get("/scans/:id", async (req, res) => {
|
|||
.where(eq(findingsTable.scanId, scan.id))
|
||||
.orderBy(findingsTable.id);
|
||||
|
||||
return res.json(GetScanResponse.parse(serializeScanDetail(scan, files, findings)));
|
||||
return res.json(GetScanResponse.parse(await buildScanDetail(scan, files, findings)));
|
||||
});
|
||||
|
||||
router.get("/scans/:id/compare/:otherId", async (req, res) => {
|
||||
const params = CompareScansParams.safeParse(req.params);
|
||||
if (!params.success)
|
||||
return res.status(400).json({ message: "Ungültige ID" });
|
||||
|
||||
const { id, otherId } = params.data;
|
||||
|
||||
const [current] = await db
|
||||
.select()
|
||||
.from(scansTable)
|
||||
.where(eq(scansTable.id, id));
|
||||
const [previous] = await db
|
||||
.select()
|
||||
.from(scansTable)
|
||||
.where(eq(scansTable.id, otherId));
|
||||
|
||||
if (!current || !previous)
|
||||
return res.status(404).json({ message: "Scan nicht gefunden" });
|
||||
|
||||
const [currentFiles, previousFiles] = await Promise.all([
|
||||
db.select().from(scanFilesTable).where(eq(scanFilesTable.scanId, id)),
|
||||
db.select().from(scanFilesTable).where(eq(scanFilesTable.scanId, otherId)),
|
||||
]);
|
||||
|
||||
const currentByPath = new Map(currentFiles.map((f) => [f.path, f]));
|
||||
const previousByPath = new Map(previousFiles.map((f) => [f.path, f]));
|
||||
const paths = Array.from(
|
||||
new Set([...currentByPath.keys(), ...previousByPath.keys()]),
|
||||
).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
|
||||
const fileDiffs = paths.map((path) => {
|
||||
const cur = currentByPath.get(path) ?? null;
|
||||
const prev = previousByPath.get(path) ?? null;
|
||||
|
||||
let status: "unchanged" | "modified" | "added" | "removed";
|
||||
if (cur && !prev) status = "added";
|
||||
else if (!cur && prev) status = "removed";
|
||||
else if (cur && prev && cur.hash === prev.hash) status = "unchanged";
|
||||
else status = "modified";
|
||||
|
||||
let diff:
|
||||
| {
|
||||
type: "context" | "add" | "remove";
|
||||
text: string;
|
||||
previousLine: number | null;
|
||||
currentLine: number | null;
|
||||
}[]
|
||||
| null = null;
|
||||
if (
|
||||
status === "modified" &&
|
||||
cur?.content !== null &&
|
||||
cur?.content !== undefined &&
|
||||
prev?.content !== null &&
|
||||
prev?.content !== undefined
|
||||
) {
|
||||
diff = lineDiff(prev.content, cur.content);
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
status,
|
||||
previousHash: prev?.hash ?? null,
|
||||
currentHash: cur?.hash ?? null,
|
||||
previousSize: prev?.size ?? null,
|
||||
currentSize: cur?.size ?? null,
|
||||
previousHasContent: prev ? prev.content !== null : null,
|
||||
currentHasContent: cur ? cur.content !== null : null,
|
||||
lineDiff: diff,
|
||||
};
|
||||
});
|
||||
|
||||
const side = (s: Scan) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
verdict: s.verdict,
|
||||
riskScore: s.riskScore,
|
||||
fileCount: s.fileCount,
|
||||
fingerprint: s.fingerprint,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
});
|
||||
|
||||
return res.json(
|
||||
CompareScansResponse.parse({
|
||||
current: side(current),
|
||||
previous: side(previous),
|
||||
files: fileDiffs,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
router.delete("/scans/:id", async (req, res) => {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import NotFound from "@/pages/not-found";
|
|||
import Dashboard from "@/pages/dashboard";
|
||||
import ScanForm from "@/pages/scan-form";
|
||||
import ScanReport from "@/pages/scan-report";
|
||||
import ScanCompare from "@/pages/scan-compare";
|
||||
import ScanHistory from "@/pages/scan-history";
|
||||
import Admin from "@/pages/admin";
|
||||
|
||||
|
|
@ -20,6 +21,7 @@ function Router() {
|
|||
<Route path="/" component={Dashboard} />
|
||||
<Route path="/pruefen" component={ScanForm} />
|
||||
<Route path="/berichte/:id" component={ScanReport} />
|
||||
<Route path="/vergleich/:id/:otherId" component={ScanCompare} />
|
||||
<Route path="/verlauf" component={ScanHistory} />
|
||||
<Route path="/admin" component={Admin} />
|
||||
<Route component={NotFound} />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { ShieldCheck, ShieldAlert, Shield, AlertTriangle, Info, AlertCircle, AlertOctagon, CheckCircle2, MinusCircle, XCircle } from "lucide-react";
|
||||
import { ShieldCheck, ShieldAlert, Shield, AlertTriangle, Info, AlertCircle, AlertOctagon, CheckCircle2, MinusCircle, XCircle, Sparkles, Copy, GitBranch } from "lucide-react";
|
||||
|
||||
export const CHECKPOINT_STATUS_LABELS: Record<string, string> = {
|
||||
pass: "Unauffällig",
|
||||
|
|
@ -60,3 +60,22 @@ export function AxisBadge({ axis, className }: { axis: string, className?: strin
|
|||
<Badge variant="outline" className={`border-purple-200 text-purple-700 bg-purple-50 dark:bg-purple-900/20 dark:border-purple-800 dark:text-purple-400 ${className}`}>Datenschutz</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export const RELATION_LABELS: Record<string, string> = {
|
||||
new: "Neu",
|
||||
identical: "Identisch",
|
||||
modified: "Verändert",
|
||||
};
|
||||
|
||||
export function RelationBadge({ relation, className }: { relation: string | null | undefined, className?: string }) {
|
||||
switch (relation) {
|
||||
case "new":
|
||||
return <Badge className={`bg-sky-500 hover:bg-sky-600 text-white border-transparent ${className}`}><Sparkles className="w-3 h-3 mr-1"/> Neu</Badge>;
|
||||
case "identical":
|
||||
return <Badge className={`bg-violet-500 hover:bg-violet-600 text-white border-transparent ${className}`}><Copy className="w-3 h-3 mr-1"/> Identisch</Badge>;
|
||||
case "modified":
|
||||
return <Badge className={`bg-amber-500 hover:bg-amber-600 text-white border-transparent ${className}`}><GitBranch className="w-3 h-3 mr-1"/> Verändert</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline" className={className}>Unbekannt</Badge>;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
217
artifacts/skillguard/src/pages/scan-compare.tsx
Normal file
217
artifacts/skillguard/src/pages/scan-compare.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { useState } from "react";
|
||||
import { useRoute, Link } from "wouter";
|
||||
import { useCompareScans, getCompareScansQueryKey } 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";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { VerdictBadge } from "@/components/ui-helpers";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { ShieldQuestion, ArrowLeft, FileCode, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import type { ScanComparisonSide, ScanFileDiff } from "@workspace/api-client-react";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
unchanged: "Unverändert",
|
||||
modified: "Geändert",
|
||||
added: "Neu",
|
||||
removed: "Entfernt",
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
switch (status) {
|
||||
case "unchanged":
|
||||
return <Badge variant="outline" className="text-muted-foreground">Unverändert</Badge>;
|
||||
case "modified":
|
||||
return <Badge className="bg-amber-500 hover:bg-amber-600 text-white border-transparent">Geändert</Badge>;
|
||||
case "added":
|
||||
return <Badge className="bg-emerald-500 hover:bg-emerald-600 text-white border-transparent">Neu</Badge>;
|
||||
case "removed":
|
||||
return <Badge className="bg-rose-500 hover:bg-rose-600 text-white border-transparent">Entfernt</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function SkillSummaryCard({ side, label }: { side: ScanComparisonSide; label: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>{label}</CardDescription>
|
||||
<CardTitle className="text-lg flex items-center gap-2 flex-wrap">
|
||||
<Link href={`/berichte/${side.id}`} className="hover:underline">
|
||||
{side.name || `Scan #${side.id}`}
|
||||
</Link>
|
||||
<VerdictBadge verdict={side.verdict} />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Risiko-Score</span>
|
||||
<span className="font-mono font-bold">{side.riskScore} / 100</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Dateien</span>
|
||||
<span className="font-mono">{side.fileCount}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Erstellt</span>
|
||||
<span>{formatDate(side.createdAt)}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pt-1">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wider">Fingerprint</span>
|
||||
<code className="font-mono text-xs break-all bg-muted rounded px-2 py-1.5" title={side.fingerprint}>
|
||||
{side.fingerprint ? `${side.fingerprint.slice(0, 24)}…` : "-"}
|
||||
</code>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function FileDiffRow({ file }: { file: ScanFileDiff }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const canExpand = file.status === "modified" && file.lineDiff && file.lineDiff.length > 0;
|
||||
|
||||
return (
|
||||
<div className="border-b last:border-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canExpand && setOpen((o) => !o)}
|
||||
className={`w-full flex items-center gap-3 p-3 text-left transition-colors ${canExpand ? "hover:bg-muted/50 cursor-pointer" : "cursor-default"}`}
|
||||
>
|
||||
{canExpand ? (
|
||||
open ? <ChevronDown className="w-4 h-4 shrink-0 text-muted-foreground" /> : <ChevronRight className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<span className="w-4 shrink-0" />
|
||||
)}
|
||||
<FileCode className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
<span className="font-mono text-sm flex-1 break-all">{file.path}</span>
|
||||
{file.status === "modified" && !file.lineDiff && (file.previousHasContent === false || file.currentHasContent === false) && (
|
||||
<Badge variant="outline" className="text-[10px]">binär</Badge>
|
||||
)}
|
||||
<StatusBadge status={file.status} />
|
||||
</button>
|
||||
|
||||
{open && canExpand && (
|
||||
<div className="bg-slate-950 overflow-x-auto">
|
||||
<table className="w-full font-mono text-xs">
|
||||
<tbody>
|
||||
{file.lineDiff!.map((line, i) => {
|
||||
const bg =
|
||||
line.type === "add" ? "bg-emerald-950/60" :
|
||||
line.type === "remove" ? "bg-rose-950/60" : "";
|
||||
const sign = line.type === "add" ? "+" : line.type === "remove" ? "-" : " ";
|
||||
const textColor =
|
||||
line.type === "add" ? "text-emerald-300" :
|
||||
line.type === "remove" ? "text-rose-300" : "text-slate-400";
|
||||
return (
|
||||
<tr key={i} className={bg}>
|
||||
<td className="px-2 py-0.5 text-right text-slate-600 select-none w-12 align-top">{line.previousLine ?? ""}</td>
|
||||
<td className="px-2 py-0.5 text-right text-slate-600 select-none w-12 align-top">{line.currentLine ?? ""}</td>
|
||||
<td className={`px-2 py-0.5 select-none w-4 align-top ${textColor}`}>{sign}</td>
|
||||
<td className={`px-2 py-0.5 whitespace-pre-wrap break-all ${textColor}`}>{line.text || " "}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ScanCompare() {
|
||||
const [, params] = useRoute("/vergleich/:id/:otherId");
|
||||
const id = Number(params?.id);
|
||||
const otherId = Number(params?.otherId);
|
||||
const valid = Number.isFinite(id) && Number.isFinite(otherId) && id > 0 && otherId > 0;
|
||||
|
||||
const { data, isLoading, error } = useCompareScans(id, otherId, {
|
||||
query: {
|
||||
enabled: valid,
|
||||
queryKey: getCompareScansQueryKey(id, otherId),
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading || (!data && !error && valid)) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-10 w-1/3" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Skeleton className="h-56 w-full" />
|
||||
<Skeleton className="h-56 w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data || !valid) {
|
||||
return (
|
||||
<div className="p-8 text-center bg-destructive/10 rounded-lg text-destructive">
|
||||
<ShieldQuestion className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<h2 className="text-xl font-bold">Vergleich nicht möglich</h2>
|
||||
<p>Einer der beiden Scans existiert nicht oder konnte nicht geladen werden.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const counts = data.files.reduce(
|
||||
(acc, f) => {
|
||||
acc[f.status] = (acc[f.status] ?? 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-12">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button asChild variant="ghost" size="sm" className="self-start gap-2 -ml-2">
|
||||
<Link href={`/berichte/${data.current.id}`}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Zurück zum Bericht
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Skill-Vergleich</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Gegenüberstellung des ursprünglich gespeicherten Skills und der aktuell geprüften Variante – inklusive Datei-Status und zeilenweisem Diff.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<SkillSummaryCard side={data.previous} label="Skill 1 – Bekannt (aus der Datenbank)" />
|
||||
<SkillSummaryCard side={data.current} label="Skill 2 – Aktuell geprüft" />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Datei-Vergleich</CardTitle>
|
||||
<CardDescription className="flex flex-wrap gap-2 pt-1">
|
||||
{(["unchanged", "modified", "added", "removed"] as const).map((s) =>
|
||||
counts[s] ? (
|
||||
<span key={s} className="flex items-center gap-1.5">
|
||||
<StatusBadge status={s} />
|
||||
<span className="text-sm">{counts[s]}</span>
|
||||
</span>
|
||||
) : null,
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
{data.files.length === 0 ? (
|
||||
<div className="p-6 text-center text-muted-foreground">Keine Dateien zum Vergleichen.</div>
|
||||
) : (
|
||||
data.files.map((file) => <FileDiffRow key={file.path} file={file} />)
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-3">
|
||||
Geänderte Textdateien lassen sich aufklappen, um den zeilenweisen Unterschied anzuzeigen.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { VerdictBadge } from "@/components/ui-helpers";
|
||||
import { VerdictBadge, RelationBadge } from "@/components/ui-helpers";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { Search, Trash2, ArrowRight } from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
|
@ -65,9 +65,10 @@ export default function ScanHistory() {
|
|||
<div className="flex flex-col sm:flex-row">
|
||||
<Link href={`/berichte/${scan.id}`} className="flex-1 p-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="font-semibold text-lg">{scan.name || `Scan #${scan.id}`}</span>
|
||||
<VerdictBadge verdict={scan.verdict} />
|
||||
{scan.relation && scan.relation !== "new" && <RelationBadge relation={scan.relation} />}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{formatDate(scan.createdAt)}</span>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useMemo } from "react";
|
||||
import { useRoute } from "wouter";
|
||||
import { useRoute, Link } from "wouter";
|
||||
import { useGetScan, getGetScanQueryKey } from "@workspace/api-client-react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -9,9 +9,9 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { VerdictBadge, SeverityBadge, AxisBadge, CheckpointStatusBadge, CHECKPOINT_STATUS_LABELS } from "@/components/ui-helpers";
|
||||
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 } from "lucide-react";
|
||||
import { ShieldQuestion, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown, ListChecks, Fingerprint, GitCompare, History } from "lucide-react";
|
||||
import type { ScanDetail } from "@workspace/api-client-react";
|
||||
|
||||
export default function ScanReport() {
|
||||
|
|
@ -207,6 +207,64 @@ export default function ScanReport() {
|
|||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Fingerprint className="w-5 h-5" /> Skill-Fingerprint
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Eindeutiger Erkennungswert dieses Skills. Identische und veränderte Versionen werden anhand des Fingerprints erkannt.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<RelationBadge relation={data.relation} />
|
||||
{data.relation === "modified" && data.similarity != null && (
|
||||
<Badge variant="outline" className="font-mono">{data.similarity}% ähnlich</Badge>
|
||||
)}
|
||||
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<History className="w-4 h-4" />
|
||||
{data.checkCount === 1
|
||||
? "Erstmals geprüft"
|
||||
: `${data.checkCount}-mal geprüft (gleicher Fingerprint)`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wider">Fingerprint</span>
|
||||
<code className="font-mono text-xs break-all bg-muted rounded px-2 py-1.5 select-all">
|
||||
{data.fingerprint || "-"}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{data.comparedScan && (
|
||||
<div className="rounded-lg border bg-muted/30 p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium">
|
||||
{data.relation === "identical" ? "Identisch zu" : "Ähnlichster bekannter Skill"}
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link href={`/berichte/${data.comparedScan.id}`} className="font-medium text-foreground hover:underline">
|
||||
{data.comparedScan.name || `Scan #${data.comparedScan.id}`}
|
||||
</Link>
|
||||
<VerdictBadge verdict={data.comparedScan.verdict} />
|
||||
<span>·</span>
|
||||
<span>Risiko {data.comparedScan.riskScore} / 100</span>
|
||||
<span>·</span>
|
||||
<span>{formatDate(data.comparedScan.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild variant="outline" className="gap-2 shrink-0">
|
||||
<Link href={`/vergleich/${data.id}/${data.comparedScan.id}`}>
|
||||
<GitCompare className="w-4 h-4" />
|
||||
Vergleich anzeigen
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Tabs defaultValue="findings" className="w-full">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="findings" className="gap-2"><Shield className="w-4 h-4"/> Auffälligkeiten ({data.findings.length})</TabsTrigger>
|
||||
|
|
@ -371,13 +429,14 @@ export default function ScanReport() {
|
|||
<th className="h-10 px-4 text-left font-medium">Pfad</th>
|
||||
<th className="h-10 px-4 text-left font-medium">Typ</th>
|
||||
<th className="h-10 px-4 text-left font-medium">Sprache</th>
|
||||
<th className="h-10 px-4 text-left font-medium">Hash (SHA-256)</th>
|
||||
<th className="h-10 px-4 text-right font-medium">Größe</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.files.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="p-4 text-center text-muted-foreground">Keine Dateien verfügbar.</td>
|
||||
<td colSpan={5} className="p-4 text-center text-muted-foreground">Keine Dateien verfügbar.</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.files.map((file, i) => (
|
||||
|
|
@ -389,6 +448,12 @@ export default function ScanReport() {
|
|||
</Badge>
|
||||
</td>
|
||||
<td className="p-4 text-muted-foreground capitalize">{file.language || "-"}</td>
|
||||
<td className="p-4 font-mono text-xs text-muted-foreground" title={file.hash}>
|
||||
{file.hash ? file.hash.slice(0, 12) : "-"}
|
||||
{!file.hasContent && (
|
||||
<Badge variant="outline" className="ml-2 text-[10px]">binär</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4 text-right font-mono">{file.size} B</td>
|
||||
</tr>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -74,6 +74,19 @@ export const ScanVerdict = {
|
|||
block: 'block',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Relation to previously stored skills
|
||||
* @nullable
|
||||
*/
|
||||
export type ScanRelation = typeof ScanRelation[keyof typeof ScanRelation] | null;
|
||||
|
||||
|
||||
export const ScanRelation = {
|
||||
new: 'new',
|
||||
identical: 'identical',
|
||||
modified: 'modified',
|
||||
} as const;
|
||||
|
||||
export interface FindingCounts {
|
||||
critical: number;
|
||||
high: number;
|
||||
|
|
@ -97,6 +110,23 @@ export interface Scan {
|
|||
/** @nullable */
|
||||
aiError?: string | null;
|
||||
findingCounts: FindingCounts;
|
||||
/** Deterministic hash over all files (path + per-file hash) */
|
||||
fingerprint: string;
|
||||
/**
|
||||
* Relation to previously stored skills
|
||||
* @nullable
|
||||
*/
|
||||
relation: ScanRelation;
|
||||
/**
|
||||
* Content-aware similarity (0-100) to the compared skill (identical files count fully, changed text files use line-level similarity)
|
||||
* @nullable
|
||||
*/
|
||||
similarity: number | null;
|
||||
/**
|
||||
* The scan this one was compared against, if any
|
||||
* @nullable
|
||||
*/
|
||||
comparedScanId: number | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
|
|
@ -179,6 +209,10 @@ export interface ScanFile {
|
|||
/** @nullable */
|
||||
language?: string | null;
|
||||
size: number;
|
||||
/** SHA-256 hash of the file content */
|
||||
hash: string;
|
||||
/** Whether the text content was stored (false for binary files) */
|
||||
hasContent: boolean;
|
||||
}
|
||||
|
||||
export type FindingAxis = typeof FindingAxis[keyof typeof FindingAxis];
|
||||
|
|
@ -226,11 +260,102 @@ export interface Finding {
|
|||
detectedBy: FindingDetectedBy;
|
||||
}
|
||||
|
||||
export type ScanDetail = Scan & {
|
||||
export type ComparedScanVerdict = typeof ComparedScanVerdict[keyof typeof ComparedScanVerdict];
|
||||
|
||||
|
||||
export const ComparedScanVerdict = {
|
||||
pass: 'pass',
|
||||
review: 'review',
|
||||
block: 'block',
|
||||
} as const;
|
||||
|
||||
export interface ComparedScan {
|
||||
id: number;
|
||||
name: string;
|
||||
verdict: ComparedScanVerdict;
|
||||
riskScore: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type ScanDetail = Scan & ({
|
||||
files: ScanFile[];
|
||||
findings: Finding[];
|
||||
checkpoints: ScanCheckpoint[];
|
||||
};
|
||||
/** How often a skill with this exact fingerprint was scanned */
|
||||
checkCount: number;
|
||||
comparedScan: ComparedScan | null;
|
||||
});
|
||||
|
||||
export type ScanComparisonSideVerdict = typeof ScanComparisonSideVerdict[keyof typeof ScanComparisonSideVerdict];
|
||||
|
||||
|
||||
export const ScanComparisonSideVerdict = {
|
||||
pass: 'pass',
|
||||
review: 'review',
|
||||
block: 'block',
|
||||
} as const;
|
||||
|
||||
export interface ScanComparisonSide {
|
||||
id: number;
|
||||
name: string;
|
||||
verdict: ScanComparisonSideVerdict;
|
||||
riskScore: number;
|
||||
fileCount: number;
|
||||
fingerprint: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type DiffLineType = typeof DiffLineType[keyof typeof DiffLineType];
|
||||
|
||||
|
||||
export const DiffLineType = {
|
||||
context: 'context',
|
||||
add: 'add',
|
||||
remove: 'remove',
|
||||
} as const;
|
||||
|
||||
export interface DiffLine {
|
||||
type: DiffLineType;
|
||||
text: string;
|
||||
/** @nullable */
|
||||
previousLine: number | null;
|
||||
/** @nullable */
|
||||
currentLine: number | null;
|
||||
}
|
||||
|
||||
export type ScanFileDiffStatus = typeof ScanFileDiffStatus[keyof typeof ScanFileDiffStatus];
|
||||
|
||||
|
||||
export const ScanFileDiffStatus = {
|
||||
unchanged: 'unchanged',
|
||||
modified: 'modified',
|
||||
added: 'added',
|
||||
removed: 'removed',
|
||||
} as const;
|
||||
|
||||
export interface ScanFileDiff {
|
||||
path: string;
|
||||
status: ScanFileDiffStatus;
|
||||
/** @nullable */
|
||||
previousHash: string | null;
|
||||
/** @nullable */
|
||||
currentHash: string | null;
|
||||
/** @nullable */
|
||||
previousSize: number | null;
|
||||
/** @nullable */
|
||||
currentSize: number | null;
|
||||
/** @nullable */
|
||||
previousHasContent: boolean | null;
|
||||
/** @nullable */
|
||||
currentHasContent: boolean | null;
|
||||
lineDiff: DiffLine[] | null;
|
||||
}
|
||||
|
||||
export interface ScanComparison {
|
||||
current: ScanComparisonSide;
|
||||
previous: ScanComparisonSide;
|
||||
files: ScanFileDiff[];
|
||||
}
|
||||
|
||||
export type AiProviderApiType = typeof AiProviderApiType[keyof typeof AiProviderApiType];
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import type {
|
|||
Rule,
|
||||
RuleUpdate,
|
||||
Scan,
|
||||
ScanComparison,
|
||||
ScanDetail,
|
||||
SkillScanInput
|
||||
} from './api.schemas';
|
||||
|
|
@ -354,6 +355,89 @@ export const useCreateScan = <TError = ErrorType<ApiError>,
|
|||
return useMutation(getCreateScanMutationOptions(options));
|
||||
}
|
||||
|
||||
export const getCompareScansUrl = (id: number,
|
||||
otherId: number,) => {
|
||||
|
||||
|
||||
|
||||
|
||||
return `/api/scans/${id}/compare/${otherId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a file-level diff between the current scan (id) and a previously stored scan (otherId), including line-by-line diffs for modified text files.
|
||||
* @summary Compare two scans on the file level
|
||||
*/
|
||||
export const compareScans = async (id: number,
|
||||
otherId: number, options?: RequestInit): Promise<ScanComparison> => {
|
||||
|
||||
return customFetch<ScanComparison>(getCompareScansUrl(id,otherId),
|
||||
{
|
||||
...options,
|
||||
method: 'GET'
|
||||
|
||||
|
||||
}
|
||||
);}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export const getCompareScansQueryKey = (id: number,
|
||||
otherId: number,) => {
|
||||
return [
|
||||
`/api/scans/${id}/compare/${otherId}`
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
export const getCompareScansQueryOptions = <TData = Awaited<ReturnType<typeof compareScans>>, TError = ErrorType<ApiError>>(id: number,
|
||||
otherId: number, options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof compareScans>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||
) => {
|
||||
|
||||
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getCompareScansQueryKey(id,otherId);
|
||||
|
||||
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof compareScans>>> = ({ signal }) => compareScans(id,otherId, { signal, ...requestOptions });
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { queryKey, queryFn, enabled: !!(id && otherId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof compareScans>>, TError, TData> & { queryKey: QueryKey }
|
||||
}
|
||||
|
||||
export type CompareScansQueryResult = NonNullable<Awaited<ReturnType<typeof compareScans>>>
|
||||
export type CompareScansQueryError = ErrorType<ApiError>
|
||||
|
||||
|
||||
/**
|
||||
* @summary Compare two scans on the file level
|
||||
*/
|
||||
|
||||
export function useCompareScans<TData = Awaited<ReturnType<typeof compareScans>>, TError = ErrorType<ApiError>>(
|
||||
id: number,
|
||||
otherId: number, options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof compareScans>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
|
||||
const queryOptions = getCompareScansQueryOptions(id,otherId,options)
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export const getGetScanUrl = (id: number,) => {
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -91,6 +91,40 @@ paths:
|
|||
schema:
|
||||
$ref: "#/components/schemas/ApiError"
|
||||
|
||||
/scans/{id}/compare/{otherId}:
|
||||
get:
|
||||
operationId: compareScans
|
||||
tags: [scans]
|
||||
summary: Compare two scans on the file level
|
||||
description: >-
|
||||
Returns a file-level diff between the current scan (id) and a previously
|
||||
stored scan (otherId), including line-by-line diffs for modified text
|
||||
files.
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: otherId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: File-level comparison
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ScanComparison"
|
||||
"404":
|
||||
description: Not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ApiError"
|
||||
|
||||
/scans/{id}:
|
||||
get:
|
||||
operationId: getScan
|
||||
|
|
@ -369,6 +403,10 @@ components:
|
|||
- fileCount
|
||||
- aiUsed
|
||||
- findingCounts
|
||||
- fingerprint
|
||||
- relation
|
||||
- similarity
|
||||
- comparedScanId
|
||||
- createdAt
|
||||
properties:
|
||||
id:
|
||||
|
|
@ -394,6 +432,19 @@ components:
|
|||
type: ["string", "null"]
|
||||
findingCounts:
|
||||
$ref: "#/components/schemas/FindingCounts"
|
||||
fingerprint:
|
||||
type: string
|
||||
description: Deterministic hash over all files (path + per-file hash)
|
||||
relation:
|
||||
type: ["string", "null"]
|
||||
enum: [new, identical, modified, null]
|
||||
description: Relation to previously stored skills
|
||||
similarity:
|
||||
type: ["integer", "null"]
|
||||
description: Content-aware similarity (0-100) to the compared skill (identical files count fully, changed text files use line-level similarity)
|
||||
comparedScanId:
|
||||
type: ["integer", "null"]
|
||||
description: The scan this one was compared against, if any
|
||||
createdAt:
|
||||
type: string
|
||||
|
||||
|
|
@ -450,7 +501,7 @@ components:
|
|||
|
||||
ScanFile:
|
||||
type: object
|
||||
required: [path, kind, size]
|
||||
required: [path, kind, size, hash, hasContent]
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
|
|
@ -461,6 +512,12 @@ components:
|
|||
type: ["string", "null"]
|
||||
size:
|
||||
type: integer
|
||||
hash:
|
||||
type: string
|
||||
description: SHA-256 hash of the file content
|
||||
hasContent:
|
||||
type: boolean
|
||||
description: Whether the text content was stored (false for binary files)
|
||||
|
||||
Finding:
|
||||
type: object
|
||||
|
|
@ -503,7 +560,7 @@ components:
|
|||
allOf:
|
||||
- $ref: "#/components/schemas/Scan"
|
||||
- type: object
|
||||
required: [files, findings, checkpoints]
|
||||
required: [files, findings, checkpoints, checkCount, comparedScan]
|
||||
properties:
|
||||
files:
|
||||
type: array
|
||||
|
|
@ -517,6 +574,113 @@ components:
|
|||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ScanCheckpoint"
|
||||
checkCount:
|
||||
type: integer
|
||||
description: How often a skill with this exact fingerprint was scanned
|
||||
comparedScan:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/ComparedScan"
|
||||
- type: "null"
|
||||
|
||||
ComparedScan:
|
||||
type: object
|
||||
required: [id, name, verdict, riskScore, createdAt]
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
verdict:
|
||||
type: string
|
||||
enum: [pass, review, block]
|
||||
riskScore:
|
||||
type: integer
|
||||
createdAt:
|
||||
type: string
|
||||
|
||||
ScanComparisonSide:
|
||||
type: object
|
||||
required: [id, name, verdict, riskScore, fileCount, fingerprint, createdAt]
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
verdict:
|
||||
type: string
|
||||
enum: [pass, review, block]
|
||||
riskScore:
|
||||
type: integer
|
||||
fileCount:
|
||||
type: integer
|
||||
fingerprint:
|
||||
type: string
|
||||
createdAt:
|
||||
type: string
|
||||
|
||||
DiffLine:
|
||||
type: object
|
||||
required: [type, text, previousLine, currentLine]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [context, add, remove]
|
||||
text:
|
||||
type: string
|
||||
previousLine:
|
||||
type: ["integer", "null"]
|
||||
currentLine:
|
||||
type: ["integer", "null"]
|
||||
|
||||
ScanFileDiff:
|
||||
type: object
|
||||
required:
|
||||
- path
|
||||
- status
|
||||
- previousHash
|
||||
- currentHash
|
||||
- previousSize
|
||||
- currentSize
|
||||
- previousHasContent
|
||||
- currentHasContent
|
||||
- lineDiff
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
enum: [unchanged, modified, added, removed]
|
||||
previousHash:
|
||||
type: ["string", "null"]
|
||||
currentHash:
|
||||
type: ["string", "null"]
|
||||
previousSize:
|
||||
type: ["integer", "null"]
|
||||
currentSize:
|
||||
type: ["integer", "null"]
|
||||
previousHasContent:
|
||||
type: ["boolean", "null"]
|
||||
currentHasContent:
|
||||
type: ["boolean", "null"]
|
||||
lineDiff:
|
||||
oneOf:
|
||||
- type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DiffLine"
|
||||
- type: "null"
|
||||
|
||||
ScanComparison:
|
||||
type: object
|
||||
required: [current, previous, files]
|
||||
properties:
|
||||
current:
|
||||
$ref: "#/components/schemas/ScanComparisonSide"
|
||||
previous:
|
||||
$ref: "#/components/schemas/ScanComparisonSide"
|
||||
files:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ScanFileDiff"
|
||||
|
||||
AiProvider:
|
||||
type: object
|
||||
|
|
|
|||
|
|
@ -60,6 +60,10 @@ export const GetDashboardResponse = zod.object({
|
|||
"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()
|
||||
})),
|
||||
"topRules": zod.array(zod.object({
|
||||
|
|
@ -94,6 +98,10 @@ export const ListScansResponseItem = zod.object({
|
|||
"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()
|
||||
})
|
||||
export const ListScansResponse = zod.array(ListScansResponseItem)
|
||||
|
|
@ -113,6 +121,53 @@ export const CreateScanBody = zod.object({
|
|||
})
|
||||
|
||||
|
||||
/**
|
||||
* Returns a file-level diff between the current scan (id) and a previously stored scan (otherId), including line-by-line diffs for modified text files.
|
||||
* @summary Compare two scans on the file level
|
||||
*/
|
||||
export const CompareScansParams = zod.object({
|
||||
"id": zod.coerce.number(),
|
||||
"otherId": zod.coerce.number()
|
||||
})
|
||||
|
||||
export const CompareScansResponse = zod.object({
|
||||
"current": zod.object({
|
||||
"id": zod.number(),
|
||||
"name": zod.string(),
|
||||
"verdict": zod.enum(['pass', 'review', 'block']),
|
||||
"riskScore": zod.number(),
|
||||
"fileCount": zod.number(),
|
||||
"fingerprint": zod.string(),
|
||||
"createdAt": zod.string()
|
||||
}),
|
||||
"previous": zod.object({
|
||||
"id": zod.number(),
|
||||
"name": zod.string(),
|
||||
"verdict": zod.enum(['pass', 'review', 'block']),
|
||||
"riskScore": zod.number(),
|
||||
"fileCount": zod.number(),
|
||||
"fingerprint": zod.string(),
|
||||
"createdAt": zod.string()
|
||||
}),
|
||||
"files": zod.array(zod.object({
|
||||
"path": zod.string(),
|
||||
"status": zod.enum(['unchanged', 'modified', 'added', 'removed']),
|
||||
"previousHash": zod.string().nullable(),
|
||||
"currentHash": zod.string().nullable(),
|
||||
"previousSize": zod.number().nullable(),
|
||||
"currentSize": zod.number().nullable(),
|
||||
"previousHasContent": zod.boolean().nullable(),
|
||||
"currentHasContent": zod.boolean().nullable(),
|
||||
"lineDiff": zod.union([zod.array(zod.object({
|
||||
"type": zod.enum(['context', 'add', 'remove']),
|
||||
"text": zod.string(),
|
||||
"previousLine": zod.number().nullable(),
|
||||
"currentLine": zod.number().nullable()
|
||||
})),zod.null()])
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
/**
|
||||
* @summary Get a scan report with findings
|
||||
*/
|
||||
|
|
@ -140,13 +195,19 @@ export const GetScanResponse = zod.object({
|
|||
"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()
|
||||
"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(),
|
||||
|
|
@ -171,7 +232,15 @@ export const GetScanResponse = zod.object({
|
|||
"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).'))
|
||||
}).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()])
|
||||
}))
|
||||
|
||||
|
||||
|
|
|
|||
16
lib/api-zod/src/generated/types/comparedScan.ts
Normal file
16
lib/api-zod/src/generated/types/comparedScan.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* Generated by orval v8.9.1 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* API specification
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { ComparedScanVerdict } from './comparedScanVerdict';
|
||||
|
||||
export interface ComparedScan {
|
||||
id: number;
|
||||
name: string;
|
||||
verdict: ComparedScanVerdict;
|
||||
riskScore: number;
|
||||
createdAt: string;
|
||||
}
|
||||
16
lib/api-zod/src/generated/types/comparedScanVerdict.ts
Normal file
16
lib/api-zod/src/generated/types/comparedScanVerdict.ts
Normal file
|
|
@ -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 ComparedScanVerdict = typeof ComparedScanVerdict[keyof typeof ComparedScanVerdict];
|
||||
|
||||
|
||||
export const ComparedScanVerdict = {
|
||||
pass: 'pass',
|
||||
review: 'review',
|
||||
block: 'block',
|
||||
} as const;
|
||||
17
lib/api-zod/src/generated/types/diffLine.ts
Normal file
17
lib/api-zod/src/generated/types/diffLine.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* Generated by orval v8.9.1 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* API specification
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { DiffLineType } from './diffLineType';
|
||||
|
||||
export interface DiffLine {
|
||||
type: DiffLineType;
|
||||
text: string;
|
||||
/** @nullable */
|
||||
previousLine: number | null;
|
||||
/** @nullable */
|
||||
currentLine: number | null;
|
||||
}
|
||||
16
lib/api-zod/src/generated/types/diffLineType.ts
Normal file
16
lib/api-zod/src/generated/types/diffLineType.ts
Normal file
|
|
@ -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 DiffLineType = typeof DiffLineType[keyof typeof DiffLineType];
|
||||
|
||||
|
||||
export const DiffLineType = {
|
||||
context: 'context',
|
||||
add: 'add',
|
||||
remove: 'remove',
|
||||
} as const;
|
||||
|
|
@ -14,7 +14,11 @@ export * from './aiProviderUpdate';
|
|||
export * from './aiProviderUpdateApiType';
|
||||
export * from './apiError';
|
||||
export * from './axisTotals';
|
||||
export * from './comparedScan';
|
||||
export * from './comparedScanVerdict';
|
||||
export * from './dashboardSummary';
|
||||
export * from './diffLine';
|
||||
export * from './diffLineType';
|
||||
export * from './finding';
|
||||
export * from './findingAxis';
|
||||
export * from './findingCounts';
|
||||
|
|
@ -40,9 +44,15 @@ export * from './scanCheckpointAxis';
|
|||
export * from './scanCheckpointDetectedBy';
|
||||
export * from './scanCheckpointSeverity';
|
||||
export * from './scanCheckpointStatus';
|
||||
export * from './scanComparison';
|
||||
export * from './scanComparisonSide';
|
||||
export * from './scanComparisonSideVerdict';
|
||||
export * from './scanDetail';
|
||||
export * from './scanFile';
|
||||
export * from './scanFileDiff';
|
||||
export * from './scanFileDiffStatus';
|
||||
export * from './scanFileKind';
|
||||
export * from './scanRelation';
|
||||
export * from './scanSource';
|
||||
export * from './scanStatus';
|
||||
export * from './scanVerdict';
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { FindingCounts } from './findingCounts';
|
||||
import type { ScanRelation } from './scanRelation';
|
||||
import type { ScanSource } from './scanSource';
|
||||
import type { ScanStatus } from './scanStatus';
|
||||
import type { ScanVerdict } from './scanVerdict';
|
||||
|
|
@ -22,5 +23,22 @@ export interface Scan {
|
|||
/** @nullable */
|
||||
aiError?: string | null;
|
||||
findingCounts: FindingCounts;
|
||||
/** Deterministic hash over all files (path + per-file hash) */
|
||||
fingerprint: string;
|
||||
/**
|
||||
* Relation to previously stored skills
|
||||
* @nullable
|
||||
*/
|
||||
relation: ScanRelation;
|
||||
/**
|
||||
* Content-aware similarity (0-100) to the compared skill (identical files count fully, changed text files use line-level similarity)
|
||||
* @nullable
|
||||
*/
|
||||
similarity: number | null;
|
||||
/**
|
||||
* The scan this one was compared against, if any
|
||||
* @nullable
|
||||
*/
|
||||
comparedScanId: number | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
|
|
|||
15
lib/api-zod/src/generated/types/scanComparison.ts
Normal file
15
lib/api-zod/src/generated/types/scanComparison.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* Generated by orval v8.9.1 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* API specification
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { ScanComparisonSide } from './scanComparisonSide';
|
||||
import type { ScanFileDiff } from './scanFileDiff';
|
||||
|
||||
export interface ScanComparison {
|
||||
current: ScanComparisonSide;
|
||||
previous: ScanComparisonSide;
|
||||
files: ScanFileDiff[];
|
||||
}
|
||||
18
lib/api-zod/src/generated/types/scanComparisonSide.ts
Normal file
18
lib/api-zod/src/generated/types/scanComparisonSide.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* Generated by orval v8.9.1 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* API specification
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { ScanComparisonSideVerdict } from './scanComparisonSideVerdict';
|
||||
|
||||
export interface ScanComparisonSide {
|
||||
id: number;
|
||||
name: string;
|
||||
verdict: ScanComparisonSideVerdict;
|
||||
riskScore: number;
|
||||
fileCount: number;
|
||||
fingerprint: string;
|
||||
createdAt: string;
|
||||
}
|
||||
16
lib/api-zod/src/generated/types/scanComparisonSideVerdict.ts
Normal file
16
lib/api-zod/src/generated/types/scanComparisonSideVerdict.ts
Normal file
|
|
@ -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 ScanComparisonSideVerdict = typeof ScanComparisonSideVerdict[keyof typeof ScanComparisonSideVerdict];
|
||||
|
||||
|
||||
export const ScanComparisonSideVerdict = {
|
||||
pass: 'pass',
|
||||
review: 'review',
|
||||
block: 'block',
|
||||
} as const;
|
||||
|
|
@ -5,13 +5,17 @@
|
|||
* API specification
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { ComparedScan } from './comparedScan';
|
||||
import type { Finding } from './finding';
|
||||
import type { Scan } from './scan';
|
||||
import type { ScanCheckpoint } from './scanCheckpoint';
|
||||
import type { ScanFile } from './scanFile';
|
||||
|
||||
export type ScanDetail = Scan & {
|
||||
export type ScanDetail = Scan & ({
|
||||
files: ScanFile[];
|
||||
findings: Finding[];
|
||||
checkpoints: ScanCheckpoint[];
|
||||
};
|
||||
/** How often a skill with this exact fingerprint was scanned */
|
||||
checkCount: number;
|
||||
comparedScan: ComparedScan | null;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,4 +13,8 @@ export interface ScanFile {
|
|||
/** @nullable */
|
||||
language?: string | null;
|
||||
size: number;
|
||||
/** SHA-256 hash of the file content */
|
||||
hash: string;
|
||||
/** Whether the text content was stored (false for binary files) */
|
||||
hasContent: boolean;
|
||||
}
|
||||
|
|
|
|||
27
lib/api-zod/src/generated/types/scanFileDiff.ts
Normal file
27
lib/api-zod/src/generated/types/scanFileDiff.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Generated by orval v8.9.1 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* API specification
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { DiffLine } from './diffLine';
|
||||
import type { ScanFileDiffStatus } from './scanFileDiffStatus';
|
||||
|
||||
export interface ScanFileDiff {
|
||||
path: string;
|
||||
status: ScanFileDiffStatus;
|
||||
/** @nullable */
|
||||
previousHash: string | null;
|
||||
/** @nullable */
|
||||
currentHash: string | null;
|
||||
/** @nullable */
|
||||
previousSize: number | null;
|
||||
/** @nullable */
|
||||
currentSize: number | null;
|
||||
/** @nullable */
|
||||
previousHasContent: boolean | null;
|
||||
/** @nullable */
|
||||
currentHasContent: boolean | null;
|
||||
lineDiff: DiffLine[] | null;
|
||||
}
|
||||
17
lib/api-zod/src/generated/types/scanFileDiffStatus.ts
Normal file
17
lib/api-zod/src/generated/types/scanFileDiffStatus.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* Generated by orval v8.9.1 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* API specification
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type ScanFileDiffStatus = typeof ScanFileDiffStatus[keyof typeof ScanFileDiffStatus];
|
||||
|
||||
|
||||
export const ScanFileDiffStatus = {
|
||||
unchanged: 'unchanged',
|
||||
modified: 'modified',
|
||||
added: 'added',
|
||||
removed: 'removed',
|
||||
} as const;
|
||||
20
lib/api-zod/src/generated/types/scanRelation.ts
Normal file
20
lib/api-zod/src/generated/types/scanRelation.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* Generated by orval v8.9.1 🍺
|
||||
* Do not edit manually.
|
||||
* Api
|
||||
* API specification
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Relation to previously stored skills
|
||||
* @nullable
|
||||
*/
|
||||
export type ScanRelation = typeof ScanRelation[keyof typeof ScanRelation] | null;
|
||||
|
||||
|
||||
export const ScanRelation = {
|
||||
new: 'new',
|
||||
identical: 'identical',
|
||||
modified: 'modified',
|
||||
} as const;
|
||||
|
|
@ -10,6 +10,8 @@ export const scanFilesTable = pgTable("scan_files", {
|
|||
kind: text("kind").notNull(),
|
||||
language: text("language"),
|
||||
size: integer("size").notNull().default(0),
|
||||
hash: text("hash").notNull().default(""),
|
||||
content: text("content"),
|
||||
});
|
||||
|
||||
export type ScanFile = typeof scanFilesTable.$inferSelect;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
boolean,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
|
|
@ -34,25 +35,37 @@ export type ScanCheckpoint = {
|
|||
detectedBy: "static" | "ai" | null;
|
||||
};
|
||||
|
||||
export const scansTable = pgTable("scans", {
|
||||
id: serial("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
source: text("source").notNull(),
|
||||
status: text("status").notNull().default("completed"),
|
||||
verdict: text("verdict").notNull().default("pass"),
|
||||
riskScore: integer("risk_score").notNull().default(0),
|
||||
fileCount: integer("file_count").notNull().default(0),
|
||||
aiUsed: boolean("ai_used").notNull().default(false),
|
||||
aiError: text("ai_error"),
|
||||
findingCounts: jsonb("finding_counts").$type<FindingCounts>().notNull(),
|
||||
checkpoints: jsonb("checkpoints")
|
||||
.$type<ScanCheckpoint[]>()
|
||||
.notNull()
|
||||
.default(sql`'[]'::jsonb`),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
});
|
||||
export type ScanRelation = "new" | "identical" | "modified";
|
||||
|
||||
export const scansTable = pgTable(
|
||||
"scans",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
source: text("source").notNull(),
|
||||
status: text("status").notNull().default("completed"),
|
||||
verdict: text("verdict").notNull().default("pass"),
|
||||
riskScore: integer("risk_score").notNull().default(0),
|
||||
fileCount: integer("file_count").notNull().default(0),
|
||||
aiUsed: boolean("ai_used").notNull().default(false),
|
||||
aiError: text("ai_error"),
|
||||
findingCounts: jsonb("finding_counts").$type<FindingCounts>().notNull(),
|
||||
checkpoints: jsonb("checkpoints")
|
||||
.$type<ScanCheckpoint[]>()
|
||||
.notNull()
|
||||
.default(sql`'[]'::jsonb`),
|
||||
fingerprint: text("fingerprint").notNull().default(""),
|
||||
relation: text("relation").$type<ScanRelation>(),
|
||||
similarity: integer("similarity"),
|
||||
comparedScanId: integer("compared_scan_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
fingerprintIdx: index("scans_fingerprint_idx").on(t.fingerprint),
|
||||
}),
|
||||
);
|
||||
|
||||
export type Scan = typeof scansTable.$inferSelect;
|
||||
export type InsertScan = typeof scansTable.$inferInsert;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue