diff --git a/README.md b/README.md index 3205dce..69d6e49 100644 --- a/README.md +++ b/README.md @@ -335,7 +335,7 @@ server { ### 同一漏洞归并与统一报告 -发现页默认「原始上报」,原始上报、按漏洞、按任务及按资产视图均展示可展开的归并文件夹。同一任务内,Reporter 依据目标、接口参数、权限边界、根因及验证证据确认同一实际缺陷后形成文件夹;仅类型或名称相同不会作为自动归并依据。严重程度、接口、后端或读写影响不同仍可能是同一漏洞;反之,共用登录入口或令牌不能单独证明同一根因。有关联但共同根因无法确认时提交人工建议。文件夹的 `严/高/中/低` 数量按原始记录统计,例如 `0/1/1/1`;发现页的独立漏洞数按文件夹去重,等级数量按原始上报统计,列表及文件夹详情不显示统一评级标签。原始上报内容、评级、状态、报告及证据不被覆盖。 +发现页默认「按漏洞」。原始上报、按任务及按资产保留原来的表格、状态和操作按钮,仅已归并记录改为可展开的漏洞文件夹。同一任务内,Reporter 依据目标、接口参数、权限边界、根因及验证证据确认同一实际缺陷后形成文件夹;仅类型或名称相同不会作为自动归并依据。严重程度、接口、后端或读写影响不同仍可能是同一漏洞;反之,共用登录入口或令牌不能单独证明同一根因。有关联但共同根因无法确认时提交人工建议。文件夹的 `严/高/中/低` 数量按原始记录统计,例如 `0/1/1/1`;发现页的独立漏洞数按文件夹去重,等级数量按原始上报统计,列表及文件夹详情不显示统一评级标签。原始上报内容、评级、状态、报告及证据不被覆盖。 点击箭头展开原始记录,点击文件夹标题查看统一报告、原始记录及归并历史。证据不足的建议需要点击「确认归并」;否决或移出后,这些关系不会被 Agent 自动再次归并。移出成员不删除原漏洞,不足两条时解散文件夹并保留历史。继承任务的漏洞组保持只读。 @@ -347,7 +347,7 @@ server { 任务详情的「复测」页签可分页选择本任务的漏洞、查看历次结论和证据,并手动发起复测。启动后保留当前页签,显示转圈图标和「复测中」;确认修复后同步更新漏洞状态。 -在漏洞列表原始记录的「更多操作」中点击「复测漏洞」,或在漏洞详情的「漏洞复测」区域点击「发起复测」,填写可选的修复版本、测试条件或限制,系统会创建独立的复测 Agent 会话,启动后保留当前页面。列表的平铺、按任务分组和资产视图均支持该入口;复测运行时显示转圈图标和「复测中」,需要查看时点击进入对应会话,结束后恢复「复测」。复测无需重新启动原扫描任务,结论分为「仍可复现」「已修复」「无法确认」,每次的结论、证据和会话链接保存在漏洞详情中。 +在漏洞列表每行操作区点击「复测」,或在漏洞详情的「漏洞复测」区域点击「发起复测」,填写可选的修复版本、测试条件或限制,系统会创建独立的复测 Agent 会话,启动后保留当前页面。列表的平铺、按任务分组和资产视图均支持该入口;复测运行时显示转圈图标和「复测中」,需要查看时点击进入对应会话,结束后恢复「复测」。复测无需重新启动原扫描任务,结论分为「仍可复现」「已修复」「无法确认」,每次的结论、证据和会话链接保存在漏洞详情中。 新版后端首次启动会预置可编辑的「漏洞复测」(`retester`)Agent,可在 Agent 管理中配置提示词、LLM、运行预算和工具。默认使用其绑定的 LLM,未绑定则使用全局激活配置。复测会话成功完成且结论为「已修复」时,系统自动将漏洞处置状态改为「已修复」;执行中、失败、停止或其他结论保留原状态。原始证据和报告始终保留。也可在状态下拉菜单中手动选择「已修复」。同一漏洞正在复测时复用已有会话,停止、失败或服务重启后可重新发起。 diff --git a/db/finding_cases.go b/db/finding_cases.go index 64600ae..d7ec976 100644 --- a/db/finding_cases.go +++ b/db/finding_cases.go @@ -559,9 +559,13 @@ func (d *DB) DistinctFindingStatsByTask() (map[string]*FindingDistinctStats, err return out, rows.Err() } -// Folder rows and candidate searches never fetch full report/PoC bodies. -func (d *DB) FindingCaseSummary(id int64) (*DBFinding, error) { +// Folder rows and candidate searches omit PoC bodies. The original table can +// request evidence for ungrouped rows without fetching report bodies. +func (d *DB) FindingCaseSummary(id int64, originalRow ...bool) (*DBFinding, error) { cols := strings.Replace(findingSelectCols, "f.evidence,", "''::text,", 1) + if len(originalRow) > 0 && originalRow[0] { + cols = findingSelectCols + } rows, err := d.Query(`SELECT `+cols+` FROM findings f LEFT JOIN tasks t ON t.id=f.task_id WHERE f.id=$1`, id) if err != nil { return nil, err diff --git a/server/finding_cases.go b/server/finding_cases.go index df93c37..d8afaf8 100644 --- a/server/finding_cases.go +++ b/server/finding_cases.go @@ -87,7 +87,7 @@ func (s *Server) listFindingCases(w http.ResponseWriter, r *http.Request) { v["assets"] = append([]FindingAssetDTO{}, findingFromDB(&db.DBFinding{AssetIDs: c.AssetIDs}, assets).Assets...) } if row.FindingID > 0 { - f, err := s.m.pg.FindingCaseSummary(row.FindingID) + f, err := s.m.pg.FindingCaseSummary(row.FindingID, q.Get("original_rows") == "1") if err != nil { caseHTTPError(w, err) return diff --git a/server/finding_cases_test.go b/server/finding_cases_test.go index feb491e..09037d8 100644 --- a/server/finding_cases_test.go +++ b/server/finding_cases_test.go @@ -352,3 +352,44 @@ func TestFindingCaseListIncludesContextAndFilteredCounts(t *testing.T) { t.Fatalf("list payload %s", w.Body.String()) } } + +func TestOriginalFindingRowsKeepEvidenceAndOmitReportBody(t *testing.T) { + s, task, ids := testCaseServer(t) + if _, err := s.m.pg.Exec(`UPDATE findings SET report=$2 WHERE id=$1`, ids[0], "large original report"); err != nil { + t.Fatal(err) + } + for _, original := range []bool{false, true} { + suffix := "" + if original { + suffix = "&original_rows=1" + } + r := httptest.NewRequest("GET", fmt.Sprintf("/api/finding-cases?task_id=%d%s", task, suffix), nil) + w := httptest.NewRecorder() + s.listFindingCases(w, r) + if w.Code != 200 { + t.Fatalf("status %d %s", w.Code, w.Body) + } + var page struct { + Items []struct { + Finding FindingDTO `json:"finding"` + } `json:"items"` + } + if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil { + t.Fatal(err) + } + if len(page.Items) != 3 { + t.Fatalf("lost original rows %s", w.Body) + } + for _, row := range page.Items { + if row.Finding.Report != "" { + t.Fatal("list loaded report body") + } + if original && !strings.HasPrefix(row.Finding.Evidence, "proof ") { + t.Fatal("original table evidence missing") + } + if !original && row.Finding.Evidence != "" { + t.Fatal("compact list should omit evidence") + } + } + } +} diff --git a/web/src/app/(main)/function/findings/_components/findings-table.tsx b/web/src/app/(main)/function/findings/_components/findings-table.tsx index 17ca1c5..cfa65c8 100644 --- a/web/src/app/(main)/function/findings/_components/findings-table.tsx +++ b/web/src/app/(main)/function/findings/_components/findings-table.tsx @@ -15,6 +15,7 @@ import { } from "lucide-react"; import { CopyButton } from "@/components/copy-button"; +import { FindingCaseFolder } from "@/components/finding-case-list"; import { Markdown } from "@/components/markdown"; import { StatusBadge } from "@/components/status-badge"; import { @@ -37,7 +38,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Spinner } from "@/components/ui/spinner"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { statusMeta } from "@/lib/status"; -import type { ActiveFindingRetest, Finding, FindingStatus, Severity } from "@/lib/types"; +import type { ActiveFindingRetest, Finding, FindingCaseListRow, FindingStatus, Severity } from "@/lib/types"; import { cn } from "@/lib/utils"; export const SEVERITIES: Severity[] = ["critical", "high", "medium", "low"]; @@ -90,6 +91,9 @@ const COLUMN_COUNT = 9; interface FindingsTableProps { items: Finding[]; + caseRows?: FindingCaseListRow[]; + matchedIds?: number[]; + presentation?: "records" | "task" | "asset"; selectedIds: Set; onToggleSelected: (id: string, checked: boolean) => void; onToggleSelectedPage: (ids: string[], checked: boolean) => void; @@ -114,6 +118,9 @@ interface FindingsTableProps { // (勾选 / 行内展开 / 行内改名与改状态 / 复测 / 深入 / 删除),差异只在外层容器与分页。 export function FindingsTable({ items, + caseRows, + matchedIds, + presentation = "records", selectedIds, onToggleSelected, onToggleSelectedPage, @@ -140,6 +147,8 @@ export function FindingsTable({ headerChecked = "indeterminate"; } + const rows: FindingCaseListRow[] = caseRows ?? items.map((finding) => ({ finding, matched_ids: [] })); + return ( /* 固定列宽保证展开内容不撑开表格;窄屏只在表格内部横向滚动。 */ @@ -163,14 +172,56 @@ export function FindingsTable({ - {items.map((f) => { + {rows.map((row) => { + if (row.case) + return ( + + + ( + + )} + /> + + + ); + const f = row.finding; + if (!f) return null; const rowKey = findingRowKey(f); const open = expandedKey === rowKey; const retest = f.finding_id ? activeRetests[f.finding_id] : undefined; return ( {f.name || f.vulnclass || "未分类"} )} {f.summary} + {matchedIds && !matchedIds.includes(Number(f.finding_id)) ? ( + + 未命中当前筛选 + + ) : null} 流量证据 {f.traffic_count ?? 0} 条 @@ -441,7 +497,7 @@ export function FindingsTable({ ); })} - {items.length === 0 && ( + {(caseRows ? caseRows.length : items.length) === 0 && ( 没有匹配的发现。 diff --git a/web/src/app/(main)/function/findings/page.tsx b/web/src/app/(main)/function/findings/page.tsx index 96f77f6..4c59d2a 100644 --- a/web/src/app/(main)/function/findings/page.tsx +++ b/web/src/app/(main)/function/findings/page.tsx @@ -17,7 +17,7 @@ import { } from "lucide-react"; import { toast } from "sonner"; -import { FindingCaseList, FindingCaseReviewPanel } from "@/components/finding-case-list"; +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"; @@ -47,6 +47,7 @@ import type { ActiveFindingRetest, Finding, FindingAssetNode, + FindingCaseListRow, FindingGroup, FindingStats, FindingStatus, @@ -55,11 +56,22 @@ import type { import { cn } from "@/lib/utils"; import { AssetTree, assetPathOf } from "./_components/asset-tree"; -import { FINDING_STATUSES, fmtTime, SEVERITIES, UNASSIGNED_TASK } from "./_components/findings-table"; +import { + FINDING_STATUSES, + type FindingEdit, + type FindingReport, + FindingsTable, + findingRowKey, + fmtTime, + isSameFinding, + SEVERITIES, + UNASSIGNED_TASK, +} from "./_components/findings-table"; const FINDING_LIST_PREFERENCE_KEY = "artex_finding_list_preferences"; -// 所有视图共用漏洞文件夹;原始上报展示更多来源信息,任务和资产视图增加外层导航。 +// 列表视图:flat = 跨任务平铺大表(默认);grouped = 按任务分组折叠; +// asset = 左侧资产树 + 右侧该子树下的发现。 type FindingView = "cases" | "flat" | "grouped" | "asset"; const FINDING_VIEWS: FindingView[] = ["cases", "flat", "grouped", "asset"]; @@ -84,6 +96,28 @@ const EMPTY_ASSET_TREE: AssetTreeState = { loading: false, }; +// 分组视图里每个已展开任务组自带一份分页状态,彼此独立。 +interface GroupFindingsState { + caseRows: FindingCaseListRow[]; + items: Finding[]; + total: number; + page: number; + pageSize: number; + loaded: boolean; + loading: boolean; +} + +// 平铺视图的页码单独放 state(而非塞进快照),筛选一变就能连带重置并触发重新加载。 +interface FlatFindingsState { + caseRows: FindingCaseListRow[]; + items: Finding[]; + total: number; + loaded: boolean; + loading: boolean; +} + +const EMPTY_FLAT_STATE: FlatFindingsState = { caseRows: [], items: [], total: 0, loaded: false, loading: false }; + function findingGroupKey(group: FindingGroup) { return group.task_id === null ? UNASSIGNED_TASK : String(group.task_id); } @@ -100,7 +134,7 @@ const EMPTY_STATS: FindingStats = { }; export default function FindingsPage() { - const [view, setView] = React.useState("flat"); + const [view, setView] = React.useState("cases"); const [severity, setSeverity] = React.useState<"all" | Severity>("all"); const [status, setStatus] = React.useState<"all" | FindingStatus>("all"); const [vulnclass, setVulnclass] = React.useState("all"); @@ -108,12 +142,16 @@ export default function FindingsPage() { const [sort, setSort] = React.useState<"severity" | "time">("severity"); const [search, setSearch] = React.useState(""); const [query, setQuery] = React.useState(""); + const [expanded, setExpanded] = React.useState(null); + const [flat, setFlat] = React.useState(EMPTY_FLAT_STATE); + const [flatPage, setFlatPage] = React.useState(1); + const [flatPageSize, setFlatPageSize] = React.useState(20); const [assetTree, setAssetTree] = React.useState(EMPTY_ASSET_TREE); const [assetScope, setAssetScope] = React.useState(null); const [groups, setGroups] = React.useState([]); const [groupTotal, setGroupTotal] = React.useState(0); const [expandedGroups, setExpandedGroups] = React.useState>(() => new Set()); - const [listRefresh, setListRefresh] = React.useState(0); + const [groupFindings, setGroupFindings] = React.useState>({}); const [total, setTotal] = React.useState(0); const [stats, setStats] = React.useState(EMPTY_STATS); const [statsLoaded, setStatsLoaded] = React.useState(false); @@ -201,6 +239,28 @@ export default function FindingsPage() { return () => window.clearTimeout(timer); }, [search]); + // setFindings 同时改写两个视图缓存里的同一条发现,切换视图不会看到过期状态。 + const setFindings = React.useCallback((update: (current: Finding[]) => Finding[]) => { + function applyUpdate(current: T): T { + const items = update(current.items); + const byId = new Map(items.map((f) => [findingRowKey(f), f])); + const caseRows = current.caseRows.flatMap((row) => { + if (row.case) return [row]; + const finding = row.finding ? byId.get(findingRowKey(row.finding)) : undefined; + return finding ? [{ ...row, finding }] : []; + }); + return { ...current, items, caseRows }; + } + setFlat(applyUpdate); + setGroupFindings((current) => { + const next: Record = {}; + for (const [key, state] of Object.entries(current)) { + next[key] = applyUpdate(state); + } + return next; + }); + }, []); + // 勾选导出:按 finding_id(独立表 id)记选中项,跨页保留。 const [selectedIds, setSelectedIds] = React.useState>(() => new Set()); // 导出弹窗状态:范围(当前筛选/全部/选中) × 格式(md 单文件/md 分文件 zip/csv/json)。 @@ -220,6 +280,17 @@ export default function FindingsPage() { }); }, []); + const toggleSelectedPage = React.useCallback((ids: string[], checked: boolean) => { + setSelectedIds((prev) => { + const next = new Set(prev); + for (const id of ids) { + if (checked) next.add(id); + else next.delete(id); + } + return next; + }); + }, []); + // 打开导出弹窗时,若有勾选项则默认范围切到「选中」,否则「当前筛选」。 function openExport() { setExportScope(selectedIds.size > 0 ? "selected" : "filtered"); @@ -253,8 +324,57 @@ export default function FindingsPage() { } } + const flatRequest = React.useRef(0); const assetTreeRequest = React.useRef(0); + const groupRequests = React.useRef>({}); const groupsRequest = React.useRef(0); + const flatStateRef = React.useRef(flat); + const expandedGroupsRef = React.useRef(expandedGroups); + const groupFindingsRef = React.useRef(groupFindings); + const visibleGroupKeysRef = React.useRef>(new Set()); + flatStateRef.current = flat; + expandedGroupsRef.current = expandedGroups; + groupFindingsRef.current = groupFindings; + visibleGroupKeysRef.current = new Set(groups.map(findingGroupKey)); + + // 资产视图右侧列表 = 平铺列表 + 选中子树的筛选,所以两个视图共用一份列表状态。 + const activeAssetScope = view === "asset" ? assetScope : null; + + // loadFlat 拉取平铺视图的当前页;task 筛选交给后端,与分组视图共用同一批筛选条件。 + const loadFlat = React.useCallback(async () => { + const requestFilter = filterFingerprint; + if (activeFilterFingerprint.current !== requestFilter) return; + const request = ++flatRequest.current; + setFlat((current) => ({ ...current, loading: true })); + try { + const result = await api.findingCases( + { + page: flatPage, + pageSize: flatPageSize, + severity, + status, + vulnclass, + task, + query, + sort, + assetScope: activeAssetScope ?? undefined, + }, + true, + ); + if (request !== flatRequest.current || activeFilterFingerprint.current !== requestFilter) return; + setFlat({ + caseRows: result.items, + items: result.items.flatMap((row) => (row.finding ? [row.finding] : [])), + total: result.total, + loaded: true, + loading: false, + }); + } catch { + if (request !== flatRequest.current || activeFilterFingerprint.current !== requestFilter) return; + // Polling keeps the last successful snapshot visible. + setFlat((current) => ({ ...current, loading: false })); + } + }, [activeAssetScope, filterFingerprint, flatPage, flatPageSize, severity, status, vulnclass, task, query, sort]); // loadAssetTree 取整棵资产树。树不随选中节点变化(否则选一下就塌成一条链), // 所以这里不带 assetScope。 @@ -305,57 +425,184 @@ export default function FindingsPage() { } }, [filterFingerprint, page, pageSize, severity, status, vulnclass, task, query, sort]); - const toggleGroup = React.useCallback((key: string) => { - setExpandedGroups((current) => { - const next = new Set(current); - if (next.has(key)) next.delete(key); - else next.add(key); - return next; - }); - }, []); - - const refreshAfterMutation = React.useCallback( - (_finding?: Finding, _removed = false) => { - setListRefresh((v) => v + 1); - if (view === "asset") void loadAssetTree(); - if (view === "grouped") void refreshGroups(); - void api - .findingStats() - .then(setStats) - .catch(() => { - /* Keep aggregate snapshot until the next poll. */ - }); + const loadGroup = React.useCallback( + async (key: string, groupPage: number, groupPageSize: number) => { + const request = (groupRequests.current[key] ?? 0) + 1; + const requestFilter = filterFingerprint; + if (activeFilterFingerprint.current !== requestFilter) return; + groupRequests.current[key] = request; + setGroupFindings((current) => ({ + ...current, + [key]: { + caseRows: current[key]?.caseRows ?? [], + items: current[key]?.items ?? [], + total: current[key]?.total ?? 0, + page: groupPage, + pageSize: groupPageSize, + loaded: current[key]?.loaded ?? false, + loading: true, + }, + })); + try { + const result = await api.findingCases( + { + page: groupPage, + pageSize: groupPageSize, + severity, + status, + vulnclass, + task: key, + query, + sort, + }, + true, + ); + if (groupRequests.current[key] !== request || activeFilterFingerprint.current !== requestFilter) return; + setGroupFindings((current) => ({ + ...current, + [key]: { + caseRows: result.items, + items: result.items.flatMap((row) => (row.finding ? [row.finding] : [])), + total: result.total, + page: result.page, + pageSize: result.page_size, + loaded: true, + loading: false, + }, + })); + } catch { + if (groupRequests.current[key] !== request || activeFilterFingerprint.current !== requestFilter) return; + setGroupFindings((current) => ({ + ...current, + [key]: { + ...(current[key] ?? { + caseRows: [], + items: [], + total: 0, + page: groupPage, + pageSize: groupPageSize, + loaded: false, + }), + loading: false, + }, + })); + } }, - [loadAssetTree, refreshGroups, view], + [filterFingerprint, severity, status, vulnclass, query, sort], ); + React.useEffect(() => { + for (const [key, state] of Object.entries(groupFindings)) { + if (!state.loaded || state.loading) continue; + const lastPage = Math.max(1, Math.ceil(state.total / state.pageSize)); + if (state.page > lastPage) void loadGroup(key, lastPage, state.pageSize); + } + }, [groupFindings, loadGroup]); + + const toggleGroup = React.useCallback( + (key: string) => { + const opening = !expandedGroups.has(key); + const next = new Set(expandedGroups); + if (opening) next.add(key); + else next.delete(key); + setExpandedGroups(next); + const state = groupFindings[key]; + if (opening && !state?.loaded && !state?.loading) { + void loadGroup(key, state?.page ?? 1, state?.pageSize ?? 10); + } + }, + [expandedGroups, groupFindings, loadGroup], + ); + + // 行内改动后刷新当前视图:平铺视图重拉当前页,分组视图刷组头 + 该发现所在的组。 + const refreshAfterMutation = React.useCallback( + (finding: Finding, removed = false) => { + if (view === "asset") { + // 资产视图不轮询,所以改完要顺带把树的计数也重新算一次。 + void loadFlat(); + void loadAssetTree(); + return; + } + if (view === "flat") { + // 删空最后一页时,页码由越界修正 effect 回退并连带重新加载。 + void loadFlat(); + return; + } + void refreshGroups(); + const key = finding.task_id ?? UNASSIGNED_TASK; + const state = groupFindingsRef.current[key]; + if (state?.loaded) { + const nextTotal = Math.max(0, state.total - (removed ? 1 : 0)); + const lastPage = Math.max(1, Math.ceil(nextTotal / state.pageSize)); + void loadGroup(key, Math.min(state.page, lastPage), state.pageSize); + } + }, + [loadAssetTree, loadFlat, loadGroup, refreshGroups, view], + ); + + // Reset every view's pagination and expansion when a shared finding filter changes. React.useEffect(() => { void filterFingerprint; setPage(1); + setExpanded(null); setExpandedGroups(new Set()); + setGroupFindings({}); + setFlatPage(1); + setFlat(EMPTY_FLAT_STATE); + // 筛选变了树也会变,原先选中的节点可能已经不在树里,退回「全部资产」。 setAssetScope(null); setAssetTree(EMPTY_ASSET_TREE); }, [filterFingerprint]); + // 换资产节点等于换了一份结果集,回到第一页。 + React.useEffect(() => { + void assetScope; + setFlatPage(1); + }, [assetScope]); + + // 资产树只在进入视图 / 筛选变化时查一次(以及本页改动发现后由 + // refreshAfterMutation 主动重拉),不做轮询。 React.useEffect(() => { if (!preferencesHydrated || view !== "asset") return; - void activeRetestFingerprint; + void activeRetestFingerprint; // 复测结束可能改变状态筛选下的资产计数。 void loadAssetTree(); }, [activeRetestFingerprint, loadAssetTree, preferencesHydrated, view]); + // 只轮询当前视图:平铺视图刷当前页,分组视图刷组头与每个已展开的组(其分页彼此独立)。 + // 资产视图只查一次(见下面的 return),它的左树是导航结构,没必要每 5 秒重算。 + // 等偏好水合后再发首个请求,否则会先按默认视图/筛选白拉一次。 React.useEffect(() => { - if (!preferencesHydrated || view !== "grouped") return; - void activeRetestFingerprint; - void refreshGroups(); - const timer = setInterval(() => void refreshGroups(), 5000); + if (!preferencesHydrated) return; + void activeRetestFingerprint; // 包括不定时轮询的资产视图,也在复测结束后刷新处置状态。 + const refresh = () => { + if (view === "flat" || view === "asset") { + if (!flatStateRef.current.loading) void loadFlat(); + return; + } + void refreshGroups(); + for (const key of expandedGroupsRef.current) { + if (!visibleGroupKeysRef.current.has(key)) continue; + const state = groupFindingsRef.current[key]; + if (state?.loaded && !state.loading) void loadGroup(key, state.page, state.pageSize); + } + }; + refresh(); + if (view === "asset") return; + const timer = setInterval(refresh, 5000); return () => clearInterval(timer); - }, [activeRetestFingerprint, preferencesHydrated, refreshGroups, view]); + }, [activeRetestFingerprint, loadFlat, loadGroup, preferencesHydrated, refreshGroups, view]); React.useEffect(() => { const lastPage = Math.max(1, Math.ceil(groupTotal / pageSize)); if (page > lastPage) setPage(lastPage); }, [groupTotal, page, pageSize]); + React.useEffect(() => { + if (!flat.loaded) return; + const lastPage = Math.max(1, Math.ceil(flat.total / flatPageSize)); + if (flatPage > lastPage) setFlatPage(lastPage); + }, [flat.loaded, flat.total, flatPage, flatPageSize]); + // Whole-table aggregates (stat cards + vuln-class options) — independent of the // current page, so they stay exact. React.useEffect(() => { @@ -393,30 +640,118 @@ export default function FindingsPage() { } }, [stats, statsLoaded, task, vulnclass]); + // updateStatus optimistically flips one finding's triage state, reverting on error. const updateStatus = React.useCallback( async (f: Finding, next: FindingStatus) => { - if (!f.finding_id || f.inherited || next === f.status) return; + if (!f.finding_id || next === f.status) return; + const prev = f.status; + setFindings((cur) => cur.map((x) => (isSameFinding(x, f) ? { ...x, status: next } : x))); try { await api.setFindingStatus(f.finding_id, next); toast.success(`已标记为「${statusMeta("finding", next).label}」`); + // refresh stat cards (pending count) and drop the row if it no longer matches the status filter + api + .findingStats() + .then(setStats) + .catch(() => { + // The row update remains valid even if the aggregate refresh fails. + }); + if (status !== "all" && next !== status) { + setFindings((cur) => cur.filter((x) => !isSameFinding(x, f))); + setTotal((t) => Math.max(0, t - 1)); + setFlat((cur) => ({ ...cur, total: Math.max(0, cur.total - 1) })); + } refreshAfterMutation(f); } catch (e) { + setFindings((cur) => cur.map((x) => (isSameFinding(x, f) ? { ...x, status: prev } : x))); toast.error(`更新失败:${(e as Error).message}`); } }, - [refreshAfterMutation], + [refreshAfterMutation, setFindings, status], ); + // 行内展开的详细报告缓存按全局稳定行键存。report 是大段 Markdown,列表查询不带它, + // 故展开时才按 finding_id 单独拉取一次;done 且文本为空 = 该漏洞暂无报告。 + const [reports, setReports] = React.useState>({}); + + // 行内可编辑缓冲:当前展开行的名称/类别/严重等级,展开时用该行数据初始化,收起清空。 + // 单行展开,故一份缓冲即可。 + const [edit, setEdit] = React.useState(null); + const [saving, setSaving] = React.useState(false); + + // toggle 展开/收起一行;新展开时初始化编辑缓冲,并(尚未取过时)按 finding_id 拉一次报告缓存。 + const toggleRow = React.useCallback( + (f: Finding) => { + const key = findingRowKey(f); + const willOpen = expanded !== key; + setExpanded(willOpen ? key : null); + if (!willOpen) { + setEdit(null); + return; + } + setEdit({ name: f.name ?? "", vulnclass: f.vulnclass, severity: f.severity }); + if (!f.finding_id || reports[key]) return; + const fid = f.finding_id; + setReports((r) => ({ ...r, [key]: { status: "loading", text: "" } })); + api + .getFinding(fid) + .then((full) => setReports((r) => ({ ...r, [key]: { status: "done", text: full.report ?? "" } }))) + .catch(() => setReports((r) => ({ ...r, [key]: { status: "error", text: "" } }))); + }, + [expanded, reports], + ); + + // saveEdit 保存当前展开行的名称/类别/严重等级,回写本地列表并刷新统计(类别下拉/严重计数可能变)。 + const saveEdit = React.useCallback( + async (f: Finding) => { + if (!f.finding_id || !edit) return; + setSaving(true); + try { + const updated = await api.updateFinding(f.finding_id, { + name: edit.name.trim(), + vulnclass: edit.vulnclass.trim(), + severity: edit.severity, + }); + setFindings((cur) => + cur.map((x) => + isSameFinding(x, f) + ? { ...x, name: updated.name, vulnclass: updated.vulnclass, severity: updated.severity } + : x, + ), + ); + toast.success("已保存"); + api + .findingStats() + .then(setStats) + .catch(() => { + // The edit remains valid even if the aggregate refresh fails. + }); + refreshAfterMutation(f); + } catch (e) { + toast.error(`保存失败:${(e as Error).message}`); + } finally { + setSaving(false); + } + }, + [edit, refreshAfterMutation, setFindings], + ); + + // deleteFinding 删除一个漏洞(需二次确认):删成功后从列表移除、收起行、刷新统计。 const deleteFinding = React.useCallback( async (f: Finding) => { if (!f.finding_id) return; try { await api.deleteFinding(f.finding_id); + setFindings((cur) => cur.filter((x) => !isSameFinding(x, f))); setSelectedIds((current) => { const next = new Set(current); next.delete(f.finding_id as string); return next; }); + setTotal((t) => Math.max(0, t - 1)); + setFlat((cur) => ({ ...cur, total: Math.max(0, cur.total - 1) })); + const rowKey = findingRowKey(f); + setExpanded((cur) => (cur === rowKey ? null : cur)); toast.success("已删除漏洞"); api .findingStats() @@ -429,7 +764,7 @@ export default function FindingsPage() { toast.error(`删除失败:${(e as Error).message}`); } }, - [refreshAfterMutation], + [refreshAfterMutation, setFindings], ); const openDeepen = React.useCallback((f: Finding) => { @@ -460,59 +795,78 @@ export default function FindingsPage() { const statCards = [ { label: "独立漏洞", value: stats.distinct?.total ?? stats.total, icon: BugIcon }, { label: "上报记录", value: stats.total, 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?.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 [caseTotal, setCaseTotal] = React.useState(0); - const [caseReportTotal, setCaseReportTotal] = React.useState(0); - const filteredTotal = view === "grouped" ? total : caseReportTotal; + const filteredTotal = view === "cases" ? caseTotal : view === "grouped" ? total : flat.total; const assetPath = React.useMemo( () => (view === "asset" ? assetPathOf(assetTree.nodes, assetScope) : []), [assetScope, assetTree.nodes, view], ); - const recordActions = { - onUpdated: refreshAfterMutation, + const rowProps = { + selectedIds, + onToggleSelected: toggleSelected, + onToggleSelectedPage: toggleSelectedPage, + expandedKey: expanded, + onToggleRow: toggleRow, + reports, + edit, + onEditChange: setEdit, + saving, + onSave: saveEdit, onStatusChange: updateStatus, onRetest: setRetestFinding, + activeRetests, onDeepen: openDeepen, onDelete: deleteFinding, - activeRetests, - }; - const sharedListProps = { - selectedIds, - onSelect: toggleSelected, - actions: recordActions, - refreshVersion: listRefresh, - showReview: false, }; + + // 平铺视图与资产视图右侧是同一张表 + 同一份分页,只是筛选条件不同。 const flatListCard = ( - + + + {flat.loading && !flat.loaded ? ( +
+ +
+ ) : ( + <> + + { + setFlatPageSize(nextSize); + setFlatPage(1); + }} + pageSizeOptions={[10, 20, 50, 100]} + /> + + )} +
+
); return (
-

发现

+

发现

跨任务漏洞汇总

setView(v as FindingView)}> @@ -646,7 +1000,7 @@ export default function FindingsPage() {
{selectedIds.size > 0 && ( - 已选 {selectedIds.size} 条 + 已选 {selectedIds.size} 条 )}
- refreshAfterMutation()} /> - {view === "flat" && flatListCard} {view === "asset" && ( @@ -699,7 +1051,7 @@ export default function FindingsPage() {
-
+
{flatListCard}
@@ -734,10 +1084,10 @@ export default function FindingsPage() { {view === "cases" && ( )} {view === "grouped" && ( @@ -745,6 +1095,15 @@ export default function FindingsPage() { {groups.map((group) => { const key = findingGroupKey(group); const groupOpen = expandedGroups.has(key); + const state = groupFindings[key] ?? { + caseRows: [], + items: [], + total: group.count, + page: 1, + pageSize: 10, + loaded: false, + loading: false, + }; return ( @@ -765,7 +1124,9 @@ export default function FindingsPage() { {group.task_id === null ? "未关联 / 任务已删除" - : `${group.task_name || "任务"}(#${group.task_id})`} + : group.task_name + ? `${group.task_name}(任务 #${group.task_id})` + : `任务 #${group.task_id}`} {group.task_description || "来源任务不可用"} @@ -780,11 +1141,11 @@ export default function FindingsPage() { return ( - {count} + {count} ); })} - + {fmtTime(group.last_found_at)} {group.task_id !== null && ( @@ -801,12 +1162,29 @@ export default function FindingsPage() {
{groupOpen && ( - - + + {state.loading && !state.loaded ? ( +
+ +
+ ) : ( + <> + + void loadGroup(key, nextPage, state.pageSize)} + onPageSizeChange={(nextSize) => void loadGroup(key, 1, nextSize)} + /> + + )}
)} @@ -814,7 +1192,7 @@ export default function FindingsPage() { })} {groups.length === 0 && ( - 没有匹配的发现。 + 没有匹配的发现。 )}
- 导出范围 + 导出范围 setExportScope(v as typeof exportScope)}>
- 导出格式 + 导出格式
)}
- {f.summary} + {f.summary}
流量证据 {f.traffic_count ?? 0} 条 @@ -87,7 +87,7 @@ function Row({ {a.label} ))} - {f.assets.length > 2 && +{f.assets.length - 2}} + {f.assets.length > 2 && +{f.assets.length - 2}}
)} {f.finding_id && !f.inherited ? ( @@ -108,7 +108,7 @@ function Row({ ) : ( )} - + {new Date(f.ts).toLocaleString("zh-CN")} {f.finding_id && ( @@ -118,7 +118,7 @@ function Row({ ? `/function/findings/detail?id=${f.finding_id}&context_task=${contextTaskId}` : `/function/findings/detail?id=${f.finding_id}` } - className="inline-flex shrink-0 items-center gap-0.5 text-muted-foreground text-xs hover:text-primary" + className="text-muted-foreground hover:text-primary inline-flex shrink-0 items-center gap-0.5 text-xs" title="查看漏洞详情" > 详情 @@ -128,8 +128,8 @@ function Row({
{open && (
-
证据 / PoC
-
+          
证据 / PoC
+
             {f.evidence}
           
@@ -190,11 +190,7 @@ export function FindingsTab({ taskId }: { taskId: string }) { return (
- + {[ ...new Set( items @@ -204,15 +200,15 @@ export function FindingsTab({ taskId }: { taskId: string }) { ), ].map((source) => (
-

继承任务 #{source} · 只读

- +

继承任务 #{source} · 只读

+
))}
- 查看原始上报列表 + 查看原始上报列表 -
+
漏洞 - ) : null} - - - - - - - 查看详情 - - { - setName(f.name ?? ""); - setVulnclass(f.vulnclass); - setSeverity(f.severity); - setEditing(true); - }} - > - 编辑原始上报 - - {actions.onRetest ? ( - actions.onRetest?.(f)}> - 复测漏洞 - - ) : null} - {actions.onDeepen && f.task_id ? ( - actions.onDeepen?.(f)}>深入利用 - ) : null} - {actions.onDelete ? ( - setConfirmDelete(true)}> - 删除原始上报 - - ) : null} - - - { - if (!saving) setEditing(open); - }} - > - - - 编辑原始上报 #{f.finding_id ?? f.id} - 修改这条记录的名称、类型和等级;所属文件夹的统一报告将标记待更新。 - -
- - setName(e.target.value)} - disabled={saving} - /> - - setVulnclass(e.target.value)} - disabled={saving} - /> - - -
- - - - -
-
- - - - 删除这条原始上报? - 报告、证据绑定和文件夹成员关系会一同移除,此操作不可撤销。 - - - 取消 - actions.onDelete?.(f)}>删除 - - - - - ); -} - export function FindingCaseMemberRow({ finding: f, selected, @@ -288,8 +58,6 @@ export function FindingCaseMemberRow({ contextTask, matched = true, nested = false, - presentation = "records", - actions, }: { finding: Finding; selected?: boolean; @@ -297,8 +65,6 @@ export function FindingCaseMemberRow({ contextTask?: string; matched?: boolean; nested?: boolean; - presentation?: FindingListPresentation; - actions?: FindingRecordActions; }) { const id = f.finding_id ?? f.id; return ( @@ -323,45 +89,10 @@ export function FindingCaseMemberRow({ #{id} {[f.name, f.vulnclass].find((v) => v?.trim()) ?? "未分类"} {f.summary} -
{!matched ? 未命中当前筛选 : null} {f.inherited ? 继承 · 只读 : null} - {actions?.onStatusChange && !f.inherited ? ( - - ) : ( - - )} - {actions && !f.inherited ? : null} + - {open.has(group.id) ? ( - - ) : ( - - )} -
- - - {group.title} - - - - 漏洞文件夹 · {group.count} 条原始上报 ·{" "} - {group.report_version !== group.version ? "统一报告待更新" : "统一报告已生成"} - - -
-
- 严 / 高 / 中 / 低 - -
-
- - {open.has(group.id) ? ( - -

原始子报告 · 各自等级和证据保留

- -
- ) : null} -
+ ); } if (row.finding) @@ -791,8 +431,6 @@ export function FindingCaseList({ ); } + +// Only consolidated findings use this folder surface. Ordinary findings keep +// the original table rows and controls in each surrounding view. +export function FindingCaseFolder({ + row, + selectedIds, + onSelect, + contextTask, + readOnly = false, + presentation = "records", + className, + renderRecords, +}: { + row: FindingCaseListRow; + selectedIds?: Set; + onSelect?: (id: string, checked: boolean) => void; + contextTask?: string; + readOnly?: boolean; + presentation?: "compact" | "records" | "task" | "asset"; + className?: string; + renderRecords?: (items: Finding[]) => React.ReactNode; +}) { + const [open, setOpen] = React.useState(false); + const group = row.case; + const taskLabel = row.task_name?.trim() ? row.task_name : row.task_description; + if (!group) return null; + return ( + + +
+ + {open ? ( + + ) : ( + + )} +
+ + + {group.title} + + + + 漏洞文件夹 · {group.count} 条原始上报 ·{" "} + {group.report_version !== group.version ? "统一报告待更新" : "统一报告已生成"} + +
+ {presentation !== "task" && group.task_id ? ( + + 所属任务 #{group.task_id} + {taskLabel ? ` · ${taskLabel}` : ""} + + ) : null} + {(row.assets ?? []).length ? ( + + 资产: + {row.assets?.map((a) => ( + + {a.label} + + ))} + {(row.asset_count ?? 0) > 4 ? +{(row.asset_count ?? 0) - 4} : null} + + ) : ( + 未关联资产 + )} + {row.last_found_at ? ( + + ) : null} +
+
+
+ 严 / 高 / 中 / 低 + +
+
+
+ {open ? ( + +

原始子报告 · 各自等级和证据保留

+ +
+ ) : null} +
+ ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index cd80ec3..2f3c1ac 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -569,10 +569,11 @@ export const api = { findingAssetTree: (q: Omit) => get(`/exploration/findings/asset-tree?${findingFilterParams(q).toString()}`), - findingCases: (q: FindingQuery) => { + findingCases: (q: FindingQuery, originalRows = false) => { const p = findingFilterParams(q); p.set("page", String(q.page)); p.set("limit", String(q.pageSize)); + if (originalRows) p.set("original_rows", "1"); return get(`/exploration/finding-cases?${p}`); }, getFindingCase: (id: string, contextTask?: string) =>