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

+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;
}