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);
+425
View File
@@ -0,0 +1,425 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { ChevronRightIcon, FolderIcon } from "lucide-react";
import { toast } from "sonner";
import { StatusBadge } from "@/components/status-badge";
import { TablePagination } from "@/components/table-pagination";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Spinner } from "@/components/ui/spinner";
import { api } from "@/lib/api";
import type {
Finding,
FindingCase,
FindingCasePage,
FindingCaseReviewRun,
FindingCaseSuggestion,
FindingQuery,
} from "@/lib/types";
import { cn } from "@/lib/utils";
export function FindingSeverityCounts({
counts,
}: {
counts: Pick<FindingCase, "critical" | "high" | "medium" | "low">;
}) {
return (
<span
className="inline-flex items-center gap-0.5 text-sm tabular-nums"
title="原始上报记录:严重 / 高 / 中 / 低"
role="img"
aria-label={`严重${counts.critical},高危${counts.high},中危${counts.medium},低危${counts.low}`}
>
<span className={counts.critical ? "text-severity-critical" : "text-muted-foreground"}>{counts.critical}</span>
<span>/</span>
<span className={counts.high ? "text-severity-high" : "text-muted-foreground"}>{counts.high}</span>
<span>/</span>
<span className={counts.medium ? "text-severity-medium" : "text-muted-foreground"}>{counts.medium}</span>
<span>/</span>
<span className={counts.low ? "text-severity-low" : "text-muted-foreground"}>{counts.low}</span>
</span>
);
}
export function FindingCaseMemberRow({
finding: f,
selected,
onSelect,
contextTask,
matched = true,
}: {
finding: Finding;
selected?: boolean;
onSelect?: (id: string, checked: boolean) => void;
contextTask?: string;
matched?: boolean;
}) {
const id = f.finding_id ?? f.id;
return (
<div
className={cn(
"flex min-w-0 flex-wrap items-center gap-3 border-b px-4 py-3 text-sm last:border-b-0",
!matched && "bg-muted/30",
)}
>
{onSelect && !f.inherited ? (
<Checkbox checked={selected} onCheckedChange={(v) => onSelect(id, v === true)} aria-label={`选择上报 #${id}`} />
) : null}
<StatusBadge domain="severity" value={f.severity} />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<Link
className="truncate font-medium hover:underline"
href={`/function/findings/detail?id=${id}${contextTask ? `&context_task=${contextTask}` : ""}`}
>
#{id} {[f.name, f.vulnclass].find((v) => v?.trim()) ?? "未分类"}
</Link>
<span className="truncate text-muted-foreground text-xs">{f.summary}</span>
</div>
{!matched ? <Badge variant="outline">未命中当前筛选</Badge> : null}
{f.inherited ? <Badge variant="outline">继承 · 只读</Badge> : null}
<StatusBadge domain="finding" value={f.status} />
<Button asChild variant="ghost" size="sm">
<Link href={`/function/findings/detail?id=${id}${contextTask ? `&context_task=${contextTask}` : ""}`}>
查看报告
</Link>
</Button>
</div>
);
}
export function FindingCaseMembers({
caseId,
version,
matchedIds,
selectedIds,
onSelect,
contextTask,
}: {
caseId: string;
version?: number;
matchedIds?: number[];
selectedIds?: Set<string>;
onSelect?: (id: string, checked: boolean) => void;
contextTask?: string;
}) {
const [page, setPage] = React.useState(1);
const [data, setData] = React.useState<{ items: Finding[]; total: number } | null>(null);
const [error, setError] = React.useState("");
const [retry, setRetry] = React.useState(0);
// biome-ignore lint/correctness/useExhaustiveDependencies: version and retry deliberately reload changed evidence.
React.useEffect(() => {
let active = true;
setError("");
api
.findingCaseMembers(caseId, page, contextTask)
.then((v) => {
if (active) setData(v);
})
.catch((e) => {
if (active) setError((e as Error).message);
});
return () => {
active = false;
};
}, [caseId, page, version, contextTask, retry]);
if (error)
return (
<Alert>
<AlertDescription>
成员加载失败:{error}
<Button variant="link" onClick={() => setRetry((v) => v + 1)}>
重试
</Button>
</AlertDescription>
</Alert>
);
if (!data)
return (
<div className="flex justify-center p-5">
<Spinner />
</div>
);
return (
<div className="flex min-w-0 flex-col">
{data.items.map((f) => (
<FindingCaseMemberRow
key={f.finding_id}
finding={f}
selected={selectedIds?.has(f.finding_id ?? f.id)}
onSelect={onSelect}
contextTask={contextTask}
matched={!matchedIds || matchedIds.includes(Number(f.finding_id))}
/>
))}
{data.total > 20 ? (
<TablePagination
page={page}
pageSize={20}
total={data.total}
onPageChange={setPage}
onPageSizeChange={() => setPage(1)}
pageSizeOptions={[20]}
/>
) : null}
</div>
);
}
export function FindingCaseReviewPanel({ taskId, onChange }: { taskId?: string; onChange?: () => void }) {
const [suggestions, setSuggestions] = React.useState<FindingCaseSuggestion[]>([]);
const [runs, setRuns] = React.useState<FindingCaseReviewRun[]>([]);
const [error, setError] = React.useState("");
React.useEffect(() => {
let active = true;
let timer: ReturnType<typeof setTimeout>;
async function load() {
try {
const [s, r] = await Promise.all([api.findingCaseSuggestions(taskId), api.findingCaseReviewRuns()]);
if (active) {
setSuggestions(s);
setRuns(r.filter((x) => !taskId || x.task_id === taskId));
setError("");
}
} catch (e) {
if (active) setError((e as Error).message);
} finally {
if (active) timer = setTimeout(load, 5000);
}
}
void load();
return () => {
active = false;
clearTimeout(timer);
};
}, [taskId]);
async function resolve(s: FindingCaseSuggestion, accept: boolean) {
try {
const result = await api.resolveFindingCaseSuggestion(s.id, accept);
setSuggestions((v) => v.filter((x) => x.id !== s.id));
if (accept && result.case_id !== "0") {
toast.success("已归并,统一报告待生成");
} else {
toast.success("已否决,不会自动再次归并");
}
onChange?.();
} catch (e) {
toast.error((e as Error).message);
}
}
return (
<div className="flex flex-col gap-2">
{error ? (
<Alert>
<AlertDescription>整理状态加载失败:{error}</AlertDescription>
</Alert>
) : null}
{suggestions.map((s) => (
<Alert key={s.id}>
<AlertDescription>
<div className="flex flex-wrap items-center gap-2">
<span className="flex-1">
疑似重复:#{s.left_id} / #{s.right_id} · {s.title}。{s.reason}
</span>
<Button variant="outline" size="sm" onClick={() => resolve(s, true)}>
确认归并
</Button>
<Button variant="ghost" size="sm" onClick={() => resolve(s, false)}>
不是同一漏洞
</Button>
</div>
</AlertDescription>
</Alert>
))}
{runs.slice(0, 5).map((r) => (
<div key={r.conversation_id} className="flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
<span>
任务 #{r.task_id} 整理:{{ queued: "排队中", running: "运行中", done: "已完成", failed: "失败" }[r.state]}
</span>
{r.error ? <span className="text-destructive">{r.error}</span> : null}
<Link className="underline" href={`/chat?c=${r.conversation_id}`}>
查看执行记录
</Link>
</div>
))}
</div>
);
}
export function FindingCaseList({
query,
selectedIds,
onSelect,
contextTask,
readOnly = false,
onTotal,
}: {
query: Omit<FindingQuery, "page" | "pageSize">;
selectedIds?: Set<string>;
onSelect?: (id: string, checked: boolean) => void;
contextTask?: string;
readOnly?: boolean;
onTotal?: (total: number) => void;
}) {
const [page, setPage] = React.useState(1);
const [data, setData] = React.useState<FindingCasePage | null>(null);
const [error, setError] = React.useState("");
const [open, setOpen] = React.useState<Set<string>>(() => new Set());
const [refresh, setRefresh] = React.useState(0);
const key = JSON.stringify(query);
// biome-ignore lint/correctness/useExhaustiveDependencies: reset pagination when the filter fingerprint changes.
React.useEffect(() => {
setPage(1);
}, [key]);
// biome-ignore lint/correctness/useExhaustiveDependencies: refresh is an explicit reload requested after grouping/retry.
React.useEffect(() => {
let active = true;
let timer: ReturnType<typeof setTimeout>;
async function load() {
try {
const v = await api.findingCases({ ...JSON.parse(key), page, pageSize: 20 });
if (active) {
setData(v);
setError("");
}
} catch (e) {
if (active) setError((e as Error).message);
} finally {
if (active) timer = setTimeout(load, 5000);
}
}
void load();
return () => {
active = false;
clearTimeout(timer);
};
}, [key, page, refresh]);
React.useEffect(() => {
if (data) onTotal?.(data.total);
}, [data, onTotal]);
const toggle = (id: string) =>
setOpen((v) => {
const next = new Set(v);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
return (
<div className="flex min-w-0 flex-col gap-3">
{!readOnly ? (
<FindingCaseReviewPanel
taskId={query.task === "all" ? undefined : query.task}
onChange={() => setRefresh((v) => v + 1)}
/>
) : null}
{error ? (
<Alert>
<AlertDescription>
加载失败:{error}
<Button variant="link" onClick={() => setRefresh((v) => v + 1)}>
重试
</Button>
</AlertDescription>
</Alert>
) : null}
{!data && !error ? (
<div className="flex justify-center p-6">
<Spinner />
</div>
) : null}
{data ? (
<p className="text-muted-foreground text-xs">
{data.stats.total} 个独立漏洞 · {data.stats.reports} 条上报
{data.stats.unassessed > 0 ? ` · ${data.stats.unassessed} 个待评估` : ""}
</p>
) : null}
{data?.items.map((row) => {
if (row.case) {
const group = row.case;
return (
<Card key={`case:${group.id}`} className="gap-0 overflow-hidden py-0">
<CardHeader className="px-4 py-3">
<div className="flex min-w-0 flex-wrap items-center gap-3">
<Button
variant="ghost"
size="icon-sm"
aria-label={`${open.has(group.id) ? "收起" : "展开"}${group.title}`}
aria-expanded={open.has(group.id)}
onClick={() => toggle(group.id)}
>
<ChevronRightIcon className={cn(open.has(group.id) && "rotate-90")} />
</Button>
<FolderIcon className="size-4 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-1 basis-2/3 flex-col gap-1 sm:basis-auto">
<CardTitle className="truncate text-sm">
<Link
className="hover:underline"
href={`/function/findings/case?id=${group.id}${contextTask ? `&context_task=${contextTask}` : ""}`}
>
{group.title}
</Link>
</CardTitle>
<CardDescription>
{group.count} 条上报 ·{" "}
{group.report_version !== group.version ? "统一报告待更新" : "统一报告已生成"}
</CardDescription>
</div>
<FindingSeverityCounts counts={group} />
<Badge variant="outline">
{group.severity
? `统一评级:${{ critical: "严重", high: "高危", medium: "中危", low: "低危" }[group.severity]}`
: "待评估"}
</Badge>
</div>
</CardHeader>
{open.has(group.id) ? (
<CardContent className="border-t px-0">
<FindingCaseMembers
caseId={group.id}
version={group.version}
matchedIds={row.matched_ids}
selectedIds={selectedIds}
onSelect={readOnly ? undefined : onSelect}
contextTask={contextTask}
/>
</CardContent>
) : null}
</Card>
);
}
if (row.finding)
return (
<Card key={`finding:${row.finding.finding_id}`} className="gap-0 py-0">
<CardContent className="px-0">
<FindingCaseMemberRow
finding={row.finding}
selected={selectedIds?.has(row.finding.finding_id ?? row.finding.id)}
onSelect={readOnly ? undefined : onSelect}
contextTask={contextTask}
/>
</CardContent>
</Card>
);
return null;
})}
{data?.total === 0 ? <p className="p-6 text-center text-muted-foreground text-sm">暂无匹配漏洞</p> : null}
{data ? (
<TablePagination
page={page}
pageSize={20}
total={data.total}
onPageChange={setPage}
onPageSizeChange={() => setPage(1)}
pageSizeOptions={[20]}
/>
) : null}
</div>
);
}
+45 -2
View File
@@ -37,6 +37,10 @@ import type {
ExplorationNodeQuery,
Finding,
FindingAssetTree,
FindingCaseDetail,
FindingCasePage,
FindingCaseReviewRun,
FindingCaseSuggestion,
FindingDeepenResponse,
FindingGroupsPage,
FindingQuery,
@@ -555,6 +559,42 @@ export const api = {
// 节点带子树聚合计数。不分页——树是导航结构,一次取完。
findingAssetTree: (q: Omit<FindingQuery, "page" | "pageSize">) =>
get<FindingAssetTree>(`/exploration/findings/asset-tree?${findingFilterParams(q).toString()}`),
findingCases: (q: FindingQuery) => {
const p = findingFilterParams(q);
p.set("page", String(q.page));
p.set("limit", String(q.pageSize));
return get<FindingCasePage>(`/exploration/finding-cases?${p}`);
},
getFindingCase: (id: string, contextTask?: string) =>
get<FindingCaseDetail>(
`/exploration/finding-cases/${id}${contextTask ? `?context_task=${encodeURIComponent(contextTask)}` : ""}`,
),
findingCaseMembers: (id: string, page = 1, contextTask?: string) =>
get<FindingsPage>(
`/exploration/finding-cases/${id}/members?page=${page}&limit=20${contextTask ? `&context_task=${encodeURIComponent(contextTask)}` : ""}`,
),
removeFindingCaseMember: (id: string, fid: string, reason: string) =>
http<{ ok: boolean }>(`/exploration/finding-cases/${id}/members/${fid}`, {
method: "DELETE",
body: JSON.stringify({ reason }),
}),
findingCaseSuggestions: (task?: string) =>
get<FindingCaseSuggestion[]>(
`/exploration/finding-case-suggestions${task ? `?task_id=${encodeURIComponent(task)}` : ""}`,
),
resolveFindingCaseSuggestion: (id: string, accept: boolean) =>
post<{ case_id: string }>(`/exploration/finding-case-suggestions/${id}`, { accept }),
reviewFindingCases: (ids: string[]) =>
post<{ runs: { conversation_id: number; task_id: string }[] }>("/exploration/finding-case-review", {
finding_ids: ids,
}),
regenerateFindingCase: (id: string, contextTask?: string) =>
post<{ runs: { conversation_id: number; task_id: string }[] }>(
`/exploration/finding-case-review${contextTask ? `?context_task=${encodeURIComponent(contextTask)}` : ""}`,
{ case_id: id },
),
findingCaseReviewRuns: () => get<FindingCaseReviewRun[]>("/exploration/finding-case-review"),
findingStats: () => get<FindingStats>("/exploration/findings/stats"),
// exportFindings 触发发现页导出并下载文件。scope=selected 时传 ids(finding_id 列表);
// scope=filtered 时传当前筛选(沿用 FindingQuery 的筛选字段);scope=all 忽略筛选。
@@ -563,8 +603,12 @@ export const api = {
scope: "filtered" | "all" | "selected";
filters?: Omit<FindingQuery, "page" | "pageSize">;
ids?: string[];
includeOriginals?: boolean;
consolidated?: boolean;
}) => {
const p = new URLSearchParams({ format: opts.format, scope: opts.scope });
p.set("mode", opts.consolidated === false ? "raw" : "consolidated");
p.set("include_originals", String(opts.includeOriginals ?? false));
if (opts.scope === "selected") {
p.set("ids", (opts.ids ?? []).join(","));
} else if (opts.scope === "filtered" && opts.filters) {
@@ -825,8 +869,7 @@ export const api = {
// 渠道是多实例资源(同一类型可配多个机器人、各有过滤规则),因此独立成组,
// 不塞进扁平的 settings 键值里。
notifyMeta: () => get<NotificationMeta>(`/notify/meta`),
notifyChannels: () =>
get<{ channels: NotificationChannel[] }>(`/notify/channels`).then((r) => arr(r.channels)),
notifyChannels: () => get<{ channels: NotificationChannel[] }>(`/notify/channels`).then((r) => arr(r.channels)),
notifyCreateChannel: (payload: {
name: string;
kind: string;
+29
View File
@@ -1843,6 +1843,35 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
return { deleted: 1, assets_deleted: assetsDeleted };
}
// Demo mode keeps raw observations as independent findings, without executing a model.
if (path === "/exploration/finding-case-suggestions" || (path === "/exploration/finding-case-review" && m === "GET"))
return [];
if (path === "/exploration/finding-case-review" && m === "POST")
throw new Error("演示模式不执行 Reporter 整理,请在已配置模型的实例中使用");
if (path === "/exploration/finding-cases" && m === "GET") {
const list = mockApplyAssetScope(mockFilterFindings(q), q.get("asset_scope"));
const ranks = { critical: 4, high: 3, medium: 2, low: 1 } as const;
list.sort(
(a, b) =>
(q.get("sort") === "severity" ? ranks[b.severity] - ranks[a.severity] : 0) || +new Date(b.ts) - +new Date(a.ts),
);
const page = Math.max(1, Number(q.get("page")) || 1);
const size = Math.min(100, Math.max(1, Number(q.get("limit")) || 20));
const scoped = mockFindings.filter((f) => !q.get("task_id") || f.task_id === q.get("task_id"));
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
for (const f of scoped) counts[f.severity]++;
return {
items: list.slice((page - 1) * size, page * size).map((f) => ({
finding: { ...f, report: undefined, finding_id: f.id, traffic_count: mockTrafficCount(f.id) },
matched_ids: [],
})),
total: list.length,
page,
page_size: size,
stats: { total: scoped.length, reports: scoped.length, unassessed: 0, ...counts },
};
}
// ── exploration ──
if (path === "/exploration/frontier") return D.frontier;
if (path === "/exploration/findings/stats") {
+70
View File
@@ -5,6 +5,7 @@ export type TaskStatus = "created" | "queued" | "running" | "paused" | "done" |
export type EngineMode = "exploring" | "paused" | "stalled" | "idle";
export interface Task {
distinct_findings?: FindingDistinctStats;
id: string;
name?: string; // 可选任务名称;空/缺省=未命名,展示时回退到描述
category_id?: number;
@@ -489,6 +490,7 @@ export interface FindingAsset {
}
export interface Finding {
case_id?: string;
traffic_count?: number;
evidence_version?: number;
report_evidence_version?: number;
@@ -550,6 +552,7 @@ export interface FindingDeepenResponse {
// FindingStats 是发现全表聚合(统计卡 + 漏洞类型下拉),服务端计算,不受分页影响。
export interface FindingStats {
distinct?: FindingDistinctStats;
total: number;
pending: number;
critical: number;
@@ -1610,3 +1613,70 @@ export interface InterceptExecution {
seq: number;
items: Activity[];
}
export interface FindingDistinctStats {
total: number;
reports: number;
critical: number;
high: number;
medium: number;
low: number;
unassessed: number;
}
export interface FindingCase {
id: string;
task_id: string | null;
title: string;
reason: string;
severity: Severity | "";
severity_reason: string;
report?: string;
version: number;
report_version: number;
active: boolean;
count: number;
critical: number;
high: number;
medium: number;
low: number;
created_at: string;
}
export interface FindingCaseListRow {
case?: FindingCase | null;
finding?: Finding;
matched_ids: number[];
}
export interface FindingCasePage {
items: FindingCaseListRow[];
total: number;
page: number;
page_size: number;
stats: FindingDistinctStats;
}
export interface FindingCaseEvent {
id: string;
action: string;
actor: string;
reason: string;
finding_ids: number[];
created_at: string;
}
export interface FindingCaseDetail {
case: FindingCase;
events: FindingCaseEvent[];
}
export interface FindingCaseSuggestion {
id: string;
task_id: string;
left_id: string;
right_id: string;
title: string;
reason: string;
state: string;
}
export interface FindingCaseReviewRun {
conversation_id: number;
task_id: string;
state: "queued" | "running" | "done" | "failed";
error: string;
}