From 1810a82187413de374b6efffc040d8a3f152f23d Mon Sep 17 00:00:00 2001 From: BaaRai <130491490+BaaRai@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:04:12 +0100 Subject: [PATCH] feat: verification status badge, filter and note in the findings UI Show the automated verification outcome next to the human triage status: a badge on the findings list and detail page ("Verified L3", "Unverified", "Blocked", "Not assessed", "False positive"), a verification-state filter next to the status filter (persisted with the other list preferences), and the verification note on the detail page when the state is not verified. API: the findings list, groups, asset tree and export accept a verification_status filter. Without it nothing changes. Not included: a verified count in the statistics. --- db/findings.go | 14 +++-- server/findings_groups.go | 7 ++- .../findings/_components/findings-table.tsx | 48 ++++++++++++++- .../(main)/function/findings/detail/page.tsx | 14 +++++ web/src/app/(main)/function/findings/page.tsx | 61 ++++++++++++++++--- web/src/lib/api.ts | 1 + web/src/lib/types.ts | 8 +++ 7 files changed, 132 insertions(+), 21 deletions(-) diff --git a/db/findings.go b/db/findings.go index 010f522..cb010d4 100644 --- a/db/findings.go +++ b/db/findings.go @@ -154,12 +154,13 @@ func (d *DB) ListFindings(limit int) ([]*DBFinding, error) { // filter on that column". Sort is "severity" (severity desc, then newest) or // anything else (newest first). type FindingFilter struct { - Severity string // high | medium | low - Status string // pending | false_positive | ignored | resolved - VulnClass string - TaskID string // task id (in string form; empty/invalid = do not filter by task) - Query string // fuzzy search keyword over name/class/summary/evidence/report body - Sort string // "severity" | "time" + Severity string // high | medium | low + Status string // pending | false_positive | ignored | resolved + VerificationStatus string // verified | unverified | blocked | not_assessed | false_positive + VulnClass string + TaskID string // task id (in string form; empty/invalid = do not filter by task) + Query string // fuzzy search keyword over name/class/summary/evidence/report body + Sort string // "severity" | "time" // AssetScope is a node key of the asset tree (a: / c: / r: / // __none__); selecting a node means selecting its whole subtree. // Empty = do not filter by asset. @@ -192,6 +193,7 @@ func (f FindingFilter) where() (string, []any) { } add("severity", f.Severity) add("status", f.Status) + add("verification_status", f.VerificationStatus) add("vulnclass", f.VulnClass) // task_id is a bigint column, compared as an integer (it cannot go through // the text add above); empty/invalid values are ignored. diff --git a/server/findings_groups.go b/server/findings_groups.go index 60188ca..075f8b0 100644 --- a/server/findings_groups.go +++ b/server/findings_groups.go @@ -29,9 +29,10 @@ func findingPaginationParam(raw string, fallback, upperBound int) int { // query string. The list / grouping / asset tree / export all share one parsing path; new filter options only change here. func findingFilterFromQuery(q url.Values) db.FindingFilter { return db.FindingFilter{ - Severity: normFilter(q.Get("severity")), - Status: normFilter(q.Get("status")), - VulnClass: normFilter(q.Get("vulnclass")), + Severity: normFilter(q.Get("severity")), + Status: normFilter(q.Get("status")), + VerificationStatus: normFilter(q.Get("verification_status")), + VulnClass: normFilter(q.Get("vulnclass")), // task_id (independent of the task param, which switches to the per-task-node branch): filters the global table by task. TaskID: normFilter(q.Get("task_id")), Query: q.Get("q"), diff --git a/web/src/app/(main)/function/findings/_components/findings-table.tsx b/web/src/app/(main)/function/findings/_components/findings-table.tsx index 1b53325..3821f56 100644 --- a/web/src/app/(main)/function/findings/_components/findings-table.tsx +++ b/web/src/app/(main)/function/findings/_components/findings-table.tsx @@ -36,8 +36,8 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Spinner } from "@/components/ui/spinner"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { statusMeta } from "@/lib/status"; -import type { ActiveFindingRetest, Finding, FindingStatus, Severity } from "@/lib/types"; +import { statusMeta, type Tone, toneClasses } from "@/lib/status"; +import type { ActiveFindingRetest, Finding, FindingStatus, Severity, VerificationStatus } from "@/lib/types"; import { cn } from "@/lib/utils"; export const SEVERITIES: Severity[] = ["critical", "high", "medium", "low"]; @@ -54,6 +54,45 @@ export const FINDING_STATUSES: FindingStatus[] = [ "risk_accepted", ]; +export const VERIFICATION_STATUSES: VerificationStatus[] = [ + "verified", + "unverified", + "blocked", + "not_assessed", + "false_positive", +]; + +// Verification outcome → label/tone, on the same tone tokens StatusBadge uses. +export const VERIFICATION_META: Record = { + verified: { label: "Verified", tone: "green" }, + unverified: { label: "Unverified", tone: "slate" }, + blocked: { label: "Blocked", tone: "amber" }, + not_assessed: { label: "Not assessed", tone: "red" }, + false_positive: { label: "False positive", tone: "neutral" }, +}; + +// VerificationBadge shows the automated verification outcome ("Verified L3" when a +// proof-of-exploitation level is declared). It renders nothing when the finding carries +// no verification status (in-task nodes, payloads from before the column existed). +export function VerificationBadge({ finding }: { finding: Pick }) { + const meta = finding.verification_status ? VERIFICATION_META[finding.verification_status] : undefined; + if (!meta) return null; + const label = + finding.verification_status === "verified" && finding.poe_level + ? `${meta.label} L${finding.poe_level}` + : meta.label; + return ( + + {label} + + ); +} + export const UNASSIGNED_TASK = "__unassigned__"; // Inline edit buffer: the expanded row's name/category/severity. @@ -213,7 +252,10 @@ export function FindingsTable({ {f.name || f.vulnclass || "Uncategorized"} )} {f.summary} - Traffic evidence: {f.traffic_count ?? 0} +
+ Traffic evidence: {f.traffic_count ?? 0} + +
diff --git a/web/src/app/(main)/function/findings/detail/page.tsx b/web/src/app/(main)/function/findings/detail/page.tsx index ca8614f..b92decc 100644 --- a/web/src/app/(main)/function/findings/detail/page.tsx +++ b/web/src/app/(main)/function/findings/detail/page.tsx @@ -25,6 +25,7 @@ import { api } from "@/lib/api"; import { statusMeta } from "@/lib/status"; import type { Finding, FindingStatus, Severity } from "@/lib/types"; +import { VerificationBadge } from "../_components/findings-table"; import { FindingLineageView } from "./lineage"; const SEVERITIES: Severity[] = ["critical", "high", "medium", "low"]; @@ -147,6 +148,7 @@ function FindingDetailInner() { + {finding.inherited && finding.source_task_id && ( Source task #{finding.source_task_id} · read-only )} @@ -272,6 +274,18 @@ function FindingDetailInner() { )} + {/* Verification: automated outcome; the note explains any state other than verified */} + {finding.verification_status && ( + + + {finding.verification_status !== "verified" && finding.verification_note?.trim() && ( +

+ {finding.verification_note} +

+ )} +
+ )} + {/* Vulnerability type */} {finding.vulnclass ? ( diff --git a/web/src/app/(main)/function/findings/page.tsx b/web/src/app/(main)/function/findings/page.tsx index 350c8d9..285f763 100644 --- a/web/src/app/(main)/function/findings/page.tsx +++ b/web/src/app/(main)/function/findings/page.tsx @@ -49,6 +49,7 @@ import type { FindingStats, FindingStatus, Severity, + VerificationStatus, } from "@/lib/types"; import { cn } from "@/lib/utils"; @@ -63,6 +64,8 @@ import { isSameFinding, SEVERITIES, UNASSIGNED_TASK, + VERIFICATION_META, + VERIFICATION_STATUSES, } from "./_components/findings-table"; const FINDING_LIST_PREFERENCE_KEY = "artex_finding_list_preferences"; @@ -132,6 +135,7 @@ export default function FindingsPage() { const [view, setView] = React.useState("flat"); const [severity, setSeverity] = React.useState<"all" | Severity>("all"); const [status, setStatus] = React.useState<"all" | FindingStatus>("all"); + const [verificationStatus, setVerificationStatus] = React.useState<"all" | VerificationStatus>("all"); const [vulnclass, setVulnclass] = React.useState("all"); const [task, setTask] = React.useState("all"); const [sort, setSort] = React.useState<"severity" | "time">("severity"); @@ -162,7 +166,7 @@ export default function FindingsPage() { const retestRefreshVersion = React.useRef(0); const [deepenDescription, setDeepenDescription] = React.useState(""); const [deepening, setDeepening] = React.useState(false); - const filterFingerprint = JSON.stringify([severity, status, vulnclass, task, sort, query]); + const filterFingerprint = JSON.stringify([severity, status, verificationStatus, vulnclass, task, sort, query]); const activeFilterFingerprint = React.useRef(filterFingerprint); activeFilterFingerprint.current = filterFingerprint; @@ -200,6 +204,7 @@ export default function FindingsPage() { view?: unknown; severity?: unknown; status?: unknown; + verificationStatus?: unknown; vulnclass?: unknown; task?: unknown; sort?: unknown; @@ -211,6 +216,12 @@ export default function FindingsPage() { if (parsed.status === "all" || FINDING_STATUSES.includes(parsed.status as FindingStatus)) { setStatus(parsed.status as "all" | FindingStatus); } + if ( + parsed.verificationStatus === "all" || + VERIFICATION_STATUSES.includes(parsed.verificationStatus as VerificationStatus) + ) { + setVerificationStatus(parsed.verificationStatus as "all" | VerificationStatus); + } if (typeof parsed.vulnclass === "string" && parsed.vulnclass) setVulnclass(parsed.vulnclass); if (typeof parsed.task === "string" && parsed.task) setTask(parsed.task); if (parsed.sort === "severity" || parsed.sort === "time") setSort(parsed.sort); @@ -225,9 +236,9 @@ export default function FindingsPage() { if (!preferencesHydrated) return; setLocalStorageValue( FINDING_LIST_PREFERENCE_KEY, - JSON.stringify({ view, severity, status, vulnclass, task, sort }), + JSON.stringify({ view, severity, status, verificationStatus, vulnclass, task, sort }), ); - }, [preferencesHydrated, severity, sort, status, task, view, vulnclass]); + }, [preferencesHydrated, severity, sort, status, task, verificationStatus, view, vulnclass]); React.useEffect(() => { const timer = window.setTimeout(() => setQuery(search.trim()), 300); @@ -286,7 +297,7 @@ export default function FindingsPage() { await api.exportFindings({ format: exportFormat, scope: exportScope, - filters: { severity, status, vulnclass, task, query, sort }, + filters: { severity, status, verificationStatus, vulnclass, task, query, sort }, ids: [...selectedIds], }); setExportOpen(false); @@ -326,6 +337,7 @@ export default function FindingsPage() { pageSize: flatPageSize, severity, status, + verificationStatus, vulnclass, task, query, @@ -339,7 +351,19 @@ export default function FindingsPage() { // Polling keeps the last successful snapshot visible. setFlat((current) => ({ ...current, loading: false })); } - }, [activeAssetScope, filterFingerprint, flatPage, flatPageSize, severity, status, vulnclass, task, query, sort]); + }, [ + activeAssetScope, + filterFingerprint, + flatPage, + flatPageSize, + severity, + status, + verificationStatus, + vulnclass, + task, + query, + sort, + ]); // loadAssetTree fetches the whole asset tree. The tree doesn't change with the selected node (otherwise // picking one would collapse it into a single chain), so no assetScope here. @@ -349,7 +373,7 @@ export default function FindingsPage() { const request = ++assetTreeRequest.current; setAssetTree((current) => ({ ...current, loading: true })); try { - const result = await api.findingAssetTree({ severity, status, vulnclass, task, query, sort }); + const result = await api.findingAssetTree({ severity, status, verificationStatus, vulnclass, task, query, sort }); if (request !== assetTreeRequest.current || activeFilterFingerprint.current !== requestFilter) return; setAssetTree({ nodes: result.nodes ?? [], @@ -364,7 +388,7 @@ export default function FindingsPage() { setAssetTree((current) => ({ ...current, loading: false })); toast.error(`Failed to load the asset tree: ${(e as Error).message}`); } - }, [filterFingerprint, severity, status, vulnclass, task, query, sort]); + }, [filterFingerprint, severity, status, verificationStatus, vulnclass, task, query, sort]); const refreshGroups = React.useCallback(async () => { const requestFilter = filterFingerprint; @@ -376,6 +400,7 @@ export default function FindingsPage() { pageSize, severity, status, + verificationStatus, vulnclass, task, query, @@ -388,7 +413,7 @@ export default function FindingsPage() { } catch { // Polling keeps the last successful snapshot visible. } - }, [filterFingerprint, page, pageSize, severity, status, vulnclass, task, query, sort]); + }, [filterFingerprint, page, pageSize, severity, status, verificationStatus, vulnclass, task, query, sort]); const loadGroup = React.useCallback( async (key: string, groupPage: number, groupPageSize: number) => { @@ -413,6 +438,7 @@ export default function FindingsPage() { pageSize: groupPageSize, severity, status, + verificationStatus, vulnclass, task: key, query, @@ -447,7 +473,7 @@ export default function FindingsPage() { })); } }, - [filterFingerprint, severity, status, vulnclass, query, sort], + [filterFingerprint, severity, status, verificationStatus, vulnclass, query, sort], ); React.useEffect(() => { @@ -898,6 +924,23 @@ export default function FindingsPage() { + +