diff --git a/agent/finding_cases.go b/agent/finding_cases.go index 3a84e96..01ec1c3 100644 --- a/agent/finding_cases.go +++ b/agent/finding_cases.go @@ -45,7 +45,7 @@ func findingCaseWorkflow(key string, tools []actool.CoreTool) ([]actool.CoreTool } return tools, ` -【同一漏洞的多次上报整理】保留本次独立报告,不覆盖其他原报告。默认报告完成即停止的旧指引在此扩展:先保存本次报告,然后 search_finding_duplicates(finding_id=独立记录ID) 分页读取同任务候选摘要(按 score 排序;strong=同漏洞类型且资产重叠,优先核对;weak=单一条件命中,仅作参考),按需 get_finding_record 查看证据。仅凭漏洞类型、标题、令牌、登录入口相似或高中低等级不能归并;须核对目标、接口、参数、权限边界、根因及实际验证证据。只能处理同一任务,尊重人工否决和 finding_case_blocks。 +【同一漏洞的多次上报整理】保留本次独立报告,不覆盖其他原报告。默认报告完成即停止的旧指引在此扩展:先保存本次报告,然后 search_finding_duplicates(finding_id=独立记录ID) 分页读取同任务候选摘要(按 score 排序;strong=同漏洞类型且资产重叠,优先核对;weak=未同时具备同漏洞类型和资产重叠,结合 hit_reasons 核对,仅作参考),按需 get_finding_record 查看证据。仅凭漏洞类型、标题、令牌、登录入口相似或高中低等级不能归并;须核对目标、接口、参数、权限边界、根因及实际验证证据。只能处理同一任务,尊重人工否决和 finding_case_blocks。 【判断准则】若证据表明端点/路由、处理器或后端组件、易受影响参数,或缺失检查属于不同代码路径,可以据此判定 independent;必须说明各自证据。仅凭路径、接口、登录入口、令牌、标题或漏洞类型相同/不同,都不能单独证明同一根因或不同根因。只有根因明确相同才直接调用 merge_finding_records(task_id,finding_ids,title,reason)。只有存在具体共同点(两条记录都证据化地指向同一端点、参数、组件或缺失检查),但缺少确认共同根因的关键证据时,才调用 suggest_finding_merge;一次提交覆盖2..20条同任务记录的一个分组,不要拆成两两建议。仅共享漏洞类型或资产没有具体共同点,不得提交建议;没有具体共同点时保持 independent,不要因为不确定就 suggest。不得重复上报、删除记录或重新探测目标。人工选择整理时只能判断所选记录,不能加入未选记录(既有组成员可作为报告上下文)。 归并成功或已有case_id时,get_finding_case 记录实际version,分页读取全部成员,按需 get_finding_record、get_finding_traffic 读取原报告、PoC及已保存流量。根因明确时归并不被统一报告阻塞,应随后尽力用 update_finding_case_report 保存统一报告;如果本次无法完成,文件夹显示“统一报告待更新”,由后续 reporter 运行或人工补齐。统一报告在保存时仍须包含有内容的概述、影响、前置条件、复现步骤、证据、PoC、根因分析、修复建议章节,不得半篇截断;评级及 severity_reason 必须基于已验证影响,不能直接取成员最高等级。集中给出前置条件、完整执行顺序、输入输出如何衔接、可运行的完整脚本及命令,不把必要步骤分散到原报告;缺失条件、未验证步骤如实标明。版本冲突重新读取材料并生成,不能仅换version重试。各成员仍有独立报告、评级和证据。工具被关闭时不绕过配置。` } diff --git a/db/finding_cases.go b/db/finding_cases.go index b87a2f4..e469c54 100644 --- a/db/finding_cases.go +++ b/db/finding_cases.go @@ -209,6 +209,7 @@ func (d *DB) mergeFindingCase(ctx context.Context, taskID int64, ids []int64, ti if strings.TrimSpace(title) == "" || strings.TrimSpace(reason) == "" { return 0, errors.New("必须提供漏洞名称及基于证据的归并理由") } + keptIDs := append([]int64(nil), ids...) var cid int64 err = d.WithEvidenceTx(ctx, func(tx *sql.Tx) error { if err := lockCaseTask(tx, taskID); err != nil { @@ -313,7 +314,7 @@ func (d *DB) mergeFindingCase(ctx context.Context, taskID int64, ids []int64, ti if all[excluded] { return fmt.Errorf("排除的记录 #%d 已与所选记录在同一文件夹,请先移出该文件夹后再确认", excluded) } - for _, kept := range ids { + for _, kept := range keptIDs { if _, err := tx.Exec(`INSERT INTO finding_case_blocks(left_id,right_id,reason) VALUES(LEAST($1::bigint,$2::bigint),GREATEST($1::bigint,$2::bigint),'人工确认分组时排除') ON CONFLICT DO NOTHING`, excluded, kept); err != nil { @@ -337,24 +338,31 @@ func (d *DB) mergeFindingCase(ctx context.Context, taskID int64, ids []int64, ti func sweepFindingCaseSuggestions(tx *sql.Tx, taskID int64) error { if _, err := tx.Exec(`UPDATE finding_case_suggestions s SET state='accepted' WHERE s.task_id=$1 AND s.state='pending' + AND (SELECT count(*) FROM finding_case_suggestion_members sm + WHERE sm.suggestion_id=s.id + AND EXISTS (SELECT 1 FROM findings f WHERE f.id=sm.finding_id AND f.task_id=s.task_id)) >= 2 AND (SELECT count(*) FROM finding_case_suggestion_members sm JOIN findings f ON f.id=sm.finding_id AND f.task_id=s.task_id JOIN finding_case_members cm ON cm.finding_id=sm.finding_id - JOIN finding_cases c ON c.id=cm.case_id AND c.active) >= 2 + JOIN finding_cases c ON c.id=cm.case_id AND c.active + WHERE sm.suggestion_id=s.id) >= 2 AND (SELECT count(DISTINCT cm.case_id) FROM finding_case_suggestion_members sm JOIN findings f ON f.id=sm.finding_id AND f.task_id=s.task_id JOIN finding_case_members cm ON cm.finding_id=sm.finding_id - JOIN finding_cases c ON c.id=cm.case_id AND c.active) = 1 + JOIN finding_cases c ON c.id=cm.case_id AND c.active + WHERE sm.suggestion_id=s.id) = 1 AND NOT EXISTS (SELECT 1 FROM finding_case_suggestion_members sm JOIN findings f ON f.id=sm.finding_id AND f.task_id=s.task_id LEFT JOIN finding_case_members cm ON cm.finding_id=sm.finding_id - WHERE cm.finding_id IS NULL)`, taskID); err != nil { + LEFT JOIN finding_cases c ON c.id=cm.case_id AND c.active + WHERE sm.suggestion_id=s.id AND c.id IS NULL)`, taskID); err != nil { return err } _, err := tx.Exec(`UPDATE finding_case_suggestions s SET state='obsolete' WHERE s.task_id=$1 AND s.state='pending' AND ( (SELECT count(*) FROM finding_case_suggestion_members sm - JOIN findings f ON f.id=sm.finding_id AND f.task_id=s.task_id) < 2 + JOIN findings f ON f.id=sm.finding_id AND f.task_id=s.task_id + WHERE sm.suggestion_id=s.id) < 2 OR EXISTS (SELECT 1 FROM finding_case_suggestion_members a JOIN finding_case_suggestion_members b diff --git a/db/finding_cases_test.go b/db/finding_cases_test.go index 5f4df1c..f54c337 100644 --- a/db/finding_cases_test.go +++ b/db/finding_cases_test.go @@ -388,6 +388,32 @@ func TestFindingCaseSuggestionClusterLifecycle(t *testing.T) { } } +func TestFindingCaseSuggestionSweepScopesEachCard(t *testing.T) { + d, task, ids := caseTestDB(t) + ctx := context.Background() + first, err := d.SuggestFindingCase(ctx, task.ID, ids[:2], "first", "first group") + if err != nil { + t.Fatal(err) + } + second, err := d.SuggestFindingCase(ctx, task.ID, ids[2:], "second", "second group") + if err != nil { + t.Fatal(err) + } + if _, err := d.MergeFindingCase(ctx, task.ID, ids[:2], "first", "same root cause", "human"); err != nil { + t.Fatal(err) + } + var firstState, secondState string + if err := d.QueryRow(`SELECT state FROM finding_case_suggestions WHERE id=$1`, first).Scan(&firstState); err != nil { + t.Fatal(err) + } + if err := d.QueryRow(`SELECT state FROM finding_case_suggestions WHERE id=$1`, second).Scan(&secondState); err != nil { + t.Fatal(err) + } + if firstState != "accepted" || secondState != "pending" { + t.Fatalf("suggestion states first=%q second=%q", firstState, secondState) + } +} + func TestFindingCaseSuggestionSubsetSuperseded(t *testing.T) { d, task, ids := caseTestDB(t) ctx := context.Background() @@ -427,6 +453,31 @@ func TestFindingCaseSuggestionAcceptExclusionAndReject(t *testing.T) { } } +func TestFindingCaseSuggestionExclusionBlocksOnlyKeptSuggestionMembers(t *testing.T) { + d, task, ids := caseTestDB(t) + ctx := context.Background() + if _, err := d.MergeFindingCase(ctx, task.ID, ids[:2], "existing folder", "same root cause", "human"); err != nil { + t.Fatal(err) + } + sid, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[0], ids[2], ids[3]}, "orders", "same component") + if err != nil { + t.Fatal(err) + } + if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, true, []int64{ids[3]}); err != nil { + t.Fatal(err) + } + var folderMateBlock, keptBlock int + if err := d.QueryRow(`SELECT count(*) FROM finding_case_blocks WHERE left_id=$1 AND right_id=$2`, ids[1], ids[3]).Scan(&folderMateBlock); err != nil { + t.Fatal(err) + } + if err := d.QueryRow(`SELECT count(*) FROM finding_case_blocks WHERE left_id=$1 AND right_id=$2`, ids[0], ids[3]).Scan(&keptBlock); err != nil { + t.Fatal(err) + } + if folderMateBlock != 0 || keptBlock != 1 { + t.Fatalf("exclusion blocks folder mate=%d kept=%d", folderMateBlock, keptBlock) + } +} + func TestFindingCaseSuggestionRejectVetoesAllPairs(t *testing.T) { d, task, ids := caseTestDB(t) ctx := context.Background() diff --git a/server/finding_cases.go b/server/finding_cases.go index fa6bc33..46200cc 100644 --- a/server/finding_cases.go +++ b/server/finding_cases.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "net/http" "strconv" "strings" @@ -264,7 +265,7 @@ func (s *Server) findingCaseTools() []actool.CoreTool { name, desc string required []string }{ - {"search_finding_duplicates", "只读检索同任务候选摘要与资产,按分数排序并返回 score、tier、hit_reasons:strong=同漏洞类型且资产重叠,weak=单一条件命中仅作参考。等级不同也可能重复。支持分页,按需读取详情。", []string{"finding_id"}}, + {"search_finding_duplicates", "只读检索同任务候选摘要与资产,按分数排序并返回 score、tier、hit_reasons:strong=同漏洞类型且资产重叠,weak=未同时具备同漏洞类型和资产重叠,请结合 hit_reasons 核对,仅作参考。等级不同也可能重复。支持分页,按需读取详情。", []string{"finding_id"}}, {"get_finding_record", "读取独立漏洞完整原始证据和报告,返回探索节点编号以便追溯执行记录。", []string{"finding_id"}}, {"merge_finding_records", "证据明确证明同一缺陷时归并;保留所有原始记录。不跨任务、不改变原评级,不可绕过人工否决。", []string{"task_id", "finding_ids", "title", "reason"}}, {"suggest_finding_merge", "证据存在具体共同点但缺少关键证据时,提交一个疑似重复分组(2..20 条同任务记录,一次覆盖全部疑似成员,不要拆成两两建议),待人工确认;不是自动合并。仅类型/资产相同不应提交。", []string{"task_id", "finding_ids", "title", "reason"}}, @@ -325,7 +326,7 @@ func (s *Server) performFindingCaseTool(ctx context.Context, name string, a find } items, total, strongTotal, err := s.m.pg.FindingCaseCandidates(fid, page, 20, scopes...) return map[string]any{"items": items, "total": total, "strong_total": strongTotal, "page": page, "page_size": 20, - "note": "按分数排序;weak 为单一条件命中,仅作参考,通常不应据此建议归并"}, err + "note": "按分数排序;weak 表示未同时具备同漏洞类型和资产重叠,请结合 hit_reasons 核对,仅作参考,通常不应据此建议归并"}, err case "get_finding_record": f, err := s.m.pg.GetFinding(fid) if err != nil { @@ -390,7 +391,7 @@ func (s *Server) seedFindingCaseTools() { if err != nil || a == nil { return } - const flag = "finding_case_tools_v2_review" + const flag = "finding_case_tools_v3_review" if v, _, _ := s.m.pg.GetSetting(flag); v == "true" { return } @@ -398,6 +399,9 @@ func (s *Server) seedFindingCaseTools() { previous, _, _ := s.m.pg.GetSetting("finding_case_tools_v1") for _, t := range s.findingCaseTools() { schema, _ := json.Marshal(t.InputSchema()) + if err := s.m.pg.RefreshToolDefaults(t.Name(), t.Description(), schema); err != nil { + log.Printf("[finding-cases] refresh %s schema failed: %v", t.Name(), err) + } if err := s.m.pg.SeedTool(t.Name(), t.Description(), schema, json.RawMessage(`["reporter"]`)); err != nil { return } diff --git a/server/orchestration.go b/server/orchestration.go index 3dd95c9..44eb0d6 100644 --- a/server/orchestration.go +++ b/server/orchestration.go @@ -524,7 +524,7 @@ func (s *Server) seedOrchestrationTools() { // reaches an old DB otherwise. Preserves each tool's agent binding + enabled flag. // Bump the flag whenever these tools' schemas/descriptions change in code. func (s *Server) refreshBuiltinToolSchemas() { - const flag = "tool_schema_refresh_v7_list_facts_paging" + const flag = "tool_schema_refresh_v8_finding_case_tools" if v, _, _ := s.m.pg.GetSetting(flag); v == "true" { return } diff --git a/web/src/components/finding-case-list.tsx b/web/src/components/finding-case-list.tsx index 8ad3a62..c0b6cea 100644 --- a/web/src/components/finding-case-list.tsx +++ b/web/src/components/finding-case-list.tsx @@ -196,15 +196,25 @@ function FindingMergeSuggestionCard({ }) { const [expanded, setExpanded] = React.useState(false); const [busy, setBusy] = React.useState(false); - const ids = Array.isArray(s.finding_ids) && s.finding_ids.length > 0 ? s.finding_ids : [s.left_id, s.right_id]; + const ids = React.useMemo( + () => (Array.isArray(s.finding_ids) && s.finding_ids.length > 0 ? s.finding_ids : [s.left_id, s.right_id]), + [s.finding_ids, s.left_id, s.right_id], + ); const [excluded, setExcluded] = React.useState([]); - const included = ids.filter((id) => !excluded.includes(id)); + React.useEffect(() => { + setExcluded((current) => { + const next = current.filter((id) => ids.includes(id)); + return next.length === current.length ? current : next; + }); + }, [ids]); + const validExcluded = excluded.filter((id) => ids.includes(id)); + const included = ids.filter((id) => !validExcluded.includes(id)); async function resolve(accept: boolean) { if (busy) return; if (accept && included.length < 2) return; setBusy(true); try { - await onResolve(s, accept, accept ? excluded : []); + await onResolve(s, accept, accept ? validExcluded : []); } finally { setBusy(false); } @@ -294,13 +304,20 @@ export function FindingCaseReviewPanel({ taskId, onChange }: { taskId?: string; const taskKey = taskId ?? ""; const taskKeyRef = React.useRef(taskKey); taskKeyRef.current = taskKey; + const previousTaskKey = React.useRef(taskKey); + React.useEffect(() => { + if (previousTaskKey.current === taskKey) return; + previousTaskKey.current = taskKey; + setSuggestions([]); + setRuns([]); + setError(""); + }, [taskKey]); React.useEffect(() => { const seq = ++requestSeq.current; const requestedTask = taskKey; void reload; let active = true; let timer: ReturnType; - setSuggestions([]); async function load() { try { const [s, r] = await Promise.all([api.findingCaseSuggestions(taskId), api.findingCaseReviewRuns()]); @@ -328,18 +345,23 @@ export function FindingCaseReviewPanel({ taskId, onChange }: { taskId?: string; const requestedTask = taskKey; try { const result = await api.resolveFindingCaseSuggestion(s.id, accept, excludeIds); - if (seq !== requestSeq.current || requestedTask !== taskKeyRef.current) return; - setSuggestions((v) => v.filter((x) => x.id !== s.id)); - setReload((v) => v + 1); - if (accept && result.case_id !== "0") { - toast.success("已归并,请进入文件夹生成统一报告"); - } else { - toast.success("已否决,不会自动再次归并"); + if (requestedTask === taskKeyRef.current) { + setReload((v) => v + 1); + if (seq === requestSeq.current) { + setSuggestions((v) => v.filter((x) => x.id !== s.id)); + if (accept && result.case_id !== "0") { + toast.success("已归并,请进入文件夹生成统一报告"); + } else { + toast.success("已否决,不会自动再次归并"); + } + onChange?.(); + } } - onChange?.(); } catch (e) { - if (seq === requestSeq.current && requestedTask === taskKeyRef.current) setReload((v) => v + 1); - toast.error((e as Error).message); + if (requestedTask === taskKeyRef.current) { + setReload((v) => v + 1); + if (seq === requestSeq.current) toast.error((e as Error).message); + } } } return (