feat(findings): consolidate duplicate reports into expandable cases

This commit is contained in:
ji ruo committed 2026-10-09 10:43:53 +08:00
1 parent a91b27feb7
commit 24d4726aee
29 files changed
+3051 -63

No files matched your search

@@ -0,0 +1,242 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { toast } from "sonner";
import { FindingCaseMembers, FindingCaseReviewPanel, FindingSeverityCounts } from "@/components/finding-case-list";
import { Markdown } from "@/components/markdown";
import { StatusBadge } from "@/components/status-badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api";
import type { FindingCaseDetail } from "@/lib/types";
function CaseDetail() {
const params = useSearchParams();
const id = params.get("id") ?? "";
const contextTask = params.get("context_task") ?? undefined;
const [data, setData] = React.useState<FindingCaseDetail | null>(null);
const [error, setError] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [selected, setSelected] = React.useState<Set<string>>(() => new Set());
const [refresh, setRefresh] = React.useState(0);
// biome-ignore lint/correctness/useExhaustiveDependencies: refresh explicitly reloads group after a detach.
React.useEffect(() => {
let active = true;
let timer: ReturnType<typeof setTimeout>;
async function load() {
try {
const v = await api.getFindingCase(id, contextTask);
if (active) {
setData(v);
setError("");
}
} catch (e) {
if (active) setError((e as Error).message);
} finally {
if (active) timer = setTimeout(load, 5000);
}
}
if (id) void load();
return () => {
active = false;
clearTimeout(timer);
};
}, [id, contextTask, refresh]);
const readOnly = !!contextTask && contextTask !== data?.case.task_id;
const select = (fid: string, on: boolean) =>
setSelected((v) => {
const n = new Set(v);
if (on) n.add(fid);
else n.delete(fid);
return n;
});
async function regenerate() {
setBusy(true);
try {
await api.regenerateFindingCase(id, contextTask);
toast.success("已提交重新整理,可查看执行状态");
} catch (e) {
toast.error((e as Error).message);
} finally {
setBusy(false);
}
}
async function detach() {
setBusy(true);
try {
for (const fid of selected) await api.removeFindingCaseMember(id, fid, "人工撤销归并");
setSelected(new Set());
setRefresh((v) => v + 1);
toast.success("已移出,原始记录保留");
} catch (e) {
toast.error((e as Error).message);
} finally {
setBusy(false);
}
}
if (!data)
return error ? (
<Alert>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : (
<Spinner />
);
const c = data.case;
return (
<div className="flex min-w-0 flex-col gap-4">
<div className="flex flex-wrap items-center gap-3">
<Button asChild variant="outline">
<Link href="/function/findings">返回漏洞列表</Link>
</Button>
<h1 className="font-semibold text-xl">{c.title}</h1>
<FindingSeverityCounts counts={c} />
{c.severity ? <StatusBadge domain="severity" value={c.severity} /> : <span>统一评级待评估</span>}
</div>
{error ? (
<Alert>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{!c.active ? (
<Alert>
<AlertDescription>此文件夹已解散或并入其他组,原始记录仍可从漏洞列表查看。</AlertDescription>
</Alert>
) : null}
{readOnly ? (
<Alert>
<AlertDescription>继承任务的漏洞组,只读。</AlertDescription>
</Alert>
) : (
<FindingCaseReviewPanel taskId={c.task_id ?? undefined} />
)}
<Tabs defaultValue="report">
<TabsList>
<TabsTrigger value="report">统一报告</TabsTrigger>
<TabsTrigger value="members">原始上报({c.count})</TabsTrigger>
<TabsTrigger value="history">归并历史</TabsTrigger>
</TabsList>
<TabsContent value="report">
<Card>
<CardHeader>
<CardTitle>完整复现与修复报告</CardTitle>
<CardDescription>{c.severity_reason || "统一评级尚未完成"}</CardDescription>
</CardHeader>
<CardContent className="flex min-w-0 flex-col gap-4">
{c.report_version !== c.version ? (
<Alert>
<AlertDescription>成员或证据已变化,统一报告待更新。</AlertDescription>
</Alert>
) : null}
{!readOnly && c.active ? (
<Button variant="outline" onClick={regenerate} disabled={busy}>
{busy ? <Spinner /> : null}重新生成统一报告
</Button>
) : null}
{c.report ? (
<Markdown text={c.report} />
) : (
<p className="text-muted-foreground text-sm">统一报告尚未生成,原始报告可在“原始上报”中查看。</p>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="members">
<Card>
<CardHeader>
<CardTitle>原始报告和证据完整保留</CardTitle>
<CardDescription>成员保留原评级;统一评级不覆盖原始记录。</CardDescription>
</CardHeader>
<CardContent>
{!readOnly && selected.size > 0 ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" disabled={busy}>
移出所选({selected.size})
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>移出归并组?</AlertDialogTitle>
<AlertDialogDescription>
原报告和证据保留,Agent 不会自动把这些关系再次归并。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={detach}>移出</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null}
<FindingCaseMembers
caseId={id}
version={c.version}
selectedIds={selected}
onSelect={readOnly ? undefined : select}
contextTask={contextTask}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="history">
<Card>
<CardHeader>
<CardTitle>判断依据与操作记录</CardTitle>
<CardDescription>{c.reason}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{data.events.map((e) => (
<div key={e.id} className="border-b pb-3 text-sm">
<p>
{({ reporter: "报告 Agent", human: "人工", system: "系统" } as Record<string, string>)[e.actor] ??
e.actor}{" "}
·{" "}
{(
{
merge: "归并",
detach: "移出成员",
member_deleted: "成员移出或删除",
report_updated: "更新统一报告",
} as Record<string, string>
)[e.action] ?? e.action}{" "}
· {new Date(e.created_at).toLocaleString("zh-CN")}
</p>
<p className="text-muted-foreground">
{e.reason}
{e.finding_ids.length > 0 ? ` · #${e.finding_ids.join(" / #")}` : ""}
</p>
</div>
))}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
export default function Page() {
return (
<React.Suspense fallback={<Spinner />}>
<CaseDetail />
</React.Suspense>
);
}
@@ -140,6 +140,15 @@ function FindingDetailInner() {
</Link>
</Button>
<ShieldAlertIcon className="size-4 text-muted-foreground" />
{finding.case_id ? (
<Button asChild variant="outline" size="sm">
<Link
href={`/function/findings/case?id=${finding.case_id}${contextTaskId ? `&context_task=${contextTaskId}` : ""}`}
>
所属漏洞文件夹
</Link>
</Button>
) : null}
<h1 className="max-w-md truncate text-sm font-semibold" title={title}>
{title}
</h1>
@@ -172,7 +181,7 @@ function FindingDetailInner() {
<p className="text-sm leading-relaxed whitespace-pre-wrap">{finding.summary || "(无摘要)"}</p>
</CardContent>
</Card>
<FindingRetestPanel key={id} findingId={id} readOnly={finding.inherited} onCompleted={load} />
<FindingRetestPanel key={`retest:${id}`} findingId={id} readOnly={finding.inherited} onCompleted={load} />
<Card>
<CardHeader>
<CardTitle className="text-sm">证据 / PoC</CardTitle>
@@ -188,7 +197,7 @@ function FindingDetailInner() {
</CardContent>
</Card>
<FindingTrafficPanel
key={id}
key={`traffic:${id}`}
findingId={id}
contextTask={contextTaskId || undefined}
readOnly={finding.inherited}
+54 -11
View File
@@ -17,11 +17,13 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { FindingCaseList } from "@/components/finding-case-list";
import { FindingRetestDialog } from "@/components/finding-retest-dialog";
import { StatusBadge } from "@/components/status-badge";
import { TablePagination } from "@/components/table-pagination";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -69,9 +71,9 @@ const FINDING_LIST_PREFERENCE_KEY = "artex_finding_list_preferences";
// 列表视图:flat = 跨任务平铺大表(默认);grouped = 按任务分组折叠;
// asset = 左侧资产树 + 右侧该子树下的发现。
type FindingView = "flat" | "grouped" | "asset";
type FindingView = "cases" | "flat" | "grouped" | "asset";
const FINDING_VIEWS: FindingView[] = ["flat", "grouped", "asset"];
const FINDING_VIEWS: FindingView[] = ["cases", "flat", "grouped", "asset"];
// 资产树的一次性快照。与另外两个视图不同,资产视图不轮询:进入视图、改筛选、
// 或本页改动了发现之后才重新查询。
@@ -129,7 +131,7 @@ const EMPTY_STATS: FindingStats = {
};
export default function FindingsPage() {
const [view, setView] = React.useState<FindingView>("flat");
const [view, setView] = React.useState<FindingView>("cases");
const [severity, setSeverity] = React.useState<"all" | Severity>("all");
const [status, setStatus] = React.useState<"all" | FindingStatus>("all");
const [vulnclass, setVulnclass] = React.useState<string>("all");
@@ -252,6 +254,8 @@ export default function FindingsPage() {
const [exportOpen, setExportOpen] = React.useState(false);
const [exportScope, setExportScope] = React.useState<"filtered" | "all" | "selected">("filtered");
const [exportFormat, setExportFormat] = React.useState<"md-single" | "md-zip" | "csv" | "json">("md-single");
const [includeOriginals, setIncludeOriginals] = React.useState(false);
const [reviewing, setReviewing] = React.useState(false);
const [exporting, setExporting] = React.useState(false);
const toggleSelected = React.useCallback((id: string, checked: boolean) => {
@@ -288,6 +292,7 @@ export default function FindingsPage() {
scope: exportScope,
filters: { severity, status, vulnclass, task, query, sort },
ids: [...selectedIds],
includeOriginals,
});
setExportOpen(false);
toast.success("已开始下载导出文件");
@@ -752,17 +757,18 @@ export default function FindingsPage() {
}
const statCards = [
{ label: "发现总数", value: stats.total, icon: BugIcon },
{ label: "待处理", value: stats.pending, tone: "text-amber-500", icon: ClockIcon },
{ label: "严重", value: stats.critical, tone: "text-rose-600", icon: ShieldAlertIcon },
{ label: "高危", value: stats.high, tone: "text-red-500", icon: TriangleAlertIcon },
{ label: "中危", value: stats.medium, tone: "text-amber-500", icon: TriangleAlertIcon },
{ label: "低危", value: stats.low, tone: "text-slate-500", icon: InfoIcon },
{ label: "独立漏洞", value: stats.distinct?.total ?? stats.total, icon: BugIcon },
{ label: "上报记录", value: stats.total, tone: "text-amber-500", icon: ClockIcon },
{ label: "严重", value: stats.distinct?.critical ?? stats.critical, tone: "text-rose-600", icon: ShieldAlertIcon },
{ label: "高危", value: stats.distinct?.high ?? stats.high, tone: "text-red-500", icon: TriangleAlertIcon },
{ label: "中危", value: stats.distinct?.medium ?? stats.medium, tone: "text-amber-500", icon: TriangleAlertIcon },
{ label: "低危", value: stats.distinct?.low ?? stats.low, tone: "text-slate-500", icon: InfoIcon },
];
// 导出弹窗里「当前筛选」的条数:两个视图的筛选一致,只是统计口径来源不同。
// 平铺与资产视图共用 flat 列表状态,分组视图的口径来自组接口的 finding_total。
const filteredTotal = view === "grouped" ? total : flat.total;
const [caseTotal, setCaseTotal] = React.useState(0);
const filteredTotal = view === "cases" ? caseTotal : view === "grouped" ? total : flat.total;
const assetPath = React.useMemo(
() => (view === "asset" ? assetPathOf(assetTree.nodes, assetScope) : []),
[assetScope, assetTree.nodes, view],
@@ -823,7 +829,8 @@ export default function FindingsPage() {
</div>
<Tabs value={view} onValueChange={(v) => setView(v as FindingView)}>
<TabsList>
<TabsTrigger value="flat">全部发现</TabsTrigger>
<TabsTrigger value="cases">按漏洞</TabsTrigger>
<TabsTrigger value="flat">原始上报</TabsTrigger>
<TabsTrigger value="grouped">按任务分组</TabsTrigger>
<TabsTrigger value="asset">按资产</TabsTrigger>
</TabsList>
@@ -953,6 +960,24 @@ export default function FindingsPage() {
{selectedIds.size > 0 && (
<span className="text-xs text-muted-foreground tabular-nums">已选 {selectedIds.size} 条</span>
)}
<Button
size="sm"
variant="outline"
disabled={reviewing || selectedIds.size === 0}
onClick={async () => {
setReviewing(true);
try {
await api.reviewFindingCases([...selectedIds]);
toast.success("已提交整理,按任务分别执行");
} catch (e) {
toast.error((e as Error).message);
} finally {
setReviewing(false);
}
}}
>
{reviewing ? <Spinner /> : null}整理所选({selectedIds.size})
</Button>
<Button size="sm" variant="outline" onClick={openExport}>
<DownloadIcon /> 导出
</Button>
@@ -1015,6 +1040,14 @@ export default function FindingsPage() {
</div>
)}
{view === "cases" && (
<FindingCaseList
onTotal={setCaseTotal}
query={{ severity, status, vulnclass, task, query, sort }}
selectedIds={selectedIds}
onSelect={toggleSelected}
/>
)}
{view === "grouped" && (
<div className="flex flex-col gap-3">
{groups.map((group) => {
@@ -1231,6 +1264,16 @@ export default function FindingsPage() {
<div className="flex flex-col gap-2">
<span className="text-xs text-muted-foreground">导出格式</span>
<div className="flex items-center gap-2">
<Checkbox
id="include-original-reports"
checked={includeOriginals}
onCheckedChange={(v) => setIncludeOriginals(v === true)}
/>
<label htmlFor="include-original-reports" className="text-sm">
包含原始子报告(默认每个文件夹一份统一报告)
</label>
</div>
<RadioGroup value={exportFormat} onValueChange={(v) => setExportFormat(v as typeof exportFormat)}>
<label htmlFor="export-format-md-single" className="flex items-center gap-2 text-sm">
<RadioGroupItem id="export-format-md-single" value="md-single" /> Markdown 汇总报告(单个 .md 文件)
@@ -7,6 +7,7 @@ import Link from "next/link";
import { ArrowDownIcon, ArrowUpIcon, ArrowUpRightIcon, ChevronRightIcon } from "lucide-react";
import { toast } from "sonner";
import { FindingCaseList } from "@/components/finding-case-list";
import { StatusBadge } from "@/components/status-badge";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
@@ -188,36 +189,55 @@ export function FindingsTab({ taskId }: { taskId: string }) {
});
return (
<Card className="overflow-hidden py-0">
<CardContent className="px-0">
<div className="flex items-center border-b px-4 py-2 text-xs text-muted-foreground">
<span className="min-w-0 flex-1">漏洞</span>
<button
type="button"
className="inline-flex items-center gap-1 outline-none focus-visible:underline"
aria-label={`发现时间当前${sortPreference.direction === "asc" ? "正序" : "倒序"},点击切换排序方向`}
onClick={() =>
setSortPreference((current) => ({
field: "time",
direction: current.direction === "asc" ? "desc" : "asc",
}))
}
>
<span>发现时间</span>
{sortPreference.direction === "asc" ? (
<ArrowUpIcon className="size-3.5" />
) : (
<ArrowDownIcon className="size-3.5" />
)}
</button>
<div className="flex flex-col gap-4">
<FindingCaseList query={{ task: taskId, sort: "time" }} />
{[
...new Set(
items
.filter((f) => f.inherited)
.map((f) => f.source_task_id)
.filter((id): id is string => !!id),
),
].map((source) => (
<div key={source}>
<p className="mb-2 text-sm text-muted-foreground">继承任务 #{source} · 只读</p>
<FindingCaseList query={{ task: source, sort: "time" }} contextTask={taskId} readOnly />
</div>
{items.map((f) => (
<Row key={f.id} f={f} contextTaskId={taskId} onStatus={onStatus} />
))}
{items.length === 0 && (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">本任务及直接关联任务暂无确认发现。</p>
)}
</CardContent>
</Card>
))}
<details>
<summary className="cursor-pointer text-sm text-muted-foreground">查看原始上报列表</summary>
<Card className="overflow-hidden py-0">
<CardContent className="px-0">
<div className="flex items-center border-b px-4 py-2 text-xs text-muted-foreground">
<span className="min-w-0 flex-1">漏洞</span>
<button
type="button"
className="inline-flex items-center gap-1 outline-none focus-visible:underline"
aria-label={`发现时间当前${sortPreference.direction === "asc" ? "正序" : "倒序"},点击切换排序方向`}
onClick={() =>
setSortPreference((current) => ({
field: "time",
direction: current.direction === "asc" ? "desc" : "asc",
}))
}
>
<span>发现时间</span>
{sortPreference.direction === "asc" ? (
<ArrowUpIcon className="size-3.5" />
) : (
<ArrowDownIcon className="size-3.5" />
)}
</button>
</div>
{items.map((f) => (
<Row key={f.id} f={f} contextTaskId={taskId} onStatus={onStatus} />
))}
{items.length === 0 && (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">本任务及直接关联任务暂无确认发现。</p>
)}
</CardContent>
</Card>
</details>
</div>
);
}
+15 -7
View File
@@ -950,7 +950,7 @@ export default function TasksPage() {
onSort={sortTasksBy}
/>
<TableHead className="text-center">目标进度</TableHead>
<TableHead className="text-center" title="严重 / 高 / 中 / 低">
<TableHead className="text-center" title="独立漏洞:严重 / 高 / 中 / 低">
漏洞 <span className="text-muted-foreground font-normal">严/高/中/低</span>
</TableHead>
<TableHead className="text-center">运行中 Worker</TableHead>
@@ -1220,14 +1220,16 @@ const TaskRow = React.memo(function TaskRow({
</TableCell>
<TableCell className="text-center text-xs tabular-nums">
{(() => {
const f = task.findings;
const f = task.distinct_findings ?? task.findings;
const total = f ? f.critical + f.high + f.medium + f.low : 0;
if (!f || total === 0) return <span className="text-muted-foreground">0</span>;
const seg = (n: number, cls: string) => (
<span className={n > 0 ? cls : "text-muted-foreground"}>{n}</span>
);
if (!f || (total === 0 && !task.distinct_findings?.total))
return <span className="text-muted-foreground">0</span>;
const seg = (n: number, cls: string) => <span className={n > 0 ? cls : "text-muted-foreground"}>{n}</span>;
return (
<span className="font-medium whitespace-nowrap" title="严重 / 高 / 中 / 低">
<span
className="font-medium whitespace-nowrap"
title={`独立漏洞:严重 / 高 / 中 / 低;上报 ${task.distinct_findings?.reports ?? total} 条;待评估 ${task.distinct_findings?.unassessed ?? 0} 个`}
>
{seg(f.critical, "text-rose-600 dark:text-rose-400")}
<span className="text-muted-foreground">/</span>
{seg(f.high, "text-red-600 dark:text-red-400")}
@@ -1235,6 +1237,12 @@ const TaskRow = React.memo(function TaskRow({
{seg(f.medium, "text-amber-600 dark:text-amber-400")}
<span className="text-muted-foreground">/</span>
{seg(f.low, "text-slate-600 dark:text-slate-400")}
{task.distinct_findings ? (
<span className="ml-1 text-muted-foreground">
· {task.distinct_findings.total} 漏洞 / {task.distinct_findings.reports} 上报
{task.distinct_findings.unassessed > 0 ? ` · 待评估 ${task.distinct_findings.unassessed}` : ""}
</span>
) : null}
</span>
);
})()}
+12
View File
@@ -10,6 +10,10 @@
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-severity-critical: var(--severity-critical);
--color-severity-high: var(--severity-high);
--color-severity-medium: var(--severity-medium);
--color-severity-low: var(--severity-low);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
@@ -55,6 +59,10 @@
/* Default theme styles (used when no data-theme-preset is set or when 'default' is selected).
These serve as the fallback; there is no separate default.css file. */
:root {
--severity-critical: #e11d48;
--severity-high: #dc2626;
--severity-medium: #d97706;
--severity-low: #475569;
--radius: 0.625rem;
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
@@ -94,6 +102,10 @@ These serve as the fallback; there is no separate default.css file. */
}
.dark {
--severity-critical: #fb7185;
--severity-high: #f87171;
--severity-medium: #fbbf24;
--severity-low: #94a3b8;
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);