mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-03 19:23:51 +08:00
feat(findings): consolidate duplicate reports into expandable cases
This commit is contained in:
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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user