Compare commits

...
Author SHA1 Message Date
BaaRai 8634ddbf23 feat: a completed retest updates the verification state
A completed retest with a reproduced or fixed verdict is an independent proof,
so it sets verification_status to verified with a note naming the retest, its
verdict and the start of its summary. The upgrade applies only from unverified,
not_assessed or blocked: a verified finding keeps its POE level and note.

Inconclusive, failed and stopped retests never change verification_status; their
verdict stays in the retest history. The human status is untouched by this path.
The existing fixed path (status set to Fixed with notifications) is unchanged.

Tests cover reproduced, fixed (status stays Fixed), inconclusive on a verified
finding, failed and stopped, and the upgrade from not_assessed and blocked.
2026-11-09 21:47:40 +01:00
BaaRai ec89070b8e Merge pull request #163 from BaaRai/feat/verification-ui
feat: verification badge, filter and note in the findings UI
2026-11-09 21:21:42 +01:00
BaaRai 1810a82187 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.
2026-11-09 21:04:12 +01:00
BaaRai 34cbbeadb7 Merge pull request #162 from BaaRai/feat/verified-findings
feat: verified findings backend (S1)
2026-11-09 20:50:13 +01:00
9 changed files with 266 additions and 23 deletions
+27 -2
View File
@@ -12,6 +12,19 @@ import (
const FindingRetestAgentKey = "retester"
// retestNoteSummaryRunes caps the retest summary copied into verification_note.
const retestNoteSummaryRunes = 200
// truncateRunes returns s trimmed to at most n runes, with an ellipsis when cut.
func truncateRunes(s string, n int) string {
s = strings.TrimSpace(s)
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
var ErrRetestNotRunning = errors.New("this retest has ended or has not started; please start a new retest from the finding details")
// FindingRetest is an immutable historical test once its conversation turn ends.
@@ -225,17 +238,29 @@ func (d *DB) FinishFindingRetest(id int64, status, reason string) error {
if err != nil {
return err
}
var finalStatus, verdict string
var finalStatus, verdict, summary string
err = tx.QueryRow(`UPDATE finding_retests SET
status=CASE WHEN $2='completed' AND verdict='' THEN 'failed' ELSE $2 END,
error=CASE WHEN $2='completed' AND verdict='' THEN 'Agent did not save the retest conclusion; please review the conversation and retest again' ELSE $3 END,
finished_at=now() WHERE id=$1 AND status IN ('pending','running') RETURNING status,verdict`, id, status, reason).Scan(&finalStatus, &verdict)
finished_at=now() WHERE id=$1 AND status IN ('pending','running') RETURNING status,verdict,summary`, id, status, reason).Scan(&finalStatus, &verdict, &summary)
if errors.Is(err, sql.ErrNoRows) {
return nil // A replay must not overwrite a later manual triage decision.
}
if err != nil {
return err
}
// A completed reproduced/fixed retest is an independent proof: it upgrades the
// machine verification state, but only from unverified / not_assessed / blocked.
// A verified finding keeps its POE level and note. An inconclusive, failed or
// stopped retest never downgrades anything; its verdict stays in finding_retests.
// The human disposition (status) is not touched here.
if finalStatus == "completed" && (verdict == "reproduced" || verdict == "fixed") {
note := fmt.Sprintf("retest #%d: %s — %s", id, verdict, truncateRunes(summary, retestNoteSummaryRunes))
if _, err := tx.Exec(`UPDATE findings SET verification_status='verified', verification_note=$2
WHERE id=$1 AND verification_status IN ('unverified','not_assessed','blocked')`, findingID, note); err != nil {
return err
}
}
if finalStatus == "completed" && verdict == "fixed" {
// Go through the notifying version, sharing the same semantics as a
// manual status change on the details page.
+107
View File
@@ -0,0 +1,107 @@
package db
import (
"strings"
"testing"
)
// runRetest drives one retest session to its terminal state with the given
// staged verdict, using the same calls the server makes, and returns its ID.
func runRetest(t *testing.T, d *DB, fid int64, verdict, summary, finish string) int64 {
t.Helper()
ctx := t.Context()
r, c, _, err := d.CreateFindingRetest(ctx, fid, "")
if err != nil {
t.Fatal(err)
}
if ok, err := d.StartFindingRetest(ctx, r.ID); err != nil || !ok {
t.Fatalf("start retest: ok=%v err=%v", ok, err)
}
if verdict != "" {
if err := d.RecordFindingRetestResult(ctx, c.ID, verdict, summary, "retest evidence"); err != nil {
t.Fatal(err)
}
}
if err := d.FinishFindingRetest(r.ID, finish, ""); err != nil {
t.Fatal(err)
}
return r.ID
}
func verificationOf(t *testing.T, d *DB, fid int64) (status string, poe int, note string) {
t.Helper()
if err := d.QueryRow(`SELECT verification_status, poe_level, verification_note FROM findings WHERE id=$1`, fid).
Scan(&status, &poe, &note); err != nil {
t.Fatal(err)
}
return
}
func TestRetestReproducedVerifiesUnverifiedFinding(t *testing.T) {
d, fid := retestDB(t)
runRetest(t, d, fid, "reproduced", "still exploitable with the original PoC", "completed")
status, _, note := verificationOf(t, d, fid)
if status != VerificationVerified || !strings.Contains(note, "retest") || !strings.Contains(note, "reproduced") {
t.Fatalf("verification after reproduced: status=%s note=%q", status, note)
}
}
func TestRetestFixedVerifiesAndKeepsFixedStatus(t *testing.T) {
d, fid := retestDB(t)
runRetest(t, d, fid, "fixed", "payload now rejected, control still works", "completed")
status, _, note := verificationOf(t, d, fid)
if status != VerificationVerified || !strings.Contains(note, "retest") || !strings.Contains(note, "fixed") {
t.Fatalf("verification after fixed: status=%s note=%q", status, note)
}
f, err := d.GetFinding(fid)
if err != nil || f == nil {
t.Fatalf("get finding: %v", err)
}
if f.Status != FindingFixed {
t.Fatalf("human status after fixed retest = %q, want %q", f.Status, FindingFixed)
}
}
func TestRetestInconclusiveDoesNotDowngradeVerified(t *testing.T) {
d, fid := retestDB(t)
if err := d.SetFindingVerification(fid, VerificationVerified, 3, ""); err != nil {
t.Fatal(err)
}
runRetest(t, d, fid, "inconclusive", "target unreachable from the retest session", "completed")
status, poe, note := verificationOf(t, d, fid)
if status != VerificationVerified || poe != 3 || note != "" {
t.Fatalf("verified finding after inconclusive: status=%s poe=%d note=%q", status, poe, note)
}
}
func TestRetestFailedOrStoppedLeavesVerificationUnchanged(t *testing.T) {
for _, finish := range []string{"failed", "stopped"} {
t.Run(finish, func(t *testing.T) {
d, fid := retestDB(t)
if err := d.SetFindingVerification(fid, VerificationNotAssessed, 3, "earlier note"); err != nil {
t.Fatal(err)
}
// A verdict staged before the session was cut off must not count.
runRetest(t, d, fid, "reproduced", "staged before interruption", finish)
status, poe, note := verificationOf(t, d, fid)
if status != VerificationNotAssessed || poe != 3 || note != "earlier note" {
t.Fatalf("after %s: status=%s poe=%d note=%q", finish, status, poe, note)
}
})
}
}
func TestRetestUpgradesFromNotAssessedAndBlocked(t *testing.T) {
for _, from := range []string{VerificationNotAssessed, VerificationBlocked} {
t.Run(from, func(t *testing.T) {
d, fid := retestDB(t)
if err := d.SetFindingVerification(fid, from, 0, ""); err != nil {
t.Fatal(err)
}
runRetest(t, d, fid, "reproduced", "reproduced on retest", "completed")
if status, _, _ := verificationOf(t, d, fid); status != VerificationVerified {
t.Fatalf("from %s after reproduced: status=%s", from, status)
}
})
}
}
+8 -6
View File
@@ -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:<id> / c:<id> / r:<domain> /
// __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.
+4 -3
View File
@@ -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"),
@@ -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<VerificationStatus, { label: string; tone: Tone }> = {
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<Finding, "verification_status" | "poe_level"> }) {
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 (
<span
className={cn(
"inline-flex w-fit items-center whitespace-nowrap rounded-md border px-2 py-0.5 font-medium text-xs",
toneClasses[meta.tone],
)}
>
{label}
</span>
);
}
export const UNASSIGNED_TASK = "__unassigned__";
// Inline edit buffer: the expanded row's name/category/severity.
@@ -213,7 +252,10 @@ export function FindingsTable({
<span className="truncate font-medium">{f.name || f.vulnclass || "Uncategorized"}</span>
)}
<span className="truncate text-xs text-muted-foreground">{f.summary}</span>
<Badge variant="outline">Traffic evidence: {f.traffic_count ?? 0}</Badge>
<div className="flex flex-wrap items-center gap-1">
<Badge variant="outline">Traffic evidence: {f.traffic_count ?? 0}</Badge>
<VerificationBadge finding={f} />
</div>
</div>
</TableCell>
<TableCell>
@@ -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() {
<Separator orientation="vertical" className="mx-1 h-4" />
<StatusBadge domain="severity" value={finding.severity} dot />
<StatusBadge domain="finding" value={finding.status} dot />
<VerificationBadge finding={finding} />
{finding.inherited && finding.source_task_id && (
<Badge variant="outline">Source task #{finding.source_task_id} · read-only</Badge>
)}
@@ -272,6 +274,18 @@ function FindingDetailInner() {
)}
</FieldRow>
{/* Verification: automated outcome; the note explains any state other than verified */}
{finding.verification_status && (
<FieldRow label="Verification">
<VerificationBadge finding={finding} />
{finding.verification_status !== "verified" && finding.verification_note?.trim() && (
<p className="whitespace-pre-wrap break-words text-muted-foreground text-xs">
{finding.verification_note}
</p>
)}
</FieldRow>
)}
{/* Vulnerability type */}
<FieldRow label="Vulnerability type">
{finding.vulnclass ? (
+52 -9
View File
@@ -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<FindingView>("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<string>("all");
const [task, setTask] = React.useState<string>("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() {
</SelectContent>
</Select>
<Select
value={verificationStatus}
onValueChange={(v) => setVerificationStatus(v as "all" | VerificationStatus)}
>
<SelectTrigger size="sm" className="w-48">
<SelectValue placeholder="Verification" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All verification states</SelectItem>
{VERIFICATION_STATUSES.map((st) => (
<SelectItem key={st} value={st}>
{VERIFICATION_META[st].label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={vulnclass} onValueChange={setVulnclass}>
<SelectTrigger size="sm" className="w-40">
<SelectValue placeholder="Vulnerability type" />
+1
View File
@@ -218,6 +218,7 @@ function findingFilterParams(q: Omit<FindingQuery, "page" | "pageSize">): URLSea
const p = new URLSearchParams();
if (q.severity && q.severity !== "all") p.set("severity", q.severity);
if (q.status && q.status !== "all") p.set("status", q.status);
if (q.verificationStatus && q.verificationStatus !== "all") p.set("verification_status", q.verificationStatus);
if (q.vulnclass && q.vulnclass !== "all") p.set("vulnclass", q.vulnclass);
if (q.task && q.task !== "all") p.set("task_id", q.task);
if (q.query?.trim()) p.set("q", q.query.trim());
+8
View File
@@ -481,6 +481,10 @@ export type FindingStatus =
| "duplicate"
| "risk_accepted";
// Verification status (S1): the automated proof-of-exploitation outcome, distinct from the
// human triage status above. "blocked" is produced by no path yet but is displayed when present.
export type VerificationStatus = "verified" | "unverified" | "blocked" | "not_assessed" | "false_positive";
// FindingAsset is an asset bound to a vulnerability (label pre-rendered by the backend).
export interface FindingAsset {
id: string;
@@ -493,6 +497,9 @@ export interface Finding {
evidence_version?: number;
report_evidence_version?: number;
report_stale?: boolean;
poe_level?: number; // proof-of-exploitation level (3 or 4 when verified); 0 = none declared
verification_status?: VerificationStatus; // automated verification outcome (S1)
verification_note?: string; // why the status was reached; empty when verified
id: string;
finding_id?: string; // row id in the standalone findings table, the handle for status updates (may be missing for old in-task nodes)
vulnclass: string;
@@ -575,6 +582,7 @@ export interface FindingQuery {
pageSize: number;
severity?: "all" | Severity;
status?: "all" | FindingStatus;
verificationStatus?: "all" | VerificationStatus;
vulnclass?: string;
task?: string; // task id; "all"/empty = no task filter
query?: string;