mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
Merge pull request #163 from BaaRai/feat/verification-ui
feat: verification badge, filter and note in the findings UI
This commit is contained in:
+8
-6
@@ -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.
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user