diff --git a/README.md b/README.md index 504896c..3205dce 100644 --- a/README.md +++ b/README.md @@ -335,19 +335,19 @@ server { ### 同一漏洞归并与统一报告 -发现页默认「按漏洞」显示。同一任务内,Reporter 依据目标、接口参数、权限边界、根因及验证证据确认同一实际缺陷后形成文件夹;仅类型或名称相同不会作为自动归并依据。严重程度、接口、后端或读写影响不同仍可能是同一漏洞;反之,共用登录入口或令牌不能单独证明同一根因。有关联但共同根因无法确认时提交人工建议。文件夹的 `严/高/中/低` 数量按原始记录统计,例如 `0/1/1/1`;独立漏洞统计采用另行评估的统一等级,尚未评估时显示「待评估」。原始上报内容、评级、状态、报告及证据不被覆盖。 +发现页默认「原始上报」,原始上报、按漏洞、按任务及按资产视图均展示可展开的归并文件夹。同一任务内,Reporter 依据目标、接口参数、权限边界、根因及验证证据确认同一实际缺陷后形成文件夹;仅类型或名称相同不会作为自动归并依据。严重程度、接口、后端或读写影响不同仍可能是同一漏洞;反之,共用登录入口或令牌不能单独证明同一根因。有关联但共同根因无法确认时提交人工建议。文件夹的 `严/高/中/低` 数量按原始记录统计,例如 `0/1/1/1`;发现页的独立漏洞数按文件夹去重,等级数量按原始上报统计,列表及文件夹详情不显示统一评级标签。原始上报内容、评级、状态、报告及证据不被覆盖。 点击箭头展开原始记录,点击文件夹标题查看统一报告、原始记录及归并历史。证据不足的建议需要点击「确认归并」;否决或移出后,这些关系不会被 Agent 自动再次归并。移出成员不删除原漏洞,不足两条时解散文件夹并保留历史。继承任务的漏洞组保持只读。 新上报由现有 Reporter 生成独立报告并检查重复。升级首次补充 Reporter 的归并工具和运行指引,保留自定义提示词及触发设置;已删除或停用的 Reporter 不会重建、启用。历史记录不会自动扫描:展开并选择原始记录,点击「整理所选」,按任务分别排队执行,可查看执行记录及失败信息。同一任务已有排队或运行中的整理时拒绝重复提交。历史整理以只读方式复用原报告,禁止覆盖原报告或补绑证据;已有报告为空或过期时如实说明,单独通过报告撰写流程处理。结束前需提交每条所选记录的根因判断,检查读取范围、建议、统一报告完整性及版本,通过验收才显示完成。文件夹详情可重新生成统一报告;需要启用 Reporter 并配置可用模型。判断和写作会消耗模型 token,候选检索先分页返回摘要,详情证据按需读取,不重新探测目标。 -成员、报告或证据变化后统一报告标记待更新,写入时校验实际读取版本。导出默认每组一份统一报告及未归并记录,可勾选「包含原始子报告」;统一报告未生成或过期时附明确说明及全部原始材料。原始记录、按任务及按资产视图继续保留。升级沿用启动时幂等数据库迁移,无需执行独立 SQL。 +成员、报告或证据变化后统一报告标记待更新,写入时校验实际读取版本。导出默认每组一份统一报告及未归并记录,可勾选「包含原始子报告」;统一报告未生成或过期时附明确说明及全部原始材料。原始上报显示所属任务、资产及时间;按任务先展开任务,再展开漏洞文件夹;按资产保留资产树,命中任一成员即展示整个文件夹,展开时标明未命中筛选的成员。折叠状态只读取轻量摘要,不加载成员报告。升级沿用启动时幂等数据库迁移,无需执行独立 SQL。 ### 手动漏洞复测 任务详情的「复测」页签可分页选择本任务的漏洞、查看历次结论和证据,并手动发起复测。启动后保留当前页签,显示转圈图标和「复测中」;确认修复后同步更新漏洞状态。 -在漏洞列表每行操作区点击「复测」,或在漏洞详情的「漏洞复测」区域点击「发起复测」,填写可选的修复版本、测试条件或限制,系统会创建独立的复测 Agent 会话,启动后保留当前页面。列表的平铺、按任务分组和资产视图均支持该入口;复测运行时显示转圈图标和「复测中」,需要查看时点击进入对应会话,结束后恢复「复测」。复测无需重新启动原扫描任务,结论分为「仍可复现」「已修复」「无法确认」,每次的结论、证据和会话链接保存在漏洞详情中。 +在漏洞列表原始记录的「更多操作」中点击「复测漏洞」,或在漏洞详情的「漏洞复测」区域点击「发起复测」,填写可选的修复版本、测试条件或限制,系统会创建独立的复测 Agent 会话,启动后保留当前页面。列表的平铺、按任务分组和资产视图均支持该入口;复测运行时显示转圈图标和「复测中」,需要查看时点击进入对应会话,结束后恢复「复测」。复测无需重新启动原扫描任务,结论分为「仍可复现」「已修复」「无法确认」,每次的结论、证据和会话链接保存在漏洞详情中。 新版后端首次启动会预置可编辑的「漏洞复测」(`retester`)Agent,可在 Agent 管理中配置提示词、LLM、运行预算和工具。默认使用其绑定的 LLM,未绑定则使用全局激活配置。复测会话成功完成且结论为「已修复」时,系统自动将漏洞处置状态改为「已修复」;执行中、失败、停止或其他结论保留原状态。原始证据和报告始终保留。也可在状态下拉菜单中手动选择「已修复」。同一漏洞正在复测时复用已有会话,停止、失败或服务重启后可重新发起。 diff --git a/db/finding_cases.go b/db/finding_cases.go index c243d4a..64600ae 100644 --- a/db/finding_cases.go +++ b/db/finding_cases.go @@ -117,17 +117,17 @@ func (d *DB) ListFindingCases(filter FindingFilter, page, size int) ([]FindingCa return nil, 0, err } where, args := filter.where() - base := `WITH matched AS (SELECT f.id,f.severity,f.created_at,COALESCE(m.case_id,0) cid FROM findings f LEFT JOIN tasks t ON t.id=f.task_id LEFT JOIN finding_case_members m ON m.finding_id=f.id` + where + `), entities AS (SELECT CASE WHEN cid>0 THEN 'c:'||cid ELSE 'f:'||id END key,MAX(cid) cid,MIN(id) fid,jsonb_agg(id ORDER BY id) matched_ids,MAX(created_at) created FROM matched GROUP BY key)` + base := `WITH matched AS (SELECT f.id,f.severity,f.created_at,COALESCE(m.case_id,0) cid FROM findings f LEFT JOIN tasks t ON t.id=f.task_id LEFT JOIN finding_case_members m ON m.finding_id=f.id` + where + `), entities AS (SELECT CASE WHEN cid>0 THEN 'c:'||cid ELSE 'f:'||id END key,MAX(cid) cid,MIN(id) fid,jsonb_agg(id ORDER BY id) matched_ids,MAX(created_at) created,MAX(CASE severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END) severity_rank FROM matched GROUP BY key)` var total int if err := d.QueryRow(base+` SELECT count(*) FROM entities`, args...).Scan(&total); err != nil { return nil, 0, err } order := `e.created DESC,e.fid DESC` if filter.Sort == "severity" { - order = `CASE COALESCE(c.severity,f.severity) WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END DESC,` + order + order = `e.severity_rank DESC,` + order } args = append(args, size, (page-1)*size) - rows, err := d.Query(base+fmt.Sprintf(` SELECT e.cid,e.fid,e.matched_ids FROM entities e LEFT JOIN finding_cases c ON c.id=e.cid LEFT JOIN findings f ON f.id=e.fid ORDER BY %s LIMIT $%d OFFSET $%d`, order, len(args)-1, len(args)), args...) + rows, err := d.Query(base+fmt.Sprintf(` SELECT e.cid,e.fid,e.matched_ids FROM entities e ORDER BY %s LIMIT $%d OFFSET $%d`, order, len(args)-1, len(args)), args...) if err != nil { return nil, 0, err } @@ -573,3 +573,61 @@ func (d *DB) FindingCaseSummary(id int64) (*DBFinding, error) { } return fs[0], nil } + +// FindingCaseListContext contains bounded metadata for collapsed folder rows. +// Reports and member evidence are still fetched only when opened. +type FindingCaseListContext struct { + TaskName string + TaskDescription string + AssetIDs []int64 + AssetCount int + LastFoundAt time.Time +} + +func (d *DB) FindingCaseListContexts(ids []int64) (map[int64]FindingCaseListContext, error) { + result := map[int64]FindingCaseListContext{} + if len(ids) == 0 { + return result, nil + } + raw, err := json.Marshal(ids) + if err != nil { + return nil, err + } + rows, err := d.Query(`SELECT c.id,COALESCE(t.name,''),COALESCE(t.description,''), + COALESCE((SELECT MAX(f.created_at) FROM finding_case_members m JOIN findings f ON f.id=m.finding_id WHERE m.case_id=c.id),c.created_at), + COALESCE((SELECT jsonb_agg(v.id ORDER BY v.id) FROM ( + SELECT DISTINCT a.id FROM finding_case_members m JOIN findings f ON f.id=m.finding_id + CROSS JOIN LATERAL jsonb_array_elements_text(f.asset_ids) e(v) JOIN assets a ON a.id=e.v::bigint + WHERE m.case_id=c.id ORDER BY a.id LIMIT 4) v),'[]'::jsonb), + (SELECT COUNT(DISTINCT a.id) FROM finding_case_members m JOIN findings f ON f.id=m.finding_id + CROSS JOIN LATERAL jsonb_array_elements_text(f.asset_ids) e(v) JOIN assets a ON a.id=e.v::bigint WHERE m.case_id=c.id) + FROM finding_cases c LEFT JOIN tasks t ON t.id=c.task_id + WHERE c.id IN (SELECT jsonb_array_elements_text($1::jsonb)::bigint)`, string(raw)) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var id int64 + var c FindingCaseListContext + var assets []byte + if err := rows.Scan(&id, &c.TaskName, &c.TaskDescription, &c.LastFoundAt, &assets, &c.AssetCount); err != nil { + return nil, err + } + if err := json.Unmarshal(assets, &c.AssetIDs); err != nil { + return nil, err + } + result[id] = c + } + return result, rows.Err() +} +func (d *DB) MatchingFindingReportCount(filter FindingFilter) (int, error) { + filter, err := d.applyAssetScope(filter) + if err != nil { + return 0, err + } + where, args := filter.where() + var n int + err = d.QueryRow(`SELECT COUNT(*) FROM findings f LEFT JOIN tasks t ON t.id=f.task_id`+where, args...).Scan(&n) + return n, err +} diff --git a/db/finding_cases_test.go b/db/finding_cases_test.go index fe8d163..48398a5 100644 --- a/db/finding_cases_test.go +++ b/db/finding_cases_test.go @@ -2,7 +2,9 @@ package db import ( "context" + "encoding/json" "fmt" + "reflect" "sync" "testing" ) @@ -289,3 +291,67 @@ func TestFindingCaseCandidatesAreCompactAndRelated(t *testing.T) { t.Fatalf("veto excluded %d %v", total, err) } } + +func TestFindingCaseViewContextAndAssetFiltering(t *testing.T) { + d, task, ids := caseTestDB(t) + if _, err := d.Exec(`UPDATE tasks SET name='Order API' WHERE id=$1`, task.ID); err != nil { + t.Fatal(err) + } + assets := []int64{} + for i := 0; i < 6; i++ { + aid := seedTreeAsset(t, d, "service", map[string]any{"url": fmt.Sprintf("https://view-context-%d-%d.example", task.ID, i)}) + assets = append(assets, aid) + } + t.Cleanup(func() { + for _, a := range assets { + _, _ = d.Exec(`DELETE FROM assets WHERE id=$1`, a) + } + }) + for i, id := range ids[:3] { + raw, _ := json.Marshal(assets[i*2 : i*2+2]) + if _, err := d.Exec(`UPDATE findings SET asset_ids=$2 WHERE id=$1`, id, string(raw)); err != nil { + t.Fatal(err) + } + } + cid, err := d.MergeFindingCase(t.Context(), task.ID, ids[:3], "order access", "same permission check", "human") + if err != nil { + t.Fatal(err) + } + contexts, err := d.FindingCaseListContexts([]int64{cid}) + if err != nil { + t.Fatal(err) + } + c := contexts[cid] + if c.TaskName != "Order API" || c.TaskDescription != task.Description || c.AssetCount != 6 || !reflect.DeepEqual(c.AssetIDs, assets[:4]) || c.LastFoundAt.IsZero() { + t.Fatalf("context %+v", c) + } + filter := FindingFilter{TaskID: fmt.Sprint(task.ID), AssetScope: fmt.Sprintf("a:%d", assets[0])} + rows, total, err := d.ListFindingCases(filter, 1, 20) + if err != nil || total != 1 || len(rows) != 1 || rows[0].Case == nil || rows[0].Case.ID != cid || !reflect.DeepEqual(rows[0].MatchedIDs, ids[:1]) { + t.Fatalf("filtered rows %+v total %d err %v", rows, total, err) + } + members, n, err := d.FindingCaseMembers(cid, 1, 20) + if err != nil || n != 3 || len(members) != 3 { + t.Fatalf("all folder members %d %v", n, err) + } + matches, err := d.MatchingFindingReportCount(filter) + if err != nil || matches != 1 { + t.Fatalf("matching reports %d %v", matches, err) + } + rows, total, err = d.ListFindingCases(FindingFilter{TaskID: fmt.Sprint(task.ID), Severity: "medium"}, 1, 20) + if err != nil || total != 1 || len(rows) != 1 || rows[0].Case.Count != 3 || rows[0].Case.High != 1 || rows[0].Case.Medium != 1 || rows[0].Case.Low != 1 { + t.Fatalf("severity filter %+v %v", rows, err) + } + matches, err = d.MatchingFindingReportCount(FindingFilter{TaskID: fmt.Sprint(task.ID), Severity: "medium"}) + if err != nil || matches != 1 { + t.Fatalf("severity reports %d %v", matches, err) + } + // Group has no unified severity yet; it must sort by its original high report. + if _, err := d.Exec(`UPDATE findings SET severity='low' WHERE id=$1`, ids[3]); err != nil { + t.Fatal(err) + } + rows, total, err = d.ListFindingCases(FindingFilter{TaskID: fmt.Sprint(task.ID), Sort: "severity"}, 1, 1) + if err != nil || total != 2 || len(rows) != 1 || rows[0].Case == nil || rows[0].Case.ID != cid { + t.Fatalf("original severity order %+v %v", rows, err) + } +} diff --git a/server/finding_cases.go b/server/finding_cases.go index a2a3415..df93c37 100644 --- a/server/finding_cases.go +++ b/server/finding_cases.go @@ -62,9 +62,30 @@ func (s *Server) listFindingCases(w http.ResponseWriter, r *http.Request) { caseHTTPError(w, err) return } + caseIDs := []int64{} + for _, row := range rows { + if row.Case != nil { + caseIDs = append(caseIDs, row.Case.ID) + } + } + contexts, err := s.m.pg.FindingCaseListContexts(caseIDs) + if err != nil { + caseHTTPError(w, err) + return + } + assetIDs := []int64{} + for _, c := range contexts { + assetIDs = append(assetIDs, c.AssetIDs...) + } + assets := s.resolveAssetIDs(assetIDs) items := []map[string]any{} for _, row := range rows { v := map[string]any{"case": row.Case, "matched_ids": row.MatchedIDs} + if row.Case != nil { + c := contexts[row.Case.ID] + v["task_name"], v["task_description"], v["last_found_at"], v["asset_count"] = c.TaskName, c.TaskDescription, c.LastFoundAt, c.AssetCount + v["assets"] = append([]FindingAssetDTO{}, findingFromDB(&db.DBFinding{AssetIDs: c.AssetIDs}, assets).Assets...) + } if row.FindingID > 0 { f, err := s.m.pg.FindingCaseSummary(row.FindingID) if err != nil { @@ -84,7 +105,12 @@ func (s *Server) listFindingCases(w http.ResponseWriter, r *http.Request) { caseHTTPError(w, err) return } - writeJSON(w, 200, map[string]any{"items": items, "total": total, "page": page, "page_size": size, "stats": stats}) + matchingReports, err := s.m.pg.MatchingFindingReportCount(findingFilterFromQuery(q)) + if err != nil { + caseHTTPError(w, err) + return + } + writeJSON(w, 200, map[string]any{"items": items, "total": total, "matching_reports": matchingReports, "page": page, "page_size": size, "stats": stats}) } func (s *Server) findingCaseAccess(w http.ResponseWriter, r *http.Request, mutation bool) (*db.FindingCase, bool) { id := int64(atoiDefault(r.PathValue("id"), 0)) diff --git a/server/finding_cases_test.go b/server/finding_cases_test.go index 91438fa..feb491e 100644 --- a/server/finding_cases_test.go +++ b/server/finding_cases_test.go @@ -321,3 +321,34 @@ func TestFindingCaseGroupedTrafficExportAndInheritedAccess(t *testing.T) { t.Fatal(w.Code, w.Body) } } + +func TestFindingCaseListIncludesContextAndFilteredCounts(t *testing.T) { + s, task, ids := testCaseServer(t) + cid, err := s.m.pg.MergeFindingCase(t.Context(), task, ids[:3], "same defect", "verified root cause", "human") + if err != nil { + t.Fatal(err) + } + r := httptest.NewRequest("GET", fmt.Sprintf("/api/finding-cases?task_id=%d&severity=medium", task), nil) + w := httptest.NewRecorder() + s.listFindingCases(w, r) + if w.Code != 200 { + t.Fatalf("status %d %s", w.Code, w.Body.String()) + } + var page struct { + Total int `json:"total"` + MatchingReports int `json:"matching_reports"` + Items []struct { + Case *db.FindingCase `json:"case"` + TaskDescription string `json:"task_description"` + Assets []FindingAssetDTO `json:"assets"` + AssetCount int `json:"asset_count"` + LastFoundAt string `json:"last_found_at"` + } `json:"items"` + } + if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil { + t.Fatal(err) + } + if page.Total != 1 || page.MatchingReports != 1 || len(page.Items) != 1 || page.Items[0].Case.ID != cid || page.Items[0].Case.Count != 3 || page.Items[0].Case.Report != "" || page.Items[0].TaskDescription == "" || page.Items[0].LastFoundAt == "" || page.Items[0].Assets == nil { + t.Fatalf("list payload %s", w.Body.String()) + } +} diff --git a/web/src/app/(main)/function/findings/case/page.tsx b/web/src/app/(main)/function/findings/case/page.tsx index 3fa7c77..0e21a20 100644 --- a/web/src/app/(main)/function/findings/case/page.tsx +++ b/web/src/app/(main)/function/findings/case/page.tsx @@ -8,7 +8,6 @@ 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, @@ -109,7 +108,6 @@ function CaseDetail() {

{c.title}

- {c.severity ? : 统一评级待评估} {error ? ( @@ -138,7 +136,7 @@ function CaseDetail() { 完整复现与修复报告 - {c.severity_reason || "统一评级尚未完成"} + 集中查看所有成员的复现步骤、证据与修复建议。 {c.report_version !== c.version ? ( @@ -163,7 +161,7 @@ function CaseDetail() { 原始报告和证据完整保留 - 成员保留原评级;统一评级不覆盖原始记录。 + 各条原始上报保留各自的等级、报告和证据。 {!readOnly && selected.size > 0 ? ( diff --git a/web/src/app/(main)/function/findings/page.tsx b/web/src/app/(main)/function/findings/page.tsx index af50be2..96f77f6 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 } from "@/components/finding-case-list"; +import { FindingCaseList, FindingCaseReviewPanel } from "@/components/finding-case-list"; import { FindingRetestDialog } from "@/components/finding-retest-dialog"; import { StatusBadge } from "@/components/status-badge"; import { TablePagination } from "@/components/table-pagination"; @@ -55,22 +55,11 @@ import type { import { cn } from "@/lib/utils"; import { AssetTree, assetPathOf } from "./_components/asset-tree"; -import { - FINDING_STATUSES, - type FindingEdit, - type FindingReport, - FindingsTable, - findingRowKey, - fmtTime, - isSameFinding, - SEVERITIES, - UNASSIGNED_TASK, -} from "./_components/findings-table"; +import { FINDING_STATUSES, fmtTime, 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"]; @@ -95,26 +84,6 @@ const EMPTY_ASSET_TREE: AssetTreeState = { loading: false, }; -// 分组视图里每个已展开任务组自带一份分页状态,彼此独立。 -interface GroupFindingsState { - items: Finding[]; - total: number; - page: number; - pageSize: number; - loaded: boolean; - loading: boolean; -} - -// 平铺视图的页码单独放 state(而非塞进快照),筛选一变就能连带重置并触发重新加载。 -interface FlatFindingsState { - items: Finding[]; - total: number; - loaded: boolean; - loading: boolean; -} - -const EMPTY_FLAT_STATE: FlatFindingsState = { items: [], total: 0, loaded: false, loading: false }; - function findingGroupKey(group: FindingGroup) { return group.task_id === null ? UNASSIGNED_TASK : String(group.task_id); } @@ -131,7 +100,7 @@ const EMPTY_STATS: FindingStats = { }; export default function FindingsPage() { - const [view, setView] = React.useState("cases"); + const [view, setView] = React.useState("flat"); const [severity, setSeverity] = React.useState<"all" | Severity>("all"); const [status, setStatus] = React.useState<"all" | FindingStatus>("all"); const [vulnclass, setVulnclass] = React.useState("all"); @@ -139,16 +108,12 @@ 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 [groupFindings, setGroupFindings] = React.useState>({}); + const [listRefresh, setListRefresh] = React.useState(0); const [total, setTotal] = React.useState(0); const [stats, setStats] = React.useState(EMPTY_STATS); const [statsLoaded, setStatsLoaded] = React.useState(false); @@ -236,18 +201,6 @@ export default function FindingsPage() { return () => window.clearTimeout(timer); }, [search]); - // setFindings 同时改写两个视图缓存里的同一条发现,切换视图不会看到过期状态。 - const setFindings = React.useCallback((update: (current: Finding[]) => Finding[]) => { - setFlat((current) => ({ ...current, items: update(current.items) })); - setGroupFindings((current) => { - const next: Record = {}; - for (const [key, state] of Object.entries(current)) { - next[key] = { ...state, items: update(state.items) }; - } - return next; - }); - }, []); - // 勾选导出:按 finding_id(独立表 id)记选中项,跨页保留。 const [selectedIds, setSelectedIds] = React.useState>(() => new Set()); // 导出弹窗状态:范围(当前筛选/全部/选中) × 格式(md 单文件/md 分文件 zip/csv/json)。 @@ -267,17 +220,6 @@ 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"); @@ -290,7 +232,15 @@ export default function FindingsPage() { await api.exportFindings({ format: exportFormat, scope: exportScope, - filters: { severity, status, vulnclass, task, query, sort }, + filters: { + severity, + status, + vulnclass, + task, + query, + sort, + assetScope: view === "asset" ? (assetScope ?? undefined) : undefined, + }, ids: [...selectedIds], includeOriginals, }); @@ -303,48 +253,8 @@ 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.findingsPage({ - page: flatPage, - pageSize: flatPageSize, - severity, - status, - vulnclass, - task, - query, - sort, - assetScope: activeAssetScope ?? undefined, - }); - if (request !== flatRequest.current || activeFilterFingerprint.current !== requestFilter) return; - setFlat({ items: result.items, 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。 @@ -395,178 +305,57 @@ export default function FindingsPage() { } }, [filterFingerprint, page, pageSize, severity, status, vulnclass, task, query, sort]); - 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]: { - 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.findingsPage({ - page: groupPage, - pageSize: groupPageSize, - severity, - status, - vulnclass, - task: key, - query, - sort, - }); - if (groupRequests.current[key] !== request || activeFilterFingerprint.current !== requestFilter) return; - setGroupFindings((current) => ({ - ...current, - [key]: { - items: result.items, - 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] ?? { - items: [], - total: 0, - page: groupPage, - pageSize: groupPageSize, - loaded: false, - }), - loading: false, - }, - })); - } - }, - [filterFingerprint, severity, status, vulnclass, 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; + }); + }, []); - 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); - } + (_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. */ + }); }, - [loadAssetTree, loadFlat, loadGroup, refreshGroups, view], + [loadAssetTree, 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) 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); + if (!preferencesHydrated || view !== "grouped") return; + void activeRetestFingerprint; + void refreshGroups(); + const timer = setInterval(() => void refreshGroups(), 5000); return () => clearInterval(timer); - }, [activeRetestFingerprint, loadFlat, loadGroup, preferencesHydrated, refreshGroups, view]); + }, [activeRetestFingerprint, 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(() => { @@ -604,118 +393,30 @@ 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 || next === f.status) return; - const prev = f.status; - setFindings((cur) => cur.map((x) => (isSameFinding(x, f) ? { ...x, status: next } : x))); + if (!f.finding_id || f.inherited || next === f.status) return; 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, setFindings, status], + [refreshAfterMutation], ); - // 行内展开的详细报告缓存按全局稳定行键存。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() @@ -728,7 +429,7 @@ export default function FindingsPage() { toast.error(`删除失败:${(e as Error).message}`); } }, - [refreshAfterMutation, setFindings], + [refreshAfterMutation], ); const openDeepen = React.useCallback((f: Finding) => { @@ -759,72 +460,59 @@ 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.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 }, + { 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 }, ]; - // 导出弹窗里「当前筛选」的条数:两个视图的筛选一致,只是统计口径来源不同。 - // 平铺与资产视图共用 flat 列表状态,分组视图的口径来自组接口的 finding_total。 const [caseTotal, setCaseTotal] = React.useState(0); - const filteredTotal = view === "cases" ? caseTotal : view === "grouped" ? total : flat.total; + const [caseReportTotal, setCaseReportTotal] = React.useState(0); + const filteredTotal = view === "grouped" ? total : caseReportTotal; const assetPath = React.useMemo( () => (view === "asset" ? assetPathOf(assetTree.nodes, assetScope) : []), [assetScope, assetTree.nodes, view], ); - const rowProps = { - selectedIds, - onToggleSelected: toggleSelected, - onToggleSelectedPage: toggleSelectedPage, - expandedKey: expanded, - onToggleRow: toggleRow, - reports, - edit, - onEditChange: setEdit, - saving, - onSave: saveEdit, + const recordActions = { + onUpdated: refreshAfterMutation, 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)}> @@ -958,7 +646,7 @@ export default function FindingsPage() {
{selectedIds.size > 0 && ( - 已选 {selectedIds.size} 条 + 已选 {selectedIds.size} 条 )}
+ refreshAfterMutation()} /> + {view === "flat" && flatListCard} {view === "asset" && ( @@ -1009,7 +699,7 @@ export default function FindingsPage() {
-
+
{flatListCard}
@@ -1042,10 +734,10 @@ export default function FindingsPage() { {view === "cases" && ( )} {view === "grouped" && ( @@ -1053,14 +745,6 @@ export default function FindingsPage() { {groups.map((group) => { const key = findingGroupKey(group); const groupOpen = expandedGroups.has(key); - const state = groupFindings[key] ?? { - items: [], - total: group.count, - page: 1, - pageSize: 10, - loaded: false, - loading: false, - }; return ( @@ -1081,9 +765,7 @@ export default function FindingsPage() { {group.task_id === null ? "未关联 / 任务已删除" - : group.task_name - ? `${group.task_name}(任务 #${group.task_id})` - : `任务 #${group.task_id}`} + : `${group.task_name || "任务"}(#${group.task_id})`} {group.task_description || "来源任务不可用"} @@ -1098,11 +780,11 @@ export default function FindingsPage() { return ( - {count} + {count} ); })} - + {fmtTime(group.last_found_at)} {group.task_id !== null && ( @@ -1119,23 +801,12 @@ export default function FindingsPage() {
{groupOpen && ( - - {state.loading && !state.loaded ? ( -
- -
- ) : ( - <> - - void loadGroup(key, nextPage, state.pageSize)} - onPageSizeChange={(nextSize) => void loadGroup(key, 1, nextSize)} - /> - - )} + + )} @@ -1143,7 +814,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="text-muted-foreground hover:text-primary inline-flex shrink-0 items-center gap-0.5 text-xs" + className="inline-flex shrink-0 items-center gap-0.5 text-muted-foreground text-xs hover:text-primary" title="查看漏洞详情" > 详情 @@ -128,8 +128,8 @@ function Row({
{open && (
-
证据 / PoC
-
+          
证据 / PoC
+
             {f.evidence}
           
@@ -190,7 +190,11 @@ export function FindingsTab({ taskId }: { taskId: string }) { return (
- + {[ ...new Set( items @@ -200,15 +204,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, @@ -57,6 +288,8 @@ export function FindingCaseMemberRow({ contextTask, matched = true, nested = false, + presentation = "records", + actions, }: { finding: Finding; selected?: boolean; @@ -64,6 +297,8 @@ export function FindingCaseMemberRow({ contextTask?: string; matched?: boolean; nested?: boolean; + presentation?: FindingListPresentation; + actions?: FindingRecordActions; }) { const id = f.finding_id ?? f.id; return ( @@ -88,10 +323,45 @@ 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) ? ( @@ -470,6 +771,8 @@ export function FindingCaseList({

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