From 00ec84d14ec963bb0500a1e9b4fecd1b11294a1e Mon Sep 17 00:00:00 2001 From: RuoJi6 Date: Sat, 24 Oct 2026 19:52:26 +0800 Subject: [PATCH 1/7] feat(findings): reduce duplicate-confirmation noise with grouped suggestions and ranked candidates - Prompt: evidenced endpoint/handler/param differences justify independent; suggest only with concrete shared points - Review acceptance: merged no longer blocked by unified report - Suggestions: 2..N member groups, one-click group confirm with per-member exclusion, related cards cleared - Candidates: score/tier/hit_reasons ranking, low-score kept paginated --- agent/finding_cases.go | 8 +- agent/finding_cases_test.go | 2 +- db/finding_cases.go | 321 +++++++++++++++++++---- db/finding_cases_test.go | 159 ++++++++++- db/schema.sql | 11 + server/finding_case_reviews.go | 15 +- server/finding_case_reviews_test.go | 22 +- server/finding_cases.go | 32 ++- web/src/components/finding-case-list.tsx | 59 +++-- web/src/lib/api.ts | 7 +- web/src/lib/types.ts | 1 + 11 files changed, 532 insertions(+), 105 deletions(-) diff --git a/agent/finding_cases.go b/agent/finding_cases.go index 7e69412..370e760 100644 --- a/agent/finding_cases.go +++ b/agent/finding_cases.go @@ -45,13 +45,13 @@ func findingCaseWorkflow(key string, tools []actool.CoreTool) ([]actool.CoreTool } return tools, ` -【同一漏洞的多次上报整理】保留本次独立报告,不覆盖其他原报告。默认报告完成即停止的旧指引在此扩展:先保存本次报告,然后 search_finding_duplicates(finding_id=独立记录ID) 分页读取同任务候选摘要,按需 get_finding_record 查看证据。仅凭漏洞类型/标题相似或高中低等级不能归并;须核对目标、接口、参数、权限边界、根因及实际验证证据。 -【判断准则】接口、路径、后端、读/写影响、评级不同,都不能单独作为不重复的理由;同一登录入口、同一令牌或同一漏洞类型也不能单独证明同一根因。比较实际缺失的鉴权检查、对象权限边界、会话门或共同组件,并引用每条记录中的具体证据。读和写可能是同一权限检查缺失的不同影响;也可能是独立 handler 的独立缺陷。只有证据证明共同实际根因才自动归并,证据证明不同根因才判独立;存在关联但无法确认时必须 suggest_finding_merge,说明已知共同点、缺失证据和待确认问题,不用“路径不同/影响不同”代替根因判断。明确同一缺陷时 merge_finding_records(task_id,finding_ids,title,reason),不确定时 suggest_finding_merge 留待人工确认,不能强行归并。不重复上报、不删除记录、不重新探测目标。人工选择整理时只能判断所选记录,不能加入未选记录(既有组成员可作为报告上下文)。 -归并成功或已有case_id时,get_finding_case 记录实际version,分页读取全部成员,按需get_finding_record、get_finding_traffic读取原报告、PoC及已保存流量。update_finding_case_report保存完整统一报告(包含有内容的概述、影响、前置条件、复现步骤、证据、PoC、根因分析、修复建议章节,不得半篇截断)和基于已验证影响的severity及severity_reason;不能直接取成员最高等级。集中给出前置条件、完整执行顺序、输入输出如何衔接、可运行的完整脚本及命令,不把必要步骤分散到原报告;缺失条件、未验证步骤如实标明。版本冲突重新读取材料并生成,不能仅换version重试。各成员仍有独立报告、评级和证据。没有明确重复则保留独立记录。工具被关闭时不绕过配置。` +【同一漏洞的多次上报整理】保留本次独立报告,不覆盖其他原报告。默认报告完成即停止的旧指引在此扩展:先保存本次报告,然后 search_finding_duplicates(finding_id=独立记录ID) 分页读取同任务候选摘要(按 score 排序;strong=同漏洞类型且资产重叠,优先核对;weak=单一条件命中,仅作参考),按需 get_finding_record 查看证据。仅凭漏洞类型/标题相似或高中低等级不能归并;须核对目标、接口、参数、权限边界、根因及实际验证证据。 +【判断准则】有证据表明端点/路由、处理器或后端组件、易受影响参数、缺失检查属于不同代码路径时,可以判定 independent;但仅凭路径、接口、登录入口、令牌、标题或漏洞类型相同/不同都不能证明根因。比较实际缺失的鉴权检查、对象权限边界、会话门或共同组件,并引用每条记录中的具体证据。只有根因明确相同才用 merge_finding_records;只有存在具体共同点(同端点/参数/组件/缺失检查)但缺关键证据确认时才用 suggest_finding_merge,并一次提交覆盖2..N条记录的分组,禁止仅因类型或资产相同而建议。没有具体共同点就保持 independent,不要因不确定而建议。不重复上报、不删除记录、不重新探测目标。人工选择整理时只能判断所选记录,不能加入未选记录(既有组成员可作为报告上下文)。 +归并不以统一报告为前提:根因明确就先归并,统一报告可随后补齐(未完成时文件夹显示“统一报告待更新”)。归并成功或已有case_id时,get_finding_case 记录实际version,分页读取全部成员,按需get_finding_record、get_finding_traffic读取原报告、PoC及已保存流量。update_finding_case_report保存完整统一报告(包含有内容的概述、影响、前置条件、复现步骤、证据、PoC、根因分析、修复建议章节,不得半篇截断)和基于已验证影响的severity及severity_reason;不能直接取成员最高等级。集中给出前置条件、完整执行顺序、输入输出如何衔接、可运行的完整脚本及命令,不把必要步骤分散到原报告;缺失条件、未验证步骤如实标明。版本冲突重新读取材料并生成,不能仅换version重试。各成员仍有独立报告、评级和证据。没有明确重复则保留独立记录。工具被关闭时不绕过配置。` } const historicalFindingReviewGuidance = ` 【历史整理模式,优先于旧报告撰写及自动绑流量指引】本次只整理已经存在的上报。原始报告即使为空或待更新,也只记录缺失情况,绝不调用 update_finding_report、report_finding、bind_finding_traffic,不修改原始标题、评级、状态、证据或报告,不执行 Bash、写文件或重新探测目标。 先逐条 get_finding_record 读取所选记录,已有原报告直接作为判断材料复用,不重复撰写。只在所选范围内检查同一实际缺陷,可按需读取已存在的证据。按上述根因准则形成明确归并/独立/疑似建议;无法确认时保存建议供人工确认。 -已有或新建文件夹必须读取全部成员并写入完整、最新的统一报告;统一报告是单独的材料,不覆盖原报告。最后必须调用 complete_finding_case_review,为每条所选 finding_id 提交 verdict(independent/merged/suggested)、reason 和 root_cause_evidence,并提供总结。根因证据应说明具体相同/不同的检查及证据来源;疑似建议明确待补证据。服务端验收通过后才可宣布完成。工具不可用或验收失败时如实报告,不能只用自然语言声称完成。` +已有或新建文件夹应读取全部成员并尽力用 update_finding_case_report 写入完整、最新的统一报告;归并不因报告暂缺而阻塞,未完成时文件夹会显示“统一报告待更新”,可由后续运行或人工补齐。统一报告是单独的材料,不覆盖原报告。最后必须调用 complete_finding_case_review,为每条所选 finding_id 提交 verdict(independent/merged/suggested)、reason 和 root_cause_evidence,并提供总结。根因证据应说明具体相同/不同的检查及证据来源;疑似建议明确待补证据。服务端验收通过后才可宣布完成。工具不可用或验收失败时如实报告,不能只用自然语言声称完成。` diff --git a/agent/finding_cases_test.go b/agent/finding_cases_test.go index a8177ea..0fc0788 100644 --- a/agent/finding_cases_test.go +++ b/agent/finding_cases_test.go @@ -46,7 +46,7 @@ func TestHistoricalFindingReviewFiltersMutationAndProbeTools(t *testing.T) { t.Fatal("historical mutation capability", tool.Name()) } } - if len(out) != 6 || def.FindingGuidance != "" || !strings.Contains(def.CaseGuidance, "历史整理模式") || !strings.Contains(def.CaseGuidance, "接口、路径、后端、读/写影响、评级不同") { + if len(out) != 6 || def.FindingGuidance != "" || !strings.Contains(def.CaseGuidance, "历史整理模式") || !strings.Contains(def.CaseGuidance, "端点/路由") { t.Fatal("missing historical isolation", def) } normal, _, cl := AugmentTools(t.Context(), "reporter", tools) diff --git a/db/finding_cases.go b/db/finding_cases.go index d7ec976..5878535 100644 --- a/db/finding_cases.go +++ b/db/finding_cases.go @@ -41,13 +41,14 @@ type FindingCaseEvent struct { CreatedAt time.Time `json:"created_at"` } type FindingCaseSuggestion struct { - ID int64 `json:"id,string"` - TaskID int64 `json:"task_id,string"` - LeftID int64 `json:"left_id,string"` - RightID int64 `json:"right_id,string"` - Title string `json:"title"` - Reason string `json:"reason"` - State string `json:"state"` + ID int64 `json:"id,string"` + TaskID int64 `json:"task_id,string"` + LeftID int64 `json:"left_id,string"` + RightID int64 `json:"right_id,string"` + Title string `json:"title"` + Reason string `json:"reason"` + State string `json:"state"` + FindingIDs []string `json:"finding_ids"` } type FindingCaseRow struct { Case *FindingCase `json:"case,omitempty"` @@ -198,9 +199,13 @@ func lockCaseTask(tx *sql.Tx, taskID int64) error { return tx.QueryRow(`SELECT id FROM tasks WHERE id=$1 FOR UPDATE`, taskID).Scan(&id) } func (d *DB) MergeFindingCase(ctx context.Context, taskID int64, ids []int64, title, reason, actor string) (int64, error) { - return d.mergeFindingCase(ctx, taskID, ids, title, reason, actor, 0) + return d.mergeFindingCase(ctx, taskID, ids, title, reason, actor, 0, nil) } -func (d *DB) mergeFindingCase(ctx context.Context, taskID int64, ids []int64, title, reason, actor string, suggestionID int64) (int64, error) { + +// mergeFindingCase merges ids into one folder. With suggestionID it also resolves +// that suggestion; dropped are suggestion members a human excluded, which are vetoed +// against the merged records so the same group is not suggested again. +func (d *DB) mergeFindingCase(ctx context.Context, taskID int64, ids []int64, title, reason, actor string, suggestionID int64, dropped []int64) (int64, error) { ids, err := sortedFindingIDs(ids) if err != nil { return 0, err @@ -308,8 +313,23 @@ func (d *DB) mergeFindingCase(ctx context.Context, taskID int64, ids []int64, ti return err } } - _, err := tx.Exec(`UPDATE finding_case_suggestions SET state='accepted' WHERE state='pending' AND left_id IN(SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb)) AND right_id IN(SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb))`, string(raw)) - return err + for _, x := range dropped { + if all[x] { + // Never pull an excluded record in through an existing folder. + return fmt.Errorf("排除的记录 #%d 已与所选记录在同一文件夹,请先在文件夹中移出后再确认", x) + } + for _, y := range ids { + 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`, x, y); err != nil { + return err + } + } + } + if suggestionID > 0 { + if _, err := tx.Exec(`UPDATE finding_case_suggestions SET state='accepted' WHERE id=$1`, suggestionID); err != nil { + return err + } + } + return sweepFindingCaseSuggestions(tx, taskID) }) return cid, err } @@ -355,8 +375,10 @@ func (d *DB) RemoveFindingCaseMember(ctx context.Context, cid, fid int64, reason if _, err := tx.Exec(`INSERT INTO finding_case_events(case_id,action,actor,reason,finding_ids) VALUES($1,'detach','human',$2,jsonb_build_array($3::bigint))`, cid, reason, fid); err != nil { return err } - _, err := tx.Exec(`DELETE FROM finding_case_members WHERE case_id=$1 AND finding_id=$2`, cid, fid) - return err + if _, err := tx.Exec(`DELETE FROM finding_case_members WHERE case_id=$1 AND finding_id=$2`, cid, fid); err != nil { + return err + } + return sweepFindingCaseSuggestions(tx, origin) }) } func (d *DB) UpdateFindingCaseReport(ctx context.Context, cid, version int64, title, report, severity, reason string) error { @@ -382,41 +404,174 @@ func (d *DB) UpdateFindingCaseReport(ctx context.Context, cid, version int64, ti return err }) } -func (d *DB) SuggestFindingCase(ctx context.Context, taskID, left, right int64, title, reason string) (int64, error) { + +// suggestionMembers returns the surviving member IDs of a suggestion in ascending order. +func suggestionMembers(q interface { + Query(string, ...any) (*sql.Rows, error) +}, id int64) ([]int64, error) { + rows, err := q.Query(`SELECT finding_id FROM finding_case_suggestion_members WHERE suggestion_id=$1 ORDER BY finding_id`, id) + if err != nil { + return nil, err + } + defer rows.Close() + out := []int64{} + for rows.Next() { + var fid int64 + if err := rows.Scan(&fid); err != nil { + return nil, err + } + out = append(out, fid) + } + return out, rows.Err() +} + +// A suggestion with a human-vetoed pair can never be accepted as a whole. +const pendingSuggestionHasBlockedPair = `EXISTS(SELECT 1 FROM finding_case_suggestion_members a JOIN finding_case_suggestion_members b ON a.suggestion_id=b.suggestion_id AND a.finding_id 20 { + return 0, errors.New("疑似重复分组最多20条记录") + } var id int64 err = d.WithEvidenceTx(ctx, func(tx *sql.Tx) error { if err := lockCaseTask(tx, taskID); err != nil { return err } + cases := map[int64]bool{} for _, fid := range ids { var tid sql.NullInt64 - if err := tx.QueryRow(`SELECT task_id FROM findings WHERE id=$1`, fid).Scan(&tid); err != nil { + var cid int64 + if err := tx.QueryRow(`SELECT f.task_id,COALESCE(m.case_id,0) FROM findings f LEFT JOIN finding_case_members m ON m.finding_id=f.id WHERE f.id=$1`, fid).Scan(&tid, &cid); err != nil { return err } if !tid.Valid || tid.Int64 != taskID { return errors.New("只能建议同一任务的归并") } + cases[cid] = true } - var blocked bool - if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_blocks WHERE left_id=$1 AND right_id=$2)`, ids[0], ids[1]).Scan(&blocked); err != nil { + if len(cases) == 1 && !cases[0] { + return errors.New("这些记录已在同一文件夹中,无需建议") + } + raw, _ := json.Marshal(ids) + blocked := []string{} + rows, err := tx.Query(`SELECT left_id,right_id FROM finding_case_blocks WHERE left_id IN(SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb)) AND right_id IN(SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb)) ORDER BY left_id,right_id`, string(raw)) + if err != nil { return err } - if blocked { - return errors.New("人工已否决该归并") + for rows.Next() { + var l, r int64 + if err := rows.Scan(&l, &r); err != nil { + rows.Close() + return err + } + blocked = append(blocked, fmt.Sprintf("#%d/#%d", l, r)) } - return tx.QueryRow(`INSERT INTO finding_case_suggestions(task_id,left_id,right_id,title,reason) VALUES($1,$2,$3,$4,$5) ON CONFLICT(left_id,right_id) DO UPDATE SET reason=EXCLUDED.reason WHERE finding_case_suggestions.state='pending' RETURNING id`, taskID, ids[0], ids[1], title, reason).Scan(&id) + err = rows.Err() + rows.Close() + if err != nil { + return err + } + if len(blocked) > 0 { + return fmt.Errorf("人工已否决这些记录的归并:%s;请去掉其中一方后重新提交", strings.Join(blocked, "、")) + } + // Compare against every pending group of the task; read all rows before issuing more statements. + type pending struct { + id int64 + members map[int64]bool + } + groups := []pending{} + rows, err = tx.Query(`SELECT s.id,sm.finding_id FROM finding_case_suggestions s JOIN finding_case_suggestion_members sm ON sm.suggestion_id=s.id WHERE s.task_id=$1 AND s.state='pending' ORDER BY s.id`, taskID) + if err != nil { + return err + } + for rows.Next() { + var sid, fid int64 + if err := rows.Scan(&sid, &fid); err != nil { + rows.Close() + return err + } + if len(groups) == 0 || groups[len(groups)-1].id != sid { + groups = append(groups, pending{id: sid, members: map[int64]bool{}}) + } + groups[len(groups)-1].members[fid] = true + } + err = rows.Err() + rows.Close() + if err != nil { + return err + } + want := map[int64]bool{} + for _, fid := range ids { + want[fid] = true + } + superseded := []int64{} + for _, g := range groups { + inNew := 0 + for fid := range g.members { + if want[fid] { + inNew++ + } + } + switch { + case inNew == len(ids) && len(g.members) == len(ids): + id = g.id + _, err := tx.Exec(`UPDATE finding_case_suggestions SET title=$2,reason=$3 WHERE id=$1`, g.id, title, reason) + return err + case inNew == len(ids): + // An existing pending group already covers every requested record. + id = g.id + return nil + case inNew == len(g.members): + superseded = append(superseded, g.id) + } + } + for _, sid := range superseded { + if _, err := tx.Exec(`UPDATE finding_case_suggestions SET state='superseded' WHERE id=$1 AND state='pending'`, sid); err != nil { + return err + } + } + if err := tx.QueryRow(`INSERT INTO finding_case_suggestions(task_id,left_id,right_id,title,reason) VALUES($1,$2,$3,$4,$5) RETURNING id`, taskID, ids[0], ids[1], title, reason).Scan(&id); err != nil { + return err + } + _, err = tx.Exec(`INSERT INTO finding_case_suggestion_members(suggestion_id,finding_id) SELECT $1,value::bigint FROM jsonb_array_elements_text($2::jsonb)`, id, string(raw)) + return err }) return id, err } + +// FindingCaseSuggestions lists pending groups with at least two surviving, +// non-vetoed members. func (d *DB) FindingCaseSuggestions(taskID int64) ([]FindingCaseSuggestion, error) { - rows, err := d.Query(`SELECT id,task_id,left_id,right_id,title,reason,state FROM finding_case_suggestions WHERE state='pending' AND ($1::bigint=0 OR task_id=$1) ORDER BY id DESC`, taskID) + rows, err := d.Query(`SELECT s.id,s.task_id,s.left_id,s.right_id,s.title,s.reason,s.state,COALESCE((SELECT jsonb_agg(sm.finding_id ORDER BY sm.finding_id) FROM finding_case_suggestion_members sm WHERE sm.suggestion_id=s.id),'[]'::jsonb) + FROM finding_case_suggestions s WHERE s.state='pending' AND ($1::bigint=0 OR s.task_id=$1) AND NOT `+pendingSuggestionHasBlockedPair+` + AND (SELECT count(*) FROM finding_case_suggestion_members sm WHERE sm.suggestion_id=s.id)>=2 + AND NOT EXISTS(SELECT 1 FROM finding_case_suggestion_members sm JOIN findings f ON f.id=sm.finding_id WHERE sm.suggestion_id=s.id AND f.task_id IS DISTINCT FROM s.task_id) + ORDER BY s.id DESC`, taskID) if err != nil { return nil, err } @@ -424,14 +579,27 @@ func (d *DB) FindingCaseSuggestions(taskID int64) ([]FindingCaseSuggestion, erro out := []FindingCaseSuggestion{} for rows.Next() { var s FindingCaseSuggestion - if err := rows.Scan(&s.ID, &s.TaskID, &s.LeftID, &s.RightID, &s.Title, &s.Reason, &s.State); err != nil { + var raw []byte + if err := rows.Scan(&s.ID, &s.TaskID, &s.LeftID, &s.RightID, &s.Title, &s.Reason, &s.State, &raw); err != nil { return nil, err } + var members []int64 + if err := json.Unmarshal(raw, &members); err != nil { + return nil, err + } + s.FindingIDs = make([]string, 0, len(members)) + for _, fid := range members { + s.FindingIDs = append(s.FindingIDs, strconv.FormatInt(fid, 10)) + } out = append(out, s) } return out, rows.Err() } -func (d *DB) ResolveFindingCaseSuggestion(ctx context.Context, id int64, accept bool) (int64, error) { + +// ResolveFindingCaseSuggestion confirms or rejects a whole group. On accept the +// remaining members (minus exclude) are merged once; excluded records are vetoed +// against the remaining members. Rejecting vetoes every pair inside the group. +func (d *DB) ResolveFindingCaseSuggestion(ctx context.Context, id int64, accept bool, exclude []int64) (int64, error) { var s FindingCaseSuggestion if err := d.QueryRow(`SELECT task_id,left_id,right_id,title,reason,state FROM finding_case_suggestions WHERE id=$1`, id).Scan(&s.TaskID, &s.LeftID, &s.RightID, &s.Title, &s.Reason, &s.State); err != nil { return 0, err @@ -439,8 +607,35 @@ func (d *DB) ResolveFindingCaseSuggestion(ctx context.Context, id int64, accept if s.State != "pending" { return 0, ErrFindingCaseConflict } + members, err := suggestionMembers(d, id) + if err != nil { + return 0, err + } + hasLeft, hasRight := false, false + for _, x := range members { + hasLeft = hasLeft || x == s.LeftID + hasRight = hasRight || x == s.RightID + } + if len(members) < 2 || !hasLeft || !hasRight { + return 0, ErrFindingCaseConflict + } if accept { - return d.mergeFindingCase(ctx, s.TaskID, []int64{s.LeftID, s.RightID}, s.Title, s.Reason, "human", id) + ex := map[int64]bool{} + for _, x := range exclude { + ex[x] = true + } + keep, dropped := []int64{}, []int64{} + for _, x := range members { + if ex[x] { + dropped = append(dropped, x) + } else { + keep = append(keep, x) + } + } + if len(keep) < 2 { + return 0, errors.New("至少保留两条记录才能确认归并") + } + return d.mergeFindingCase(ctx, s.TaskID, keep, s.Title, s.Reason, "human", id, dropped) } return 0, d.WithEvidenceTx(ctx, func(tx *sql.Tx) error { if err := lockCaseTask(tx, s.TaskID); err != nil { @@ -453,10 +648,19 @@ func (d *DB) ResolveFindingCaseSuggestion(ctx context.Context, id int64, accept if n, _ := res.RowsAffected(); n != 1 { return ErrFindingCaseConflict } - if _, err := tx.Exec(`INSERT INTO finding_case_blocks(left_id,right_id,reason) VALUES($1,$2,'人工否决') ON CONFLICT DO NOTHING`, s.LeftID, s.RightID); err != nil { + // Re-read members under the task lock so the veto matches what was rejected. + current, err := suggestionMembers(tx, id) + if err != nil { return err } - return nil + for i := range current { + for j := i + 1; j < len(current); j++ { + if _, err := tx.Exec(`INSERT INTO finding_case_blocks(left_id,right_id,reason) VALUES($1,$2,'人工否决') ON CONFLICT DO NOTHING`, current[i], current[j]); err != nil { + return err + } + } + } + return sweepFindingCaseSuggestions(tx, s.TaskID) }) } @@ -479,13 +683,16 @@ func (d *DB) DistinctFindingStats(taskID string) (*FindingDistinctStats, error) } // Compact candidate pages exclude PoC/report bodies and do not treat severity as identity. -func (d *DB) FindingCaseCandidates(fid int64, page, size int, scope ...[]int64) ([]map[string]any, int, error) { +// Recall stays broad (type OR name OR shared asset) so nothing is dropped, but rows are +// ranked: same vulnclass AND shared asset is "strong"; a single-condition hit is "weak". +// strong is the total number of strong candidates across all pages. +func (d *DB) FindingCaseCandidates(fid int64, page, size int, scope ...[]int64) (items []map[string]any, total int, strong int, err error) { f, err := d.FindingCaseSummary(fid) if err != nil { - return nil, 0, err + return nil, 0, 0, err } if f == nil || f.TaskID == nil { - return nil, 0, errors.New("漏洞任务不可用") + return nil, 0, 0, errors.New("漏洞任务不可用") } if page < 1 { page = 1 @@ -497,40 +704,62 @@ func (d *DB) FindingCaseCandidates(fid int64, page, size int, scope ...[]int64) if f.AssetIDs == nil { assets = []byte("[]") } - // Type/asset/name only retrieve candidates; the Reporter must still prove the root cause. - where := ` WHERE f.task_id=$1 AND f.id<>$2 AND NOT EXISTS(SELECT 1 FROM finding_case_blocks b WHERE b.left_id=LEAST(f.id,$2::bigint) AND b.right_id=GREATEST(f.id,$2::bigint)) - AND ((trim($3::text)<>'' AND lower(f.vulnclass)=lower($3)) OR (trim($4::text)<>'' AND lower(f.name)=lower($4)) - OR EXISTS(SELECT 1 FROM jsonb_array_elements(COALESCE(NULLIF(f.asset_ids,'null'::jsonb),'[]'::jsonb)) a WHERE a.value IN(SELECT value FROM jsonb_array_elements($5::jsonb))))` + // Type/asset/name only retrieve and rank candidates; the Reporter must still prove the root cause. + hits := `SELECT f.*, + (trim($3::text)<>'' AND lower(f.vulnclass)=lower($3)) AS same_class, + (trim($4::text)<>'' AND lower(f.name)=lower($4)) AS same_name, + EXISTS(SELECT 1 FROM jsonb_array_elements(CASE WHEN jsonb_typeof(f.asset_ids)='array' THEN f.asset_ids ELSE '[]'::jsonb END) a WHERE a.value IN(SELECT value FROM jsonb_array_elements($5::jsonb))) AS shared_asset + FROM findings f WHERE f.task_id=$1 AND f.id<>$2 AND NOT EXISTS(SELECT 1 FROM finding_case_blocks b WHERE b.left_id=LEAST(f.id,$2::bigint) AND b.right_id=GREATEST(f.id,$2::bigint))` args := []any{*f.TaskID, fid, f.VulnClass, f.Name, string(assets)} if len(scope) > 0 { raw, err := json.Marshal(scope[0]) if err != nil { - return nil, 0, err + return nil, 0, 0, err } - where += ` AND f.id IN (SELECT jsonb_array_elements_text($6::jsonb)::bigint)` + hits += ` AND f.id IN (SELECT jsonb_array_elements_text($6::jsonb)::bigint)` args = append(args, string(raw)) } - var total int - if err := d.QueryRow(`SELECT count(*) FROM findings f`+where, args...).Scan(&total); err != nil { - return nil, 0, err + base := `WITH hits AS (` + hits + `), cand AS (SELECT h.*, + (CASE WHEN same_class THEN 2 ELSE 0 END + CASE WHEN shared_asset THEN 2 ELSE 0 END + CASE WHEN same_name THEN 1 ELSE 0 END + + CASE WHEN same_class AND shared_asset THEN 3 ELSE 0 END) AS score + FROM hits h WHERE same_class OR same_name OR shared_asset)` + if err := d.QueryRow(base+` SELECT count(*),count(*) FILTER(WHERE same_class AND shared_asset) FROM cand`, args...).Scan(&total, &strong); err != nil { + return nil, 0, 0, err } args = append(args, size, (page-1)*size) - rows, err := d.Query(`SELECT f.id,f.name,f.vulnclass,f.severity,left(f.summary,400),f.asset_ids,COALESCE(m.case_id,0) FROM findings f LEFT JOIN finding_case_members m ON m.finding_id=f.id`+where+fmt.Sprintf(` ORDER BY f.id DESC LIMIT $%d OFFSET $%d`, len(args)-1, len(args)), args...) + rows, err := d.Query(base+fmt.Sprintf(` SELECT c.id,c.name,c.vulnclass,c.severity,left(c.summary,400),c.asset_ids,COALESCE(m.case_id,0),c.score,c.same_class,c.shared_asset,c.same_name + FROM cand c LEFT JOIN finding_case_members m ON m.finding_id=c.id ORDER BY c.score DESC,c.id DESC LIMIT $%d OFFSET $%d`, len(args)-1, len(args)), args...) if err != nil { - return nil, 0, err + return nil, 0, 0, err } defer rows.Close() - out := []map[string]any{} + items = []map[string]any{} for rows.Next() { var id, cid int64 + var score int + var sameClass, sharedAsset, sameName bool var name, class, severity, summary string var assetIDs json.RawMessage - if err := rows.Scan(&id, &name, &class, &severity, &summary, &assetIDs, &cid); err != nil { - return nil, 0, err + if err := rows.Scan(&id, &name, &class, &severity, &summary, &assetIDs, &cid, &score, &sameClass, &sharedAsset, &sameName); err != nil { + return nil, 0, 0, err } - out = append(out, map[string]any{"finding_id": strconv.FormatInt(id, 10), "name": name, "vulnclass": class, "severity": severity, "summary": summary, "asset_ids": assetIDs, "case_id": strconv.FormatInt(cid, 10)}) + reasons := []string{} + if sameClass { + reasons = append(reasons, "同漏洞类型") + } + if sharedAsset { + reasons = append(reasons, "资产重叠") + } + if sameName { + reasons = append(reasons, "同名称") + } + tier := "weak" + if sameClass && sharedAsset { + tier = "strong" + } + items = append(items, map[string]any{"finding_id": strconv.FormatInt(id, 10), "name": name, "vulnclass": class, "severity": severity, "summary": summary, "asset_ids": assetIDs, "case_id": strconv.FormatInt(cid, 10), "score": score, "tier": tier, "hit_reasons": reasons}) } - return out, total, rows.Err() + return items, total, strong, rows.Err() } // Batch task-list counters avoid an additional database round trip per task. diff --git a/db/finding_cases_test.go b/db/finding_cases_test.go index 48398a5..a50cab4 100644 --- a/db/finding_cases_test.go +++ b/db/finding_cases_test.go @@ -118,11 +118,11 @@ func TestFindingCaseScopeSuggestionsAndDeletion(t *testing.T) { if _, err := d.MergeFindingCase(ctx, task.ID, []int64{ids[0], fid}, "x", "x", "reporter"); err == nil { t.Fatal("cross task accepted") } - sid, err := d.SuggestFindingCase(ctx, task.ID, ids[0], ids[1], "order", "uncertain") + sid, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[0], ids[1]}, "order", "uncertain") if err != nil { t.Fatal(err) } - if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, false); err != nil { + if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, false, nil); err != nil { t.Fatal(err) } if _, err := d.MergeFindingCase(ctx, task.ID, ids[:2], "x", "x", "reporter"); err == nil { @@ -233,7 +233,7 @@ func TestFindingCaseTaskDeletionAndRepeatedDetach(t *testing.T) { func TestFindingCaseSuggestionResolutionIsAtomic(t *testing.T) { d, task, ids := caseTestDB(t) ctx := context.Background() - sid, err := d.SuggestFindingCase(ctx, task.ID, ids[0], ids[1], "orders", "uncertain") + sid, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[0], ids[1]}, "orders", "uncertain") if err != nil { t.Fatal(err) } @@ -241,7 +241,7 @@ func TestFindingCaseSuggestionResolutionIsAtomic(t *testing.T) { var wg sync.WaitGroup for _, accept := range []bool{true, false} { wg.Add(1) - go func(a bool) { defer wg.Done(); _, e := d.ResolveFindingCaseSuggestion(ctx, sid, a); results <- e }(accept) + go func(a bool) { defer wg.Done(); _, e := d.ResolveFindingCaseSuggestion(ctx, sid, a, nil); results <- e }(accept) } wg.Wait() close(results) @@ -269,7 +269,7 @@ func TestFindingCaseCandidatesAreCompactAndRelated(t *testing.T) { if _, err := d.Exec(`UPDATE findings SET vulnclass='SQLi',name='search injection' WHERE id=$1`, ids[3]); err != nil { t.Fatal(err) } - items, total, err := d.FindingCaseCandidates(ids[0], 1, 1) + items, total, _, err := d.FindingCaseCandidates(ids[0], 1, 1) if err != nil || total != 2 || len(items) != 1 { t.Fatalf("candidate pagination %d %+v %v", total, items, err) } @@ -286,7 +286,7 @@ func TestFindingCaseCandidatesAreCompactAndRelated(t *testing.T) { if err := d.RemoveFindingCaseMember(context.Background(), cid, ids[1], "different cause"); err != nil { t.Fatal(err) } - _, total, err = d.FindingCaseCandidates(ids[0], 1, 20) + _, total, _, err = d.FindingCaseCandidates(ids[0], 1, 20) if err != nil || total != 1 { t.Fatalf("veto excluded %d %v", total, err) } @@ -355,3 +355,150 @@ func TestFindingCaseViewContextAndAssetFiltering(t *testing.T) { t.Fatalf("original severity order %+v %v", rows, err) } } + +func suggestionState(t *testing.T, d *DB, id int64) string { + t.Helper() + var state string + if err := d.QueryRow(`SELECT state FROM finding_case_suggestions WHERE id=$1`, id).Scan(&state); err != nil { + t.Fatal(err) + } + return state +} + +func TestFindingCaseClusterSuggestionLifecycle(t *testing.T) { + d, task, ids := caseTestDB(t) + ctx := context.Background() + pair, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[1], ids[0]}, "orders", "same handler, owner check unverified") + if err != nil { + t.Fatal(err) + } + group, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[0], ids[1], ids[2], ids[3]}, "orders", "same handler for all four") + if err != nil { + t.Fatal(err) + } + if group == pair || suggestionState(t, d, pair) != "superseded" { + t.Fatalf("subset pair not superseded: %d %d %s", pair, group, suggestionState(t, d, pair)) + } + // Re-suggesting a subset of a pending group reuses the group instead of adding a card. + again, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[2], ids[3]}, "orders", "dup") + if err != nil || again != group { + t.Fatalf("subset reuse %d %v", again, err) + } + list, err := d.FindingCaseSuggestions(task.ID) + if err != nil || len(list) != 1 || !reflect.DeepEqual(list[0].FindingIDs, []string{fmt.Sprint(ids[0]), fmt.Sprint(ids[1]), fmt.Sprint(ids[2]), fmt.Sprint(ids[3])}) { + t.Fatalf("grouped list %+v %v", list, err) + } + if _, err := d.ResolveFindingCaseSuggestion(ctx, group, true, []int64{ids[1], ids[2], ids[3]}); err == nil { + t.Fatal("accepted with fewer than two records") + } + cid, err := d.ResolveFindingCaseSuggestion(ctx, group, true, []int64{ids[3]}) + if err != nil || cid == 0 { + t.Fatal(cid, err) + } + c, err := d.GetFindingCase(cid) + if err != nil || c.Count != 3 { + t.Fatalf("one merge of remaining members %+v %v", c, err) + } + if other, _ := d.FindingCaseID(ids[3]); other != 0 { + t.Fatal("excluded record merged") + } + if suggestionState(t, d, group) != "accepted" { + t.Fatal("group not accepted") + } + var events int + if err := d.QueryRow(`SELECT count(*) FROM finding_case_events WHERE case_id=$1 AND action='merge'`, cid).Scan(&events); err != nil || events != 1 { + t.Fatalf("merge events %d %v", events, err) + } + if _, err := d.MergeFindingCase(ctx, task.ID, []int64{ids[0], ids[3]}, "x", "x", "reporter"); err == nil { + t.Fatal("human exclusion not kept as veto") + } + if _, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[2], ids[3]}, "x", "x"); err == nil { + t.Fatal("vetoed pair suggested again") + } + if _, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[0], ids[1]}, "x", "x"); err == nil { + t.Fatal("records already in one folder suggested") + } + if list, _ := d.FindingCaseSuggestions(task.ID); len(list) != 0 { + t.Fatalf("stale cards remain %+v", list) + } +} + +func TestFindingCaseClusterRejectVetoesAllPairsAndMergeClearsCards(t *testing.T) { + d, task, ids := caseTestDB(t) + ctx := context.Background() + a, err := d.SuggestFindingCase(ctx, task.ID, ids[:3], "orders", "shared component") + if err != nil { + t.Fatal(err) + } + if _, err := d.ResolveFindingCaseSuggestion(ctx, a, false, nil); err != nil { + t.Fatal(err) + } + var blocks int + if err := d.QueryRow(`SELECT count(*) FROM finding_case_blocks WHERE left_id=ANY(ARRAY[$1,$2,$3]::bigint[]) AND right_id=ANY(ARRAY[$1,$2,$3]::bigint[])`, ids[0], ids[1], ids[2]).Scan(&blocks); err != nil || blocks != 3 { + t.Fatalf("blocks %d %v", blocks, err) + } + b, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[2], ids[3]}, "orders", "same param") + if err != nil { + t.Fatal(err) + } + // A direct reporter merge covering a pending group clears the card. + if _, err := d.MergeFindingCase(ctx, task.ID, []int64{ids[2], ids[3]}, "orders", "proof", "reporter"); err != nil { + t.Fatal(err) + } + if suggestionState(t, d, b) != "accepted" { + t.Fatal("covered suggestion still pending") + } +} + +func TestFindingCaseCandidatesRankStrongBeforeWeak(t *testing.T) { + d, task, _ := caseTestDB(t) + add := func(class, name string, assets []int64) int64 { + id, err := d.AddFinding(task.ID, 0, class, name, "high", "s", "e", "worker", assets) + if err != nil { + t.Fatal(err) + } + return id + } + base := add("SSRF", "fetch proxy", []int64{900001}) + strong := add("SSRF", "image import", []int64{900001}) + assetOnly := add("XSS", "comment", []int64{900001}) + nameOnly := add("RCE", "fetch proxy", nil) + items, total, strongTotal, err := d.FindingCaseCandidates(base, 1, 20) + if err != nil { + t.Fatal(err) + } + if total != 3 || strongTotal != 1 || len(items) != 3 { + t.Fatalf("recall kept %d strong %d %+v", total, strongTotal, items) + } + if items[0]["finding_id"] != fmt.Sprint(strong) || items[0]["tier"] != "strong" || !reflect.DeepEqual(items[0]["hit_reasons"], []string{"同漏洞类型", "资产重叠"}) { + t.Fatalf("strong first %+v", items[0]) + } + if items[1]["finding_id"] != fmt.Sprint(assetOnly) || items[2]["finding_id"] != fmt.Sprint(nameOnly) || items[2]["tier"] != "weak" { + t.Fatalf("weak ordering %+v", items) + } + if items[0]["score"].(int) <= items[1]["score"].(int) { + t.Fatalf("scores %+v", items) + } + page2, total2, _, err := d.FindingCaseCandidates(base, 2, 2) + if err != nil || total2 != 3 || len(page2) != 1 || page2[0]["finding_id"] != fmt.Sprint(nameOnly) { + t.Fatalf("low score candidates stay paginated %+v %v", page2, err) + } +} + +func TestFindingCaseClusterExclusionCannotBePulledInByFolder(t *testing.T) { + d, task, ids := caseTestDB(t) + ctx := context.Background() + if _, err := d.MergeFindingCase(ctx, task.ID, ids[:2], "orders", "proof", "reporter"); err != nil { + t.Fatal(err) + } + sid, err := d.SuggestFindingCase(ctx, task.ID, ids[:3], "orders", "shared handler") + if err != nil { + t.Fatal(err) + } + if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, true, []int64{ids[1]}); err == nil { + t.Fatal("excluded member merged through its existing folder") + } + if cid, _ := d.FindingCaseID(ids[2]); cid != 0 || suggestionState(t, d, sid) != "pending" { + t.Fatal("failed exclusion was not rolled back") + } +} diff --git a/db/schema.sql b/db/schema.sql index 1fe2311..44d7c4f 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1488,6 +1488,17 @@ CREATE TABLE IF NOT EXISTS finding_case_suggestions ( UNIQUE(left_id,right_id), CHECK(left_id < right_id) ); +ALTER TABLE finding_case_suggestions DROP CONSTRAINT IF EXISTS finding_case_suggestions_left_id_right_id_key; +CREATE TABLE IF NOT EXISTS finding_case_suggestion_members ( + suggestion_id BIGINT NOT NULL REFERENCES finding_case_suggestions(id) ON DELETE CASCADE, + finding_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE, + PRIMARY KEY(suggestion_id,finding_id) +); +CREATE INDEX IF NOT EXISTS finding_case_suggestion_members_finding_idx ON finding_case_suggestion_members(finding_id); +INSERT INTO finding_case_suggestion_members(suggestion_id,finding_id) + SELECT id,left_id FROM finding_case_suggestions ON CONFLICT DO NOTHING; +INSERT INTO finding_case_suggestion_members(suggestion_id,finding_id) + SELECT id,right_id FROM finding_case_suggestions ON CONFLICT DO NOTHING; -- Rejected/detached pairs are a durable human veto, also checked on group unions. CREATE TABLE IF NOT EXISTS finding_case_blocks ( left_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE, diff --git a/server/finding_case_reviews.go b/server/finding_case_reviews.go index 16a9936..8303a20 100644 --- a/server/finding_case_reviews.go +++ b/server/finding_case_reviews.go @@ -35,7 +35,7 @@ func (s *Server) toolCompleteFindingCaseReview() actool.CoreTool { "verdict": map[string]any{"type": "string", "enum": []string{"independent", "merged", "suggested"}}, "reason": strParam("判断理由,接口或读写影响不同不能单独证明不同根因"), "root_cause_evidence": strParam("原始报告/证据中支持实际共同或不同根因的具体材料;不确定则说明缺失证据及已保存建议")}, "finding_id", "verdict", "reason", "root_cause_evidence") - return wrTool("complete_finding_case_review", "历史整理结束前提交每条所选记录的根因判断并验收:必须已读取全部记录,疑似必须已有建议,归并必须已有完整最新统一报告。验收失败不能宣布完成。", objSchema(map[string]any{"summary": strParam("整理结果总结"), "decisions": map[string]any{"type": "array", "items": decision}}, "summary", "decisions"), func(ctx context.Context, raw json.RawMessage) (actool.Result, error) { + return wrTool("complete_finding_case_review", "历史整理结束前提交每条所选记录的根因判断并验收:必须已读取全部记录,疑似必须已有建议;归并只需已在活动文件夹中,统一报告可稍后补齐。", objSchema(map[string]any{"summary": strParam("整理结果总结"), "decisions": map[string]any{"type": "array", "items": decision}}, "summary", "decisions"), func(ctx context.Context, raw json.RawMessage) (actool.Result, error) { var a findingReviewConclusion if err := json.Unmarshal(raw, &a); err != nil { return actool.Errorf(err.Error()), nil @@ -101,7 +101,7 @@ func (s *Server) saveFindingReviewConclusion(ctx context.Context, conv int64, a } case "suggested": var pending bool - if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_suggestions WHERE state='pending' AND (left_id=$1 OR right_id=$1))`, id).Scan(&pending); err != nil { + if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_suggestions s JOIN finding_case_suggestion_members m ON m.suggestion_id=s.id WHERE s.state='pending' AND m.finding_id=$1)`, id).Scan(&pending); err != nil { return err } if !pending { @@ -115,17 +115,12 @@ func (s *Server) saveFindingReviewConclusion(ctx context.Context, conv int64, a return errors.New("无效判断类型") } if cid.Valid { - var report string - var version, rv int64 var active bool - if err := tx.QueryRow(`SELECT report,version,report_version,active FROM finding_cases WHERE id=$1 FOR SHARE`, cid.Int64).Scan(&report, &version, &rv, &active); err != nil { + if err := tx.QueryRow(`SELECT active FROM finding_cases WHERE id=$1 FOR SHARE`, cid.Int64).Scan(&active); err != nil { return err } - if !active || rv != version { - return fmt.Errorf("文件夹 #%d 的统一报告待更新,请重新读取后更新", cid.Int64) - } - if err := validateUnifiedFindingReport(report); err != nil { - return err + if !active { + return fmt.Errorf("文件夹 #%d 已关闭", cid.Int64) } } } diff --git a/server/finding_case_reviews_test.go b/server/finding_case_reviews_test.go index 06e1b58..589aee0 100644 --- a/server/finding_case_reviews_test.go +++ b/server/finding_case_reviews_test.go @@ -43,7 +43,7 @@ func TestUnifiedFindingReportRejectsIncompleteAndTruncated(t *testing.T) { } } -func TestFindingCaseReviewCompletionRequiresReadsAndFreshReport(t *testing.T) { +func TestFindingCaseReviewCompletionRequiresReadsNotUnifiedReport(t *testing.T) { s, task, ids := testCaseServer(t) runs, err := s.m.pg.CreateFindingCaseReviews(t.Context(), map[int64][]int64{task: ids}) if err != nil { @@ -71,15 +71,9 @@ func TestFindingCaseReviewCompletionRequiresReadsAndFreshReport(t *testing.T) { if err != nil { t.Fatal(err) } - if err := s.saveFindingReviewConclusion(ctx, conv, a); err == nil { - t.Fatal("missing unified report accepted") - } - c, _ := s.m.pg.GetFindingCase(cid) - if err := s.m.pg.UpdateFindingCaseReport(ctx, cid, c.Version, "orders", completeFindingReviewReport, "high", "verified impact"); err != nil { - t.Fatal(err) - } + // A merged verdict no longer waits for the unified report; the folder shows it as pending. if err := s.saveFindingReviewConclusion(ctx, conv, a); err != nil { - t.Fatal(err) + t.Fatalf("merge blocked by missing unified report: %v", err) } if err := s.validateFindingReviewCompletion(conv); err != nil { t.Fatal(err) @@ -90,8 +84,14 @@ func TestFindingCaseReviewCompletionRequiresReadsAndFreshReport(t *testing.T) { if _, err := s.m.pg.Exec(`UPDATE findings SET evidence='new proof' WHERE id=$1`, ids[0]); err != nil { t.Fatal(err) } + if err := s.validateFindingReviewCompletion(conv); err != nil { + t.Fatalf("stale unified report should not fail a merged review: %v", err) + } + if err := s.m.pg.RemoveFindingCaseMember(ctx, cid, ids[0], "different cause"); err != nil { + t.Fatal(err) + } if err := s.validateFindingReviewCompletion(conv); err == nil { - t.Fatal("stale group reported completed") + t.Fatal("merged verdict accepted after the record left the folder") } } @@ -138,7 +138,7 @@ func TestFindingCaseReviewSuggestionNeedsSavedSuggestion(t *testing.T) { if err := s.saveFindingReviewConclusion(ctx, conv, a); err == nil { t.Fatal("unpersisted suggestion accepted") } - if _, err := s.m.pg.SuggestFindingCase(ctx, task, ids[0], ids[1], "orders", "common component unknown"); err != nil { + if _, err := s.m.pg.SuggestFindingCase(ctx, task, []int64{ids[0], ids[1]}, "orders", "common component unknown"); err != nil { t.Fatal(err) } if err := s.saveFindingReviewConclusion(ctx, conv, a); err != nil { diff --git a/server/finding_cases.go b/server/finding_cases.go index d8afaf8..bc08b0f 100644 --- a/server/finding_cases.go +++ b/server/finding_cases.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "net/http" + "strconv" "strings" "github.com/Autumn-27/artex/db" @@ -203,12 +204,22 @@ func (s *Server) caseSuggestions(w http.ResponseWriter, r *http.Request) { } func (s *Server) resolveCaseSuggestion(w http.ResponseWriter, r *http.Request) { var a struct { - Accept bool `json:"accept"` + Accept bool `json:"accept"` + ExcludeIDs []string `json:"exclude_ids"` } if !decodeCaseRequest(w, r, &a) { return } - id, err := s.m.pg.ResolveFindingCaseSuggestion(r.Context(), int64(atoiDefault(r.PathValue("id"), 0)), a.Accept) + exclude := make([]int64, 0, len(a.ExcludeIDs)) + for _, v := range a.ExcludeIDs { + id, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil || id <= 0 { + caseHTTPError(w, errors.New("无效的排除记录编号")) + return + } + exclude = append(exclude, id) + } + id, err := s.m.pg.ResolveFindingCaseSuggestion(r.Context(), int64(atoiDefault(r.PathValue("id"), 0)), a.Accept, exclude) if err != nil { caseHTTPError(w, err) return @@ -253,10 +264,10 @@ func (s *Server) findingCaseTools() []actool.CoreTool { name, desc string required []string }{ - {"search_finding_duplicates", "只读检索同任务候选,返回摘要与资产;等级不同也可能重复。支持分页,按需读取详情。", []string{"finding_id"}}, + {"search_finding_duplicates", "只读检索同任务候选摘要与资产,按分数排序并返回 score、tier、hit_reasons:strong=同漏洞类型且资产重叠,weak=单一条件命中仅作参考。等级不同也可能重复。支持分页,按需读取详情。", []string{"finding_id"}}, {"get_finding_record", "读取独立漏洞完整原始证据和报告,返回探索节点编号以便追溯执行记录。", []string{"finding_id"}}, {"merge_finding_records", "证据明确证明同一缺陷时归并;保留所有原始记录。不跨任务、不改变原评级,不可绕过人工否决。", []string{"task_id", "finding_ids", "title", "reason"}}, - {"suggest_finding_merge", "证据不足时提交疑似重复建议,待人工确认;不是自动合并。只允许两条同任务记录。", []string{"task_id", "finding_ids", "title", "reason"}}, + {"suggest_finding_merge", "证据存在具体共同点但缺少关键证据时,提交一个疑似重复分组(2..N 条同任务记录,一次覆盖全部疑似成员,不要拆成两两建议),待人工确认;不是自动合并。仅类型/资产相同不应提交。", []string{"task_id", "finding_ids", "title", "reason"}}, {"get_finding_case", "读取归并组和成员分页。先记录version,再读取成员的原报告、证据及流量;版本变化必须重新读取。", []string{"case_id"}}, {"update_finding_case_report", "保存统一报告及评级理由,version必须来自实际读取。保留各条原报告,不新建漏洞。", []string{"case_id", "version", "title", "report", "severity", "severity_reason"}}, } @@ -312,8 +323,9 @@ func (s *Server) performFindingCaseTool(ctx context.Context, name string, a find if scope != nil { scopes = append(scopes, scope) } - items, total, err := s.m.pg.FindingCaseCandidates(fid, page, 20, scopes...) - return map[string]any{"items": items, "total": total, "page": page, "page_size": 20}, err + items, total, strong, err := s.m.pg.FindingCaseCandidates(fid, page, 20, scopes...) + return map[string]any{"items": items, "total": total, "strong_total": strong, "page": page, "page_size": 20, + "note": "按分数排序:strong=同漏洞类型且资产重叠,优先核对;weak=单一条件命中,仅作参考,没有具体共同点时不应据此建议归并"}, err case "get_finding_record": f, err := s.m.pg.GetFinding(fid) if err != nil { @@ -341,11 +353,11 @@ func (s *Server) performFindingCaseTool(ctx context.Context, name string, a find return map[string]string{"case_id": i64s(id)}, err case "suggest_finding_merge": ids := a.ids() - if len(ids) != 2 { - return nil, errors.New("疑似重复建议需要两条记录") + if len(ids) < 2 || len(ids) > 20 { + return nil, errors.New("疑似重复分组需要2至20条记录") } - id, err := s.m.pg.SuggestFindingCase(ctx, parseProfileID(a.TaskID), ids[0], ids[1], a.Title, a.Reason) - return map[string]string{"suggestion_id": i64s(id)}, err + id, err := s.m.pg.SuggestFindingCase(ctx, parseProfileID(a.TaskID), ids, a.Title, a.Reason) + return map[string]any{"suggestion_id": i64s(id), "finding_ids": ids}, err case "get_finding_case": c, err := s.m.pg.GetFindingCase(cid) if err != nil { diff --git a/web/src/components/finding-case-list.tsx b/web/src/components/finding-case-list.tsx index 105ce1d..ff5508c 100644 --- a/web/src/components/finding-case-list.tsx +++ b/web/src/components/finding-case-list.tsx @@ -192,15 +192,18 @@ function FindingMergeSuggestionCard({ onResolve, }: { suggestion: FindingCaseSuggestion; - onResolve: (s: FindingCaseSuggestion, accept: boolean) => Promise; + onResolve: (s: FindingCaseSuggestion, accept: boolean, excludeIds?: string[]) => Promise; }) { const [expanded, setExpanded] = React.useState(false); const [busy, setBusy] = React.useState(false); + const ids = s.finding_ids?.length ? s.finding_ids : [s.left_id, s.right_id]; + const [excluded, setExcluded] = React.useState([]); + const included = ids.filter((id) => !excluded.includes(id)); async function resolve(accept: boolean) { if (busy) return; setBusy(true); try { - await onResolve(s, accept); + await onResolve(s, accept, accept ? excluded : []); } finally { setBusy(false); } @@ -209,17 +212,37 @@ function FindingMergeSuggestionCard({
- 疑似重复 · 待确认 + 疑似重复分组 · {ids.length} 条 · 待确认 任务 #{s.task_id}
{s.title} - - - 查看原报告 #{s.left_id} - - - 查看原报告 #{s.right_id} - + + {ids.map((id) => ( + + + setExcluded((old) => + v === true ? old.filter((x) => x !== id) : old.includes(id) ? old : [...old, id], + ) + } + aria-label={`归并时包含上报 #${id}`} + /> + + 查看原报告 #{id} + + + ))} + {excluded.length > 0 ? ( + 已排除 {excluded.length} 条:确认后这些记录不会再与本组建议归并 + ) : null}
@@ -238,11 +261,12 @@ function FindingMergeSuggestionCard({ -
@@ -294,10 +318,15 @@ export function FindingCaseReviewPanel({ taskId, onChange }: { taskId?: string; clearTimeout(timer); }; }, [taskId]); - async function resolve(s: FindingCaseSuggestion, accept: boolean) { + async function resolve(s: FindingCaseSuggestion, accept: boolean, excludeIds: string[] = []) { try { - const result = await api.resolveFindingCaseSuggestion(s.id, accept); + const result = await api.resolveFindingCaseSuggestion(s.id, accept, excludeIds); setSuggestions((v) => v.filter((x) => x.id !== s.id)); + // Server-side resolution may also clear related groups; refresh instead of waiting for the poll. + api + .findingCaseSuggestions(taskId) + .then(setSuggestions) + .catch(() => undefined); if (accept && result.case_id !== "0") { toast.success("已归并,请进入文件夹生成统一报告"); } else { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index fedc913..821da2f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -645,8 +645,11 @@ export const api = { get( `/exploration/finding-case-suggestions${task ? `?task_id=${encodeURIComponent(task)}` : ""}`, ), - resolveFindingCaseSuggestion: (id: string, accept: boolean) => - post<{ case_id: string }>(`/exploration/finding-case-suggestions/${id}`, { accept }), + resolveFindingCaseSuggestion: (id: string, accept: boolean, excludeIds?: string[]) => + post<{ case_id: string }>(`/exploration/finding-case-suggestions/${id}`, { + accept, + ...(excludeIds?.length ? { exclude_ids: excludeIds } : {}), + }), reviewFindingCases: (ids: string[]) => post<{ runs: { conversation_id: number; task_id: string }[] }>("/exploration/finding-case-review", { finding_ids: ids, diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 54e26a6..d730691 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -1737,6 +1737,7 @@ export interface FindingCaseSuggestion { title: string; reason: string; state: string; + finding_ids: string[]; } export interface FindingCaseReviewRun { conversation_id: number; From 0c346a7c0ae7ab37b36046598fece15f71a1811a Mon Sep 17 00:00:00 2001 From: RuoJi6 Date: Sat, 24 Oct 2026 20:48:35 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(findings):=20address=20Grok=20review=20?= =?UTF-8?q?=E2=80=94=20keep=20folders=20mergeable=20after=20group=20reject?= =?UTF-8?q?,=20re-anchor=20groups=20on=20finding=20delete,=20guard=20stale?= =?UTF-8?q?=20suggestion=20list=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent/finding_cases.go | 4 +- db/finding_cases.go | 31 +++++++++--- db/finding_cases_test.go | 60 ++++++++++++++++++++++++ db/schema.sql | 23 +++++++++ server/finding_cases.go | 2 +- web/src/components/finding-case-list.tsx | 30 ++++++++---- 6 files changed, 132 insertions(+), 18 deletions(-) diff --git a/agent/finding_cases.go b/agent/finding_cases.go index 370e760..25548dd 100644 --- a/agent/finding_cases.go +++ b/agent/finding_cases.go @@ -46,12 +46,12 @@ func findingCaseWorkflow(key string, tools []actool.CoreTool) ([]actool.CoreTool return tools, ` 【同一漏洞的多次上报整理】保留本次独立报告,不覆盖其他原报告。默认报告完成即停止的旧指引在此扩展:先保存本次报告,然后 search_finding_duplicates(finding_id=独立记录ID) 分页读取同任务候选摘要(按 score 排序;strong=同漏洞类型且资产重叠,优先核对;weak=单一条件命中,仅作参考),按需 get_finding_record 查看证据。仅凭漏洞类型/标题相似或高中低等级不能归并;须核对目标、接口、参数、权限边界、根因及实际验证证据。 -【判断准则】有证据表明端点/路由、处理器或后端组件、易受影响参数、缺失检查属于不同代码路径时,可以判定 independent;但仅凭路径、接口、登录入口、令牌、标题或漏洞类型相同/不同都不能证明根因。比较实际缺失的鉴权检查、对象权限边界、会话门或共同组件,并引用每条记录中的具体证据。只有根因明确相同才用 merge_finding_records;只有存在具体共同点(同端点/参数/组件/缺失检查)但缺关键证据确认时才用 suggest_finding_merge,并一次提交覆盖2..N条记录的分组,禁止仅因类型或资产相同而建议。没有具体共同点就保持 independent,不要因不确定而建议。不重复上报、不删除记录、不重新探测目标。人工选择整理时只能判断所选记录,不能加入未选记录(既有组成员可作为报告上下文)。 +【判断准则】有证据表明端点/路由、处理器或后端组件、易受影响参数、缺失检查属于不同代码路径时,可以判定 independent;但仅凭路径、接口、登录入口、令牌、标题或漏洞类型相同/不同都不能证明根因。比较实际缺失的鉴权检查、对象权限边界、会话门或共同组件,并引用每条记录中的具体证据。只有根因明确相同才用 merge_finding_records;只有存在具体共同点(同端点/参数/组件/缺失检查)但缺关键证据确认时才用 suggest_finding_merge,并一次提交覆盖2..20条记录的分组,禁止仅因类型或资产相同而建议。没有具体共同点就保持 independent,不要因不确定而建议。不重复上报、不删除记录、不重新探测目标。人工选择整理时只能判断所选记录,不能加入未选记录(既有组成员可作为报告上下文)。 归并不以统一报告为前提:根因明确就先归并,统一报告可随后补齐(未完成时文件夹显示“统一报告待更新”)。归并成功或已有case_id时,get_finding_case 记录实际version,分页读取全部成员,按需get_finding_record、get_finding_traffic读取原报告、PoC及已保存流量。update_finding_case_report保存完整统一报告(包含有内容的概述、影响、前置条件、复现步骤、证据、PoC、根因分析、修复建议章节,不得半篇截断)和基于已验证影响的severity及severity_reason;不能直接取成员最高等级。集中给出前置条件、完整执行顺序、输入输出如何衔接、可运行的完整脚本及命令,不把必要步骤分散到原报告;缺失条件、未验证步骤如实标明。版本冲突重新读取材料并生成,不能仅换version重试。各成员仍有独立报告、评级和证据。没有明确重复则保留独立记录。工具被关闭时不绕过配置。` } const historicalFindingReviewGuidance = ` 【历史整理模式,优先于旧报告撰写及自动绑流量指引】本次只整理已经存在的上报。原始报告即使为空或待更新,也只记录缺失情况,绝不调用 update_finding_report、report_finding、bind_finding_traffic,不修改原始标题、评级、状态、证据或报告,不执行 Bash、写文件或重新探测目标。 -先逐条 get_finding_record 读取所选记录,已有原报告直接作为判断材料复用,不重复撰写。只在所选范围内检查同一实际缺陷,可按需读取已存在的证据。按上述根因准则形成明确归并/独立/疑似建议;无法确认时保存建议供人工确认。 +先逐条 get_finding_record 读取所选记录,已有原报告直接作为判断材料复用,不重复撰写。只在所选范围内检查同一实际缺陷,可按需读取已存在的证据。按上述根因准则形成明确归并/独立/疑似建议;仅当存在具体共同点(同端点/参数/组件/缺失检查)但缺关键证据时才保存疑似建议供人工确认,没有具体共同点的不确定情况保持 independent。 已有或新建文件夹应读取全部成员并尽力用 update_finding_case_report 写入完整、最新的统一报告;归并不因报告暂缺而阻塞,未完成时文件夹会显示“统一报告待更新”,可由后续运行或人工补齐。统一报告是单独的材料,不覆盖原报告。最后必须调用 complete_finding_case_review,为每条所选 finding_id 提交 verdict(independent/merged/suggested)、reason 和 root_cause_evidence,并提供总结。根因证据应说明具体相同/不同的检查及证据来源;疑似建议明确待补证据。服务端验收通过后才可宣布完成。工具不可用或验收失败时如实报告,不能只用自然语言声称完成。` diff --git a/db/finding_cases.go b/db/finding_cases.go index 5878535..4b05b25 100644 --- a/db/finding_cases.go +++ b/db/finding_cases.go @@ -530,6 +530,7 @@ func (d *DB) SuggestFindingCase(ctx context.Context, taskID int64, findingIDs [] want[fid] = true } superseded := []int64{} + var exact, covering int64 for _, g := range groups { inNew := 0 for fid := range g.members { @@ -539,22 +540,35 @@ func (d *DB) SuggestFindingCase(ctx context.Context, taskID int64, findingIDs [] } switch { case inNew == len(ids) && len(g.members) == len(ids): - id = g.id - _, err := tx.Exec(`UPDATE finding_case_suggestions SET title=$2,reason=$3 WHERE id=$1`, g.id, title, reason) - return err + if exact == 0 { + exact = g.id + } else { + superseded = append(superseded, g.id) + } case inNew == len(ids): // An existing pending group already covers every requested record. - id = g.id - return nil + if covering == 0 { + covering = g.id + } case inNew == len(g.members): superseded = append(superseded, g.id) } } + // Strict subsets are superseded on every path, including refresh/reuse. for _, sid := range superseded { if _, err := tx.Exec(`UPDATE finding_case_suggestions SET state='superseded' WHERE id=$1 AND state='pending'`, sid); err != nil { return err } } + if exact > 0 { + id = exact + _, err := tx.Exec(`UPDATE finding_case_suggestions SET title=$2,reason=$3 WHERE id=$1`, exact, title, reason) + return err + } + if covering > 0 { + id = covering + return nil + } if err := tx.QueryRow(`INSERT INTO finding_case_suggestions(task_id,left_id,right_id,title,reason) VALUES($1,$2,$3,$4,$5) RETURNING id`, taskID, ids[0], ids[1], title, reason).Scan(&id); err != nil { return err } @@ -653,9 +667,14 @@ func (d *DB) ResolveFindingCaseSuggestion(ctx context.Context, id int64, accept if err != nil { return err } + // Pairs already sharing a folder are not vetoed: the folder is the newer human + // decision, and a block inside it would make every later merge into it fail. + // Splitting a folder goes through RemoveFindingCaseMember instead. for i := range current { for j := i + 1; j < len(current); j++ { - if _, err := tx.Exec(`INSERT INTO finding_case_blocks(left_id,right_id,reason) VALUES($1,$2,'人工否决') ON CONFLICT DO NOTHING`, current[i], current[j]); err != nil { + if _, err := tx.Exec(`INSERT INTO finding_case_blocks(left_id,right_id,reason) SELECT $1::bigint,$2::bigint,'人工否决' + WHERE NOT EXISTS(SELECT 1 FROM finding_case_members a JOIN finding_case_members b ON a.case_id=b.case_id WHERE a.finding_id=$1 AND b.finding_id=$2) + ON CONFLICT DO NOTHING`, current[i], current[j]); err != nil { return err } } diff --git a/db/finding_cases_test.go b/db/finding_cases_test.go index a50cab4..0592bf3 100644 --- a/db/finding_cases_test.go +++ b/db/finding_cases_test.go @@ -502,3 +502,63 @@ func TestFindingCaseClusterExclusionCannotBePulledInByFolder(t *testing.T) { t.Fatal("failed exclusion was not rolled back") } } + +func TestFindingCaseClusterRejectKeepsExistingFolderMergeable(t *testing.T) { + d, task, ids := caseTestDB(t) + ctx := context.Background() + cid, err := d.MergeFindingCase(ctx, task.ID, ids[:2], "orders", "proof", "reporter") + if err != nil { + t.Fatal(err) + } + sid, err := d.SuggestFindingCase(ctx, task.ID, ids[:3], "orders", "shared handler") + if err != nil { + t.Fatal(err) + } + if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, false, nil); err != nil { + t.Fatal(err) + } + var inFolder int + if err := d.QueryRow(`SELECT count(*) FROM finding_case_blocks WHERE left_id=$1 AND right_id=$2`, ids[0], ids[1]).Scan(&inFolder); err != nil || inFolder != 0 { + t.Fatalf("co-membered pair vetoed: %d %v", inFolder, err) + } + if _, err := d.MergeFindingCase(ctx, task.ID, []int64{ids[0], ids[2]}, "x", "x", "reporter"); err == nil { + t.Fatal("rejected pair merged") + } + if got, err := d.MergeFindingCase(ctx, task.ID, []int64{ids[1], ids[3]}, "orders", "proof", "reporter"); err != nil || got != cid { + t.Fatalf("folder poisoned by reject: %d %v", got, err) + } +} + +func TestFindingCaseClusterSurvivesAnchorDeletion(t *testing.T) { + d, task, ids := caseTestDB(t) + ctx := context.Background() + sid, err := d.SuggestFindingCase(ctx, task.ID, ids[:3], "orders", "shared handler") + if err != nil { + t.Fatal(err) + } + if n, err := d.DeleteFinding(ids[0]); err != nil || n != 1 { + t.Fatal(n, err) + } + list, err := d.FindingCaseSuggestions(task.ID) + if err != nil || len(list) != 1 || list[0].ID != sid || !reflect.DeepEqual(list[0].FindingIDs, []string{fmt.Sprint(ids[1]), fmt.Sprint(ids[2])}) { + t.Fatalf("group lost with its anchor: %+v %v", list, err) + } + if list[0].LeftID != ids[1] || list[0].RightID != ids[2] { + t.Fatalf("anchors not moved: %+v", list[0]) + } + if cid, err := d.ResolveFindingCaseSuggestion(ctx, sid, true, nil); err != nil || cid == 0 { + t.Fatal(cid, err) + } + // Deleting below two members still drops the pair through the cascade. + pair, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[2], ids[3]}, "orders", "x") + if err != nil { + t.Fatal(err) + } + if _, err := d.DeleteFinding(ids[3]); err != nil { + t.Fatal(err) + } + var n int + if err := d.QueryRow(`SELECT count(*) FROM finding_case_suggestions WHERE id=$1`, pair).Scan(&n); err != nil || n != 0 { + t.Fatalf("two-member group kept after anchor deletion: %d %v", n, err) + } +} diff --git a/db/schema.sql b/db/schema.sql index 44d7c4f..8e92122 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1499,6 +1499,29 @@ INSERT INTO finding_case_suggestion_members(suggestion_id,finding_id) SELECT id,left_id FROM finding_case_suggestions ON CONFLICT DO NOTHING; INSERT INTO finding_case_suggestion_members(suggestion_id,finding_id) SELECT id,right_id FROM finding_case_suggestions ON CONFLICT DO NOTHING; +-- left_id/right_id only anchor a group (ON DELETE CASCADE). Before one anchor +-- finding is deleted, move the anchors to the two smallest surviving members so +-- the rest of a 3+ member group is not cascade-deleted with it. +CREATE OR REPLACE FUNCTION finding_case_suggestion_reanchor() RETURNS trigger AS $$ +DECLARE + sid BIGINT; + l BIGINT; + r BIGINT; +BEGIN + FOR sid IN SELECT id FROM finding_case_suggestions WHERE left_id=OLD.id OR right_id=OLD.id LOOP + SELECT min(sm.finding_id) INTO l FROM finding_case_suggestion_members sm + WHERE sm.suggestion_id=sid AND sm.finding_id<>OLD.id AND EXISTS(SELECT 1 FROM findings f WHERE f.id=sm.finding_id); + SELECT min(sm.finding_id) INTO r FROM finding_case_suggestion_members sm + WHERE sm.suggestion_id=sid AND sm.finding_id>l AND sm.finding_id<>OLD.id AND EXISTS(SELECT 1 FROM findings f WHERE f.id=sm.finding_id); + IF r IS NOT NULL THEN + UPDATE finding_case_suggestions SET left_id=l,right_id=r WHERE id=sid; + END IF; + END LOOP; + RETURN OLD; +END $$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS trg_finding_case_suggestion_reanchor ON findings; +CREATE TRIGGER trg_finding_case_suggestion_reanchor BEFORE DELETE ON findings + FOR EACH ROW EXECUTE FUNCTION finding_case_suggestion_reanchor(); -- Rejected/detached pairs are a durable human veto, also checked on group unions. CREATE TABLE IF NOT EXISTS finding_case_blocks ( left_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE, diff --git a/server/finding_cases.go b/server/finding_cases.go index bc08b0f..3f0b7d8 100644 --- a/server/finding_cases.go +++ b/server/finding_cases.go @@ -267,7 +267,7 @@ func (s *Server) findingCaseTools() []actool.CoreTool { {"search_finding_duplicates", "只读检索同任务候选摘要与资产,按分数排序并返回 score、tier、hit_reasons:strong=同漏洞类型且资产重叠,weak=单一条件命中仅作参考。等级不同也可能重复。支持分页,按需读取详情。", []string{"finding_id"}}, {"get_finding_record", "读取独立漏洞完整原始证据和报告,返回探索节点编号以便追溯执行记录。", []string{"finding_id"}}, {"merge_finding_records", "证据明确证明同一缺陷时归并;保留所有原始记录。不跨任务、不改变原评级,不可绕过人工否决。", []string{"task_id", "finding_ids", "title", "reason"}}, - {"suggest_finding_merge", "证据存在具体共同点但缺少关键证据时,提交一个疑似重复分组(2..N 条同任务记录,一次覆盖全部疑似成员,不要拆成两两建议),待人工确认;不是自动合并。仅类型/资产相同不应提交。", []string{"task_id", "finding_ids", "title", "reason"}}, + {"suggest_finding_merge", "证据存在具体共同点但缺少关键证据时,提交一个疑似重复分组(2..20 条同任务记录,一次覆盖全部疑似成员,不要拆成两两建议),待人工确认;不是自动合并。仅类型/资产相同不应提交。", []string{"task_id", "finding_ids", "title", "reason"}}, {"get_finding_case", "读取归并组和成员分页。先记录version,再读取成员的原报告、证据及流量;版本变化必须重新读取。", []string{"case_id"}}, {"update_finding_case_report", "保存统一报告及评级理由,version必须来自实际读取。保留各条原报告,不新建漏洞。", []string{"case_id", "version", "title", "report", "severity", "severity_reason"}}, } diff --git a/web/src/components/finding-case-list.tsx b/web/src/components/finding-case-list.tsx index ff5508c..b4deab1 100644 --- a/web/src/components/finding-case-list.tsx +++ b/web/src/components/finding-case-list.tsx @@ -196,7 +196,7 @@ function FindingMergeSuggestionCard({ }) { const [expanded, setExpanded] = React.useState(false); const [busy, setBusy] = React.useState(false); - const ids = s.finding_ids?.length ? s.finding_ids : [s.left_id, s.right_id]; + const ids = s.finding_ids.length ? s.finding_ids : [s.left_id, s.right_id]; const [excluded, setExcluded] = React.useState([]); const included = ids.filter((id) => !excluded.includes(id)); async function resolve(accept: boolean) { @@ -224,7 +224,7 @@ function FindingMergeSuggestionCard({ disabled={busy} onCheckedChange={(v) => setExcluded((old) => - v === true ? old.filter((x) => x !== id) : old.includes(id) ? old : [...old, id], + v === true ? old.filter((x) => x !== id) : [...old.filter((x) => x !== id), id], ) } aria-label={`归并时包含上报 #${id}`} @@ -295,14 +295,20 @@ export function FindingCaseReviewPanel({ taskId, onChange }: { taskId?: string; const [suggestions, setSuggestions] = React.useState([]); const [runs, setRuns] = React.useState([]); const [error, setError] = React.useState(""); + // Only the newest suggestions request (poll or post-resolve refresh) may write the list, + // so an older response cannot bring back a resolved group or another task's list. + const listSeq = React.useRef(0); + const currentTask = React.useRef(taskId); + currentTask.current = taskId; React.useEffect(() => { let active = true; let timer: ReturnType; async function load() { + const seq = ++listSeq.current; try { const [s, r] = await Promise.all([api.findingCaseSuggestions(taskId), api.findingCaseReviewRuns()]); if (active) { - setSuggestions(s); + if (seq === listSeq.current) setSuggestions(s); setRuns(r.filter((x) => !taskId || x.task_id === taskId)); setError(""); } @@ -321,12 +327,18 @@ export function FindingCaseReviewPanel({ taskId, onChange }: { taskId?: string; async function resolve(s: FindingCaseSuggestion, accept: boolean, excludeIds: string[] = []) { try { const result = await api.resolveFindingCaseSuggestion(s.id, accept, excludeIds); - setSuggestions((v) => v.filter((x) => x.id !== s.id)); - // Server-side resolution may also clear related groups; refresh instead of waiting for the poll. - api - .findingCaseSuggestions(taskId) - .then(setSuggestions) - .catch(() => undefined); + // A resolve that finishes after switching tasks must not touch the new task's list. + if (currentTask.current === taskId) { + const seq = ++listSeq.current; + setSuggestions((v) => v.filter((x) => x.id !== s.id)); + // Server-side resolution may also clear related groups; refresh instead of waiting for the poll. + api + .findingCaseSuggestions(taskId) + .then((v) => { + if (seq === listSeq.current && currentTask.current === taskId) setSuggestions(v); + }) + .catch(() => undefined); + } if (accept && result.case_id !== "0") { toast.success("已归并,请进入文件夹生成统一报告"); } else { From b0d6ad5d76ece00baf051d87ed684809fb54bba3 Mon Sep 17 00:00:00 2001 From: RuoJi6 Date: Sat, 24 Oct 2026 21:09:20 +0800 Subject: [PATCH 3/7] revert(findings): reset dedup files to origin/main before Codex rewrite Pure git restore (git checkout origin/main -- ) so the scheme 1+2+3 implementation can be re-authored entirely by Codex. No hand-written code. --- agent/finding_cases.go | 10 +- agent/finding_cases_test.go | 2 +- db/finding_cases.go | 340 +++-------------------- db/finding_cases_test.go | 219 +-------------- db/schema.sql | 34 --- server/finding_case_reviews.go | 15 +- server/finding_case_reviews_test.go | 22 +- server/finding_cases.go | 32 +-- web/src/components/finding-case-list.tsx | 75 ++--- web/src/lib/api.ts | 7 +- web/src/lib/types.ts | 1 - 11 files changed, 108 insertions(+), 649 deletions(-) diff --git a/agent/finding_cases.go b/agent/finding_cases.go index 25548dd..7e69412 100644 --- a/agent/finding_cases.go +++ b/agent/finding_cases.go @@ -45,13 +45,13 @@ func findingCaseWorkflow(key string, tools []actool.CoreTool) ([]actool.CoreTool } return tools, ` -【同一漏洞的多次上报整理】保留本次独立报告,不覆盖其他原报告。默认报告完成即停止的旧指引在此扩展:先保存本次报告,然后 search_finding_duplicates(finding_id=独立记录ID) 分页读取同任务候选摘要(按 score 排序;strong=同漏洞类型且资产重叠,优先核对;weak=单一条件命中,仅作参考),按需 get_finding_record 查看证据。仅凭漏洞类型/标题相似或高中低等级不能归并;须核对目标、接口、参数、权限边界、根因及实际验证证据。 -【判断准则】有证据表明端点/路由、处理器或后端组件、易受影响参数、缺失检查属于不同代码路径时,可以判定 independent;但仅凭路径、接口、登录入口、令牌、标题或漏洞类型相同/不同都不能证明根因。比较实际缺失的鉴权检查、对象权限边界、会话门或共同组件,并引用每条记录中的具体证据。只有根因明确相同才用 merge_finding_records;只有存在具体共同点(同端点/参数/组件/缺失检查)但缺关键证据确认时才用 suggest_finding_merge,并一次提交覆盖2..20条记录的分组,禁止仅因类型或资产相同而建议。没有具体共同点就保持 independent,不要因不确定而建议。不重复上报、不删除记录、不重新探测目标。人工选择整理时只能判断所选记录,不能加入未选记录(既有组成员可作为报告上下文)。 -归并不以统一报告为前提:根因明确就先归并,统一报告可随后补齐(未完成时文件夹显示“统一报告待更新”)。归并成功或已有case_id时,get_finding_case 记录实际version,分页读取全部成员,按需get_finding_record、get_finding_traffic读取原报告、PoC及已保存流量。update_finding_case_report保存完整统一报告(包含有内容的概述、影响、前置条件、复现步骤、证据、PoC、根因分析、修复建议章节,不得半篇截断)和基于已验证影响的severity及severity_reason;不能直接取成员最高等级。集中给出前置条件、完整执行顺序、输入输出如何衔接、可运行的完整脚本及命令,不把必要步骤分散到原报告;缺失条件、未验证步骤如实标明。版本冲突重新读取材料并生成,不能仅换version重试。各成员仍有独立报告、评级和证据。没有明确重复则保留独立记录。工具被关闭时不绕过配置。` +【同一漏洞的多次上报整理】保留本次独立报告,不覆盖其他原报告。默认报告完成即停止的旧指引在此扩展:先保存本次报告,然后 search_finding_duplicates(finding_id=独立记录ID) 分页读取同任务候选摘要,按需 get_finding_record 查看证据。仅凭漏洞类型/标题相似或高中低等级不能归并;须核对目标、接口、参数、权限边界、根因及实际验证证据。 +【判断准则】接口、路径、后端、读/写影响、评级不同,都不能单独作为不重复的理由;同一登录入口、同一令牌或同一漏洞类型也不能单独证明同一根因。比较实际缺失的鉴权检查、对象权限边界、会话门或共同组件,并引用每条记录中的具体证据。读和写可能是同一权限检查缺失的不同影响;也可能是独立 handler 的独立缺陷。只有证据证明共同实际根因才自动归并,证据证明不同根因才判独立;存在关联但无法确认时必须 suggest_finding_merge,说明已知共同点、缺失证据和待确认问题,不用“路径不同/影响不同”代替根因判断。明确同一缺陷时 merge_finding_records(task_id,finding_ids,title,reason),不确定时 suggest_finding_merge 留待人工确认,不能强行归并。不重复上报、不删除记录、不重新探测目标。人工选择整理时只能判断所选记录,不能加入未选记录(既有组成员可作为报告上下文)。 +归并成功或已有case_id时,get_finding_case 记录实际version,分页读取全部成员,按需get_finding_record、get_finding_traffic读取原报告、PoC及已保存流量。update_finding_case_report保存完整统一报告(包含有内容的概述、影响、前置条件、复现步骤、证据、PoC、根因分析、修复建议章节,不得半篇截断)和基于已验证影响的severity及severity_reason;不能直接取成员最高等级。集中给出前置条件、完整执行顺序、输入输出如何衔接、可运行的完整脚本及命令,不把必要步骤分散到原报告;缺失条件、未验证步骤如实标明。版本冲突重新读取材料并生成,不能仅换version重试。各成员仍有独立报告、评级和证据。没有明确重复则保留独立记录。工具被关闭时不绕过配置。` } const historicalFindingReviewGuidance = ` 【历史整理模式,优先于旧报告撰写及自动绑流量指引】本次只整理已经存在的上报。原始报告即使为空或待更新,也只记录缺失情况,绝不调用 update_finding_report、report_finding、bind_finding_traffic,不修改原始标题、评级、状态、证据或报告,不执行 Bash、写文件或重新探测目标。 -先逐条 get_finding_record 读取所选记录,已有原报告直接作为判断材料复用,不重复撰写。只在所选范围内检查同一实际缺陷,可按需读取已存在的证据。按上述根因准则形成明确归并/独立/疑似建议;仅当存在具体共同点(同端点/参数/组件/缺失检查)但缺关键证据时才保存疑似建议供人工确认,没有具体共同点的不确定情况保持 independent。 -已有或新建文件夹应读取全部成员并尽力用 update_finding_case_report 写入完整、最新的统一报告;归并不因报告暂缺而阻塞,未完成时文件夹会显示“统一报告待更新”,可由后续运行或人工补齐。统一报告是单独的材料,不覆盖原报告。最后必须调用 complete_finding_case_review,为每条所选 finding_id 提交 verdict(independent/merged/suggested)、reason 和 root_cause_evidence,并提供总结。根因证据应说明具体相同/不同的检查及证据来源;疑似建议明确待补证据。服务端验收通过后才可宣布完成。工具不可用或验收失败时如实报告,不能只用自然语言声称完成。` +先逐条 get_finding_record 读取所选记录,已有原报告直接作为判断材料复用,不重复撰写。只在所选范围内检查同一实际缺陷,可按需读取已存在的证据。按上述根因准则形成明确归并/独立/疑似建议;无法确认时保存建议供人工确认。 +已有或新建文件夹必须读取全部成员并写入完整、最新的统一报告;统一报告是单独的材料,不覆盖原报告。最后必须调用 complete_finding_case_review,为每条所选 finding_id 提交 verdict(independent/merged/suggested)、reason 和 root_cause_evidence,并提供总结。根因证据应说明具体相同/不同的检查及证据来源;疑似建议明确待补证据。服务端验收通过后才可宣布完成。工具不可用或验收失败时如实报告,不能只用自然语言声称完成。` diff --git a/agent/finding_cases_test.go b/agent/finding_cases_test.go index 0fc0788..a8177ea 100644 --- a/agent/finding_cases_test.go +++ b/agent/finding_cases_test.go @@ -46,7 +46,7 @@ func TestHistoricalFindingReviewFiltersMutationAndProbeTools(t *testing.T) { t.Fatal("historical mutation capability", tool.Name()) } } - if len(out) != 6 || def.FindingGuidance != "" || !strings.Contains(def.CaseGuidance, "历史整理模式") || !strings.Contains(def.CaseGuidance, "端点/路由") { + if len(out) != 6 || def.FindingGuidance != "" || !strings.Contains(def.CaseGuidance, "历史整理模式") || !strings.Contains(def.CaseGuidance, "接口、路径、后端、读/写影响、评级不同") { t.Fatal("missing historical isolation", def) } normal, _, cl := AugmentTools(t.Context(), "reporter", tools) diff --git a/db/finding_cases.go b/db/finding_cases.go index 4b05b25..d7ec976 100644 --- a/db/finding_cases.go +++ b/db/finding_cases.go @@ -41,14 +41,13 @@ type FindingCaseEvent struct { CreatedAt time.Time `json:"created_at"` } type FindingCaseSuggestion struct { - ID int64 `json:"id,string"` - TaskID int64 `json:"task_id,string"` - LeftID int64 `json:"left_id,string"` - RightID int64 `json:"right_id,string"` - Title string `json:"title"` - Reason string `json:"reason"` - State string `json:"state"` - FindingIDs []string `json:"finding_ids"` + ID int64 `json:"id,string"` + TaskID int64 `json:"task_id,string"` + LeftID int64 `json:"left_id,string"` + RightID int64 `json:"right_id,string"` + Title string `json:"title"` + Reason string `json:"reason"` + State string `json:"state"` } type FindingCaseRow struct { Case *FindingCase `json:"case,omitempty"` @@ -199,13 +198,9 @@ func lockCaseTask(tx *sql.Tx, taskID int64) error { return tx.QueryRow(`SELECT id FROM tasks WHERE id=$1 FOR UPDATE`, taskID).Scan(&id) } func (d *DB) MergeFindingCase(ctx context.Context, taskID int64, ids []int64, title, reason, actor string) (int64, error) { - return d.mergeFindingCase(ctx, taskID, ids, title, reason, actor, 0, nil) + return d.mergeFindingCase(ctx, taskID, ids, title, reason, actor, 0) } - -// mergeFindingCase merges ids into one folder. With suggestionID it also resolves -// that suggestion; dropped are suggestion members a human excluded, which are vetoed -// against the merged records so the same group is not suggested again. -func (d *DB) mergeFindingCase(ctx context.Context, taskID int64, ids []int64, title, reason, actor string, suggestionID int64, dropped []int64) (int64, error) { +func (d *DB) mergeFindingCase(ctx context.Context, taskID int64, ids []int64, title, reason, actor string, suggestionID int64) (int64, error) { ids, err := sortedFindingIDs(ids) if err != nil { return 0, err @@ -313,23 +308,8 @@ func (d *DB) mergeFindingCase(ctx context.Context, taskID int64, ids []int64, ti return err } } - for _, x := range dropped { - if all[x] { - // Never pull an excluded record in through an existing folder. - return fmt.Errorf("排除的记录 #%d 已与所选记录在同一文件夹,请先在文件夹中移出后再确认", x) - } - for _, y := range ids { - 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`, x, y); err != nil { - return err - } - } - } - if suggestionID > 0 { - if _, err := tx.Exec(`UPDATE finding_case_suggestions SET state='accepted' WHERE id=$1`, suggestionID); err != nil { - return err - } - } - return sweepFindingCaseSuggestions(tx, taskID) + _, err := tx.Exec(`UPDATE finding_case_suggestions SET state='accepted' WHERE state='pending' AND left_id IN(SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb)) AND right_id IN(SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb))`, string(raw)) + return err }) return cid, err } @@ -375,10 +355,8 @@ func (d *DB) RemoveFindingCaseMember(ctx context.Context, cid, fid int64, reason if _, err := tx.Exec(`INSERT INTO finding_case_events(case_id,action,actor,reason,finding_ids) VALUES($1,'detach','human',$2,jsonb_build_array($3::bigint))`, cid, reason, fid); err != nil { return err } - if _, err := tx.Exec(`DELETE FROM finding_case_members WHERE case_id=$1 AND finding_id=$2`, cid, fid); err != nil { - return err - } - return sweepFindingCaseSuggestions(tx, origin) + _, err := tx.Exec(`DELETE FROM finding_case_members WHERE case_id=$1 AND finding_id=$2`, cid, fid) + return err }) } func (d *DB) UpdateFindingCaseReport(ctx context.Context, cid, version int64, title, report, severity, reason string) error { @@ -404,188 +382,41 @@ func (d *DB) UpdateFindingCaseReport(ctx context.Context, cid, version int64, ti return err }) } - -// suggestionMembers returns the surviving member IDs of a suggestion in ascending order. -func suggestionMembers(q interface { - Query(string, ...any) (*sql.Rows, error) -}, id int64) ([]int64, error) { - rows, err := q.Query(`SELECT finding_id FROM finding_case_suggestion_members WHERE suggestion_id=$1 ORDER BY finding_id`, id) - if err != nil { - return nil, err - } - defer rows.Close() - out := []int64{} - for rows.Next() { - var fid int64 - if err := rows.Scan(&fid); err != nil { - return nil, err - } - out = append(out, fid) - } - return out, rows.Err() -} - -// A suggestion with a human-vetoed pair can never be accepted as a whole. -const pendingSuggestionHasBlockedPair = `EXISTS(SELECT 1 FROM finding_case_suggestion_members a JOIN finding_case_suggestion_members b ON a.suggestion_id=b.suggestion_id AND a.finding_id 20 { - return 0, errors.New("疑似重复分组最多20条记录") - } var id int64 err = d.WithEvidenceTx(ctx, func(tx *sql.Tx) error { if err := lockCaseTask(tx, taskID); err != nil { return err } - cases := map[int64]bool{} for _, fid := range ids { var tid sql.NullInt64 - var cid int64 - if err := tx.QueryRow(`SELECT f.task_id,COALESCE(m.case_id,0) FROM findings f LEFT JOIN finding_case_members m ON m.finding_id=f.id WHERE f.id=$1`, fid).Scan(&tid, &cid); err != nil { + if err := tx.QueryRow(`SELECT task_id FROM findings WHERE id=$1`, fid).Scan(&tid); err != nil { return err } if !tid.Valid || tid.Int64 != taskID { return errors.New("只能建议同一任务的归并") } - cases[cid] = true } - if len(cases) == 1 && !cases[0] { - return errors.New("这些记录已在同一文件夹中,无需建议") - } - raw, _ := json.Marshal(ids) - blocked := []string{} - rows, err := tx.Query(`SELECT left_id,right_id FROM finding_case_blocks WHERE left_id IN(SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb)) AND right_id IN(SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb)) ORDER BY left_id,right_id`, string(raw)) - if err != nil { + var blocked bool + if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_blocks WHERE left_id=$1 AND right_id=$2)`, ids[0], ids[1]).Scan(&blocked); err != nil { return err } - for rows.Next() { - var l, r int64 - if err := rows.Scan(&l, &r); err != nil { - rows.Close() - return err - } - blocked = append(blocked, fmt.Sprintf("#%d/#%d", l, r)) + if blocked { + return errors.New("人工已否决该归并") } - err = rows.Err() - rows.Close() - if err != nil { - return err - } - if len(blocked) > 0 { - return fmt.Errorf("人工已否决这些记录的归并:%s;请去掉其中一方后重新提交", strings.Join(blocked, "、")) - } - // Compare against every pending group of the task; read all rows before issuing more statements. - type pending struct { - id int64 - members map[int64]bool - } - groups := []pending{} - rows, err = tx.Query(`SELECT s.id,sm.finding_id FROM finding_case_suggestions s JOIN finding_case_suggestion_members sm ON sm.suggestion_id=s.id WHERE s.task_id=$1 AND s.state='pending' ORDER BY s.id`, taskID) - if err != nil { - return err - } - for rows.Next() { - var sid, fid int64 - if err := rows.Scan(&sid, &fid); err != nil { - rows.Close() - return err - } - if len(groups) == 0 || groups[len(groups)-1].id != sid { - groups = append(groups, pending{id: sid, members: map[int64]bool{}}) - } - groups[len(groups)-1].members[fid] = true - } - err = rows.Err() - rows.Close() - if err != nil { - return err - } - want := map[int64]bool{} - for _, fid := range ids { - want[fid] = true - } - superseded := []int64{} - var exact, covering int64 - for _, g := range groups { - inNew := 0 - for fid := range g.members { - if want[fid] { - inNew++ - } - } - switch { - case inNew == len(ids) && len(g.members) == len(ids): - if exact == 0 { - exact = g.id - } else { - superseded = append(superseded, g.id) - } - case inNew == len(ids): - // An existing pending group already covers every requested record. - if covering == 0 { - covering = g.id - } - case inNew == len(g.members): - superseded = append(superseded, g.id) - } - } - // Strict subsets are superseded on every path, including refresh/reuse. - for _, sid := range superseded { - if _, err := tx.Exec(`UPDATE finding_case_suggestions SET state='superseded' WHERE id=$1 AND state='pending'`, sid); err != nil { - return err - } - } - if exact > 0 { - id = exact - _, err := tx.Exec(`UPDATE finding_case_suggestions SET title=$2,reason=$3 WHERE id=$1`, exact, title, reason) - return err - } - if covering > 0 { - id = covering - return nil - } - if err := tx.QueryRow(`INSERT INTO finding_case_suggestions(task_id,left_id,right_id,title,reason) VALUES($1,$2,$3,$4,$5) RETURNING id`, taskID, ids[0], ids[1], title, reason).Scan(&id); err != nil { - return err - } - _, err = tx.Exec(`INSERT INTO finding_case_suggestion_members(suggestion_id,finding_id) SELECT $1,value::bigint FROM jsonb_array_elements_text($2::jsonb)`, id, string(raw)) - return err + return tx.QueryRow(`INSERT INTO finding_case_suggestions(task_id,left_id,right_id,title,reason) VALUES($1,$2,$3,$4,$5) ON CONFLICT(left_id,right_id) DO UPDATE SET reason=EXCLUDED.reason WHERE finding_case_suggestions.state='pending' RETURNING id`, taskID, ids[0], ids[1], title, reason).Scan(&id) }) return id, err } - -// FindingCaseSuggestions lists pending groups with at least two surviving, -// non-vetoed members. func (d *DB) FindingCaseSuggestions(taskID int64) ([]FindingCaseSuggestion, error) { - rows, err := d.Query(`SELECT s.id,s.task_id,s.left_id,s.right_id,s.title,s.reason,s.state,COALESCE((SELECT jsonb_agg(sm.finding_id ORDER BY sm.finding_id) FROM finding_case_suggestion_members sm WHERE sm.suggestion_id=s.id),'[]'::jsonb) - FROM finding_case_suggestions s WHERE s.state='pending' AND ($1::bigint=0 OR s.task_id=$1) AND NOT `+pendingSuggestionHasBlockedPair+` - AND (SELECT count(*) FROM finding_case_suggestion_members sm WHERE sm.suggestion_id=s.id)>=2 - AND NOT EXISTS(SELECT 1 FROM finding_case_suggestion_members sm JOIN findings f ON f.id=sm.finding_id WHERE sm.suggestion_id=s.id AND f.task_id IS DISTINCT FROM s.task_id) - ORDER BY s.id DESC`, taskID) + rows, err := d.Query(`SELECT id,task_id,left_id,right_id,title,reason,state FROM finding_case_suggestions WHERE state='pending' AND ($1::bigint=0 OR task_id=$1) ORDER BY id DESC`, taskID) if err != nil { return nil, err } @@ -593,27 +424,14 @@ func (d *DB) FindingCaseSuggestions(taskID int64) ([]FindingCaseSuggestion, erro out := []FindingCaseSuggestion{} for rows.Next() { var s FindingCaseSuggestion - var raw []byte - if err := rows.Scan(&s.ID, &s.TaskID, &s.LeftID, &s.RightID, &s.Title, &s.Reason, &s.State, &raw); err != nil { + if err := rows.Scan(&s.ID, &s.TaskID, &s.LeftID, &s.RightID, &s.Title, &s.Reason, &s.State); err != nil { return nil, err } - var members []int64 - if err := json.Unmarshal(raw, &members); err != nil { - return nil, err - } - s.FindingIDs = make([]string, 0, len(members)) - for _, fid := range members { - s.FindingIDs = append(s.FindingIDs, strconv.FormatInt(fid, 10)) - } out = append(out, s) } return out, rows.Err() } - -// ResolveFindingCaseSuggestion confirms or rejects a whole group. On accept the -// remaining members (minus exclude) are merged once; excluded records are vetoed -// against the remaining members. Rejecting vetoes every pair inside the group. -func (d *DB) ResolveFindingCaseSuggestion(ctx context.Context, id int64, accept bool, exclude []int64) (int64, error) { +func (d *DB) ResolveFindingCaseSuggestion(ctx context.Context, id int64, accept bool) (int64, error) { var s FindingCaseSuggestion if err := d.QueryRow(`SELECT task_id,left_id,right_id,title,reason,state FROM finding_case_suggestions WHERE id=$1`, id).Scan(&s.TaskID, &s.LeftID, &s.RightID, &s.Title, &s.Reason, &s.State); err != nil { return 0, err @@ -621,35 +439,8 @@ func (d *DB) ResolveFindingCaseSuggestion(ctx context.Context, id int64, accept if s.State != "pending" { return 0, ErrFindingCaseConflict } - members, err := suggestionMembers(d, id) - if err != nil { - return 0, err - } - hasLeft, hasRight := false, false - for _, x := range members { - hasLeft = hasLeft || x == s.LeftID - hasRight = hasRight || x == s.RightID - } - if len(members) < 2 || !hasLeft || !hasRight { - return 0, ErrFindingCaseConflict - } if accept { - ex := map[int64]bool{} - for _, x := range exclude { - ex[x] = true - } - keep, dropped := []int64{}, []int64{} - for _, x := range members { - if ex[x] { - dropped = append(dropped, x) - } else { - keep = append(keep, x) - } - } - if len(keep) < 2 { - return 0, errors.New("至少保留两条记录才能确认归并") - } - return d.mergeFindingCase(ctx, s.TaskID, keep, s.Title, s.Reason, "human", id, dropped) + return d.mergeFindingCase(ctx, s.TaskID, []int64{s.LeftID, s.RightID}, s.Title, s.Reason, "human", id) } return 0, d.WithEvidenceTx(ctx, func(tx *sql.Tx) error { if err := lockCaseTask(tx, s.TaskID); err != nil { @@ -662,24 +453,10 @@ func (d *DB) ResolveFindingCaseSuggestion(ctx context.Context, id int64, accept if n, _ := res.RowsAffected(); n != 1 { return ErrFindingCaseConflict } - // Re-read members under the task lock so the veto matches what was rejected. - current, err := suggestionMembers(tx, id) - if err != nil { + if _, err := tx.Exec(`INSERT INTO finding_case_blocks(left_id,right_id,reason) VALUES($1,$2,'人工否决') ON CONFLICT DO NOTHING`, s.LeftID, s.RightID); err != nil { return err } - // Pairs already sharing a folder are not vetoed: the folder is the newer human - // decision, and a block inside it would make every later merge into it fail. - // Splitting a folder goes through RemoveFindingCaseMember instead. - for i := range current { - for j := i + 1; j < len(current); j++ { - if _, err := tx.Exec(`INSERT INTO finding_case_blocks(left_id,right_id,reason) SELECT $1::bigint,$2::bigint,'人工否决' - WHERE NOT EXISTS(SELECT 1 FROM finding_case_members a JOIN finding_case_members b ON a.case_id=b.case_id WHERE a.finding_id=$1 AND b.finding_id=$2) - ON CONFLICT DO NOTHING`, current[i], current[j]); err != nil { - return err - } - } - } - return sweepFindingCaseSuggestions(tx, s.TaskID) + return nil }) } @@ -702,16 +479,13 @@ func (d *DB) DistinctFindingStats(taskID string) (*FindingDistinctStats, error) } // Compact candidate pages exclude PoC/report bodies and do not treat severity as identity. -// Recall stays broad (type OR name OR shared asset) so nothing is dropped, but rows are -// ranked: same vulnclass AND shared asset is "strong"; a single-condition hit is "weak". -// strong is the total number of strong candidates across all pages. -func (d *DB) FindingCaseCandidates(fid int64, page, size int, scope ...[]int64) (items []map[string]any, total int, strong int, err error) { +func (d *DB) FindingCaseCandidates(fid int64, page, size int, scope ...[]int64) ([]map[string]any, int, error) { f, err := d.FindingCaseSummary(fid) if err != nil { - return nil, 0, 0, err + return nil, 0, err } if f == nil || f.TaskID == nil { - return nil, 0, 0, errors.New("漏洞任务不可用") + return nil, 0, errors.New("漏洞任务不可用") } if page < 1 { page = 1 @@ -723,62 +497,40 @@ func (d *DB) FindingCaseCandidates(fid int64, page, size int, scope ...[]int64) if f.AssetIDs == nil { assets = []byte("[]") } - // Type/asset/name only retrieve and rank candidates; the Reporter must still prove the root cause. - hits := `SELECT f.*, - (trim($3::text)<>'' AND lower(f.vulnclass)=lower($3)) AS same_class, - (trim($4::text)<>'' AND lower(f.name)=lower($4)) AS same_name, - EXISTS(SELECT 1 FROM jsonb_array_elements(CASE WHEN jsonb_typeof(f.asset_ids)='array' THEN f.asset_ids ELSE '[]'::jsonb END) a WHERE a.value IN(SELECT value FROM jsonb_array_elements($5::jsonb))) AS shared_asset - FROM findings f WHERE f.task_id=$1 AND f.id<>$2 AND NOT EXISTS(SELECT 1 FROM finding_case_blocks b WHERE b.left_id=LEAST(f.id,$2::bigint) AND b.right_id=GREATEST(f.id,$2::bigint))` + // Type/asset/name only retrieve candidates; the Reporter must still prove the root cause. + where := ` WHERE f.task_id=$1 AND f.id<>$2 AND NOT EXISTS(SELECT 1 FROM finding_case_blocks b WHERE b.left_id=LEAST(f.id,$2::bigint) AND b.right_id=GREATEST(f.id,$2::bigint)) + AND ((trim($3::text)<>'' AND lower(f.vulnclass)=lower($3)) OR (trim($4::text)<>'' AND lower(f.name)=lower($4)) + OR EXISTS(SELECT 1 FROM jsonb_array_elements(COALESCE(NULLIF(f.asset_ids,'null'::jsonb),'[]'::jsonb)) a WHERE a.value IN(SELECT value FROM jsonb_array_elements($5::jsonb))))` args := []any{*f.TaskID, fid, f.VulnClass, f.Name, string(assets)} if len(scope) > 0 { raw, err := json.Marshal(scope[0]) if err != nil { - return nil, 0, 0, err + return nil, 0, err } - hits += ` AND f.id IN (SELECT jsonb_array_elements_text($6::jsonb)::bigint)` + where += ` AND f.id IN (SELECT jsonb_array_elements_text($6::jsonb)::bigint)` args = append(args, string(raw)) } - base := `WITH hits AS (` + hits + `), cand AS (SELECT h.*, - (CASE WHEN same_class THEN 2 ELSE 0 END + CASE WHEN shared_asset THEN 2 ELSE 0 END + CASE WHEN same_name THEN 1 ELSE 0 END - + CASE WHEN same_class AND shared_asset THEN 3 ELSE 0 END) AS score - FROM hits h WHERE same_class OR same_name OR shared_asset)` - if err := d.QueryRow(base+` SELECT count(*),count(*) FILTER(WHERE same_class AND shared_asset) FROM cand`, args...).Scan(&total, &strong); err != nil { - return nil, 0, 0, err + var total int + if err := d.QueryRow(`SELECT count(*) FROM findings f`+where, args...).Scan(&total); err != nil { + return nil, 0, err } args = append(args, size, (page-1)*size) - rows, err := d.Query(base+fmt.Sprintf(` SELECT c.id,c.name,c.vulnclass,c.severity,left(c.summary,400),c.asset_ids,COALESCE(m.case_id,0),c.score,c.same_class,c.shared_asset,c.same_name - FROM cand c LEFT JOIN finding_case_members m ON m.finding_id=c.id ORDER BY c.score DESC,c.id DESC LIMIT $%d OFFSET $%d`, len(args)-1, len(args)), args...) + rows, err := d.Query(`SELECT f.id,f.name,f.vulnclass,f.severity,left(f.summary,400),f.asset_ids,COALESCE(m.case_id,0) FROM findings f LEFT JOIN finding_case_members m ON m.finding_id=f.id`+where+fmt.Sprintf(` ORDER BY f.id DESC LIMIT $%d OFFSET $%d`, len(args)-1, len(args)), args...) if err != nil { - return nil, 0, 0, err + return nil, 0, err } defer rows.Close() - items = []map[string]any{} + out := []map[string]any{} for rows.Next() { var id, cid int64 - var score int - var sameClass, sharedAsset, sameName bool var name, class, severity, summary string var assetIDs json.RawMessage - if err := rows.Scan(&id, &name, &class, &severity, &summary, &assetIDs, &cid, &score, &sameClass, &sharedAsset, &sameName); err != nil { - return nil, 0, 0, err + if err := rows.Scan(&id, &name, &class, &severity, &summary, &assetIDs, &cid); err != nil { + return nil, 0, err } - reasons := []string{} - if sameClass { - reasons = append(reasons, "同漏洞类型") - } - if sharedAsset { - reasons = append(reasons, "资产重叠") - } - if sameName { - reasons = append(reasons, "同名称") - } - tier := "weak" - if sameClass && sharedAsset { - tier = "strong" - } - items = append(items, map[string]any{"finding_id": strconv.FormatInt(id, 10), "name": name, "vulnclass": class, "severity": severity, "summary": summary, "asset_ids": assetIDs, "case_id": strconv.FormatInt(cid, 10), "score": score, "tier": tier, "hit_reasons": reasons}) + out = append(out, map[string]any{"finding_id": strconv.FormatInt(id, 10), "name": name, "vulnclass": class, "severity": severity, "summary": summary, "asset_ids": assetIDs, "case_id": strconv.FormatInt(cid, 10)}) } - return items, total, strong, rows.Err() + return out, total, rows.Err() } // Batch task-list counters avoid an additional database round trip per task. diff --git a/db/finding_cases_test.go b/db/finding_cases_test.go index 0592bf3..48398a5 100644 --- a/db/finding_cases_test.go +++ b/db/finding_cases_test.go @@ -118,11 +118,11 @@ func TestFindingCaseScopeSuggestionsAndDeletion(t *testing.T) { if _, err := d.MergeFindingCase(ctx, task.ID, []int64{ids[0], fid}, "x", "x", "reporter"); err == nil { t.Fatal("cross task accepted") } - sid, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[0], ids[1]}, "order", "uncertain") + sid, err := d.SuggestFindingCase(ctx, task.ID, ids[0], ids[1], "order", "uncertain") if err != nil { t.Fatal(err) } - if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, false, nil); err != nil { + if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, false); err != nil { t.Fatal(err) } if _, err := d.MergeFindingCase(ctx, task.ID, ids[:2], "x", "x", "reporter"); err == nil { @@ -233,7 +233,7 @@ func TestFindingCaseTaskDeletionAndRepeatedDetach(t *testing.T) { func TestFindingCaseSuggestionResolutionIsAtomic(t *testing.T) { d, task, ids := caseTestDB(t) ctx := context.Background() - sid, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[0], ids[1]}, "orders", "uncertain") + sid, err := d.SuggestFindingCase(ctx, task.ID, ids[0], ids[1], "orders", "uncertain") if err != nil { t.Fatal(err) } @@ -241,7 +241,7 @@ func TestFindingCaseSuggestionResolutionIsAtomic(t *testing.T) { var wg sync.WaitGroup for _, accept := range []bool{true, false} { wg.Add(1) - go func(a bool) { defer wg.Done(); _, e := d.ResolveFindingCaseSuggestion(ctx, sid, a, nil); results <- e }(accept) + go func(a bool) { defer wg.Done(); _, e := d.ResolveFindingCaseSuggestion(ctx, sid, a); results <- e }(accept) } wg.Wait() close(results) @@ -269,7 +269,7 @@ func TestFindingCaseCandidatesAreCompactAndRelated(t *testing.T) { if _, err := d.Exec(`UPDATE findings SET vulnclass='SQLi',name='search injection' WHERE id=$1`, ids[3]); err != nil { t.Fatal(err) } - items, total, _, err := d.FindingCaseCandidates(ids[0], 1, 1) + items, total, err := d.FindingCaseCandidates(ids[0], 1, 1) if err != nil || total != 2 || len(items) != 1 { t.Fatalf("candidate pagination %d %+v %v", total, items, err) } @@ -286,7 +286,7 @@ func TestFindingCaseCandidatesAreCompactAndRelated(t *testing.T) { if err := d.RemoveFindingCaseMember(context.Background(), cid, ids[1], "different cause"); err != nil { t.Fatal(err) } - _, total, _, err = d.FindingCaseCandidates(ids[0], 1, 20) + _, total, err = d.FindingCaseCandidates(ids[0], 1, 20) if err != nil || total != 1 { t.Fatalf("veto excluded %d %v", total, err) } @@ -355,210 +355,3 @@ func TestFindingCaseViewContextAndAssetFiltering(t *testing.T) { t.Fatalf("original severity order %+v %v", rows, err) } } - -func suggestionState(t *testing.T, d *DB, id int64) string { - t.Helper() - var state string - if err := d.QueryRow(`SELECT state FROM finding_case_suggestions WHERE id=$1`, id).Scan(&state); err != nil { - t.Fatal(err) - } - return state -} - -func TestFindingCaseClusterSuggestionLifecycle(t *testing.T) { - d, task, ids := caseTestDB(t) - ctx := context.Background() - pair, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[1], ids[0]}, "orders", "same handler, owner check unverified") - if err != nil { - t.Fatal(err) - } - group, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[0], ids[1], ids[2], ids[3]}, "orders", "same handler for all four") - if err != nil { - t.Fatal(err) - } - if group == pair || suggestionState(t, d, pair) != "superseded" { - t.Fatalf("subset pair not superseded: %d %d %s", pair, group, suggestionState(t, d, pair)) - } - // Re-suggesting a subset of a pending group reuses the group instead of adding a card. - again, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[2], ids[3]}, "orders", "dup") - if err != nil || again != group { - t.Fatalf("subset reuse %d %v", again, err) - } - list, err := d.FindingCaseSuggestions(task.ID) - if err != nil || len(list) != 1 || !reflect.DeepEqual(list[0].FindingIDs, []string{fmt.Sprint(ids[0]), fmt.Sprint(ids[1]), fmt.Sprint(ids[2]), fmt.Sprint(ids[3])}) { - t.Fatalf("grouped list %+v %v", list, err) - } - if _, err := d.ResolveFindingCaseSuggestion(ctx, group, true, []int64{ids[1], ids[2], ids[3]}); err == nil { - t.Fatal("accepted with fewer than two records") - } - cid, err := d.ResolveFindingCaseSuggestion(ctx, group, true, []int64{ids[3]}) - if err != nil || cid == 0 { - t.Fatal(cid, err) - } - c, err := d.GetFindingCase(cid) - if err != nil || c.Count != 3 { - t.Fatalf("one merge of remaining members %+v %v", c, err) - } - if other, _ := d.FindingCaseID(ids[3]); other != 0 { - t.Fatal("excluded record merged") - } - if suggestionState(t, d, group) != "accepted" { - t.Fatal("group not accepted") - } - var events int - if err := d.QueryRow(`SELECT count(*) FROM finding_case_events WHERE case_id=$1 AND action='merge'`, cid).Scan(&events); err != nil || events != 1 { - t.Fatalf("merge events %d %v", events, err) - } - if _, err := d.MergeFindingCase(ctx, task.ID, []int64{ids[0], ids[3]}, "x", "x", "reporter"); err == nil { - t.Fatal("human exclusion not kept as veto") - } - if _, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[2], ids[3]}, "x", "x"); err == nil { - t.Fatal("vetoed pair suggested again") - } - if _, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[0], ids[1]}, "x", "x"); err == nil { - t.Fatal("records already in one folder suggested") - } - if list, _ := d.FindingCaseSuggestions(task.ID); len(list) != 0 { - t.Fatalf("stale cards remain %+v", list) - } -} - -func TestFindingCaseClusterRejectVetoesAllPairsAndMergeClearsCards(t *testing.T) { - d, task, ids := caseTestDB(t) - ctx := context.Background() - a, err := d.SuggestFindingCase(ctx, task.ID, ids[:3], "orders", "shared component") - if err != nil { - t.Fatal(err) - } - if _, err := d.ResolveFindingCaseSuggestion(ctx, a, false, nil); err != nil { - t.Fatal(err) - } - var blocks int - if err := d.QueryRow(`SELECT count(*) FROM finding_case_blocks WHERE left_id=ANY(ARRAY[$1,$2,$3]::bigint[]) AND right_id=ANY(ARRAY[$1,$2,$3]::bigint[])`, ids[0], ids[1], ids[2]).Scan(&blocks); err != nil || blocks != 3 { - t.Fatalf("blocks %d %v", blocks, err) - } - b, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[2], ids[3]}, "orders", "same param") - if err != nil { - t.Fatal(err) - } - // A direct reporter merge covering a pending group clears the card. - if _, err := d.MergeFindingCase(ctx, task.ID, []int64{ids[2], ids[3]}, "orders", "proof", "reporter"); err != nil { - t.Fatal(err) - } - if suggestionState(t, d, b) != "accepted" { - t.Fatal("covered suggestion still pending") - } -} - -func TestFindingCaseCandidatesRankStrongBeforeWeak(t *testing.T) { - d, task, _ := caseTestDB(t) - add := func(class, name string, assets []int64) int64 { - id, err := d.AddFinding(task.ID, 0, class, name, "high", "s", "e", "worker", assets) - if err != nil { - t.Fatal(err) - } - return id - } - base := add("SSRF", "fetch proxy", []int64{900001}) - strong := add("SSRF", "image import", []int64{900001}) - assetOnly := add("XSS", "comment", []int64{900001}) - nameOnly := add("RCE", "fetch proxy", nil) - items, total, strongTotal, err := d.FindingCaseCandidates(base, 1, 20) - if err != nil { - t.Fatal(err) - } - if total != 3 || strongTotal != 1 || len(items) != 3 { - t.Fatalf("recall kept %d strong %d %+v", total, strongTotal, items) - } - if items[0]["finding_id"] != fmt.Sprint(strong) || items[0]["tier"] != "strong" || !reflect.DeepEqual(items[0]["hit_reasons"], []string{"同漏洞类型", "资产重叠"}) { - t.Fatalf("strong first %+v", items[0]) - } - if items[1]["finding_id"] != fmt.Sprint(assetOnly) || items[2]["finding_id"] != fmt.Sprint(nameOnly) || items[2]["tier"] != "weak" { - t.Fatalf("weak ordering %+v", items) - } - if items[0]["score"].(int) <= items[1]["score"].(int) { - t.Fatalf("scores %+v", items) - } - page2, total2, _, err := d.FindingCaseCandidates(base, 2, 2) - if err != nil || total2 != 3 || len(page2) != 1 || page2[0]["finding_id"] != fmt.Sprint(nameOnly) { - t.Fatalf("low score candidates stay paginated %+v %v", page2, err) - } -} - -func TestFindingCaseClusterExclusionCannotBePulledInByFolder(t *testing.T) { - d, task, ids := caseTestDB(t) - ctx := context.Background() - if _, err := d.MergeFindingCase(ctx, task.ID, ids[:2], "orders", "proof", "reporter"); err != nil { - t.Fatal(err) - } - sid, err := d.SuggestFindingCase(ctx, task.ID, ids[:3], "orders", "shared handler") - if err != nil { - t.Fatal(err) - } - if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, true, []int64{ids[1]}); err == nil { - t.Fatal("excluded member merged through its existing folder") - } - if cid, _ := d.FindingCaseID(ids[2]); cid != 0 || suggestionState(t, d, sid) != "pending" { - t.Fatal("failed exclusion was not rolled back") - } -} - -func TestFindingCaseClusterRejectKeepsExistingFolderMergeable(t *testing.T) { - d, task, ids := caseTestDB(t) - ctx := context.Background() - cid, err := d.MergeFindingCase(ctx, task.ID, ids[:2], "orders", "proof", "reporter") - if err != nil { - t.Fatal(err) - } - sid, err := d.SuggestFindingCase(ctx, task.ID, ids[:3], "orders", "shared handler") - if err != nil { - t.Fatal(err) - } - if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, false, nil); err != nil { - t.Fatal(err) - } - var inFolder int - if err := d.QueryRow(`SELECT count(*) FROM finding_case_blocks WHERE left_id=$1 AND right_id=$2`, ids[0], ids[1]).Scan(&inFolder); err != nil || inFolder != 0 { - t.Fatalf("co-membered pair vetoed: %d %v", inFolder, err) - } - if _, err := d.MergeFindingCase(ctx, task.ID, []int64{ids[0], ids[2]}, "x", "x", "reporter"); err == nil { - t.Fatal("rejected pair merged") - } - if got, err := d.MergeFindingCase(ctx, task.ID, []int64{ids[1], ids[3]}, "orders", "proof", "reporter"); err != nil || got != cid { - t.Fatalf("folder poisoned by reject: %d %v", got, err) - } -} - -func TestFindingCaseClusterSurvivesAnchorDeletion(t *testing.T) { - d, task, ids := caseTestDB(t) - ctx := context.Background() - sid, err := d.SuggestFindingCase(ctx, task.ID, ids[:3], "orders", "shared handler") - if err != nil { - t.Fatal(err) - } - if n, err := d.DeleteFinding(ids[0]); err != nil || n != 1 { - t.Fatal(n, err) - } - list, err := d.FindingCaseSuggestions(task.ID) - if err != nil || len(list) != 1 || list[0].ID != sid || !reflect.DeepEqual(list[0].FindingIDs, []string{fmt.Sprint(ids[1]), fmt.Sprint(ids[2])}) { - t.Fatalf("group lost with its anchor: %+v %v", list, err) - } - if list[0].LeftID != ids[1] || list[0].RightID != ids[2] { - t.Fatalf("anchors not moved: %+v", list[0]) - } - if cid, err := d.ResolveFindingCaseSuggestion(ctx, sid, true, nil); err != nil || cid == 0 { - t.Fatal(cid, err) - } - // Deleting below two members still drops the pair through the cascade. - pair, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[2], ids[3]}, "orders", "x") - if err != nil { - t.Fatal(err) - } - if _, err := d.DeleteFinding(ids[3]); err != nil { - t.Fatal(err) - } - var n int - if err := d.QueryRow(`SELECT count(*) FROM finding_case_suggestions WHERE id=$1`, pair).Scan(&n); err != nil || n != 0 { - t.Fatalf("two-member group kept after anchor deletion: %d %v", n, err) - } -} diff --git a/db/schema.sql b/db/schema.sql index 8e92122..1fe2311 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1488,40 +1488,6 @@ CREATE TABLE IF NOT EXISTS finding_case_suggestions ( UNIQUE(left_id,right_id), CHECK(left_id < right_id) ); -ALTER TABLE finding_case_suggestions DROP CONSTRAINT IF EXISTS finding_case_suggestions_left_id_right_id_key; -CREATE TABLE IF NOT EXISTS finding_case_suggestion_members ( - suggestion_id BIGINT NOT NULL REFERENCES finding_case_suggestions(id) ON DELETE CASCADE, - finding_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE, - PRIMARY KEY(suggestion_id,finding_id) -); -CREATE INDEX IF NOT EXISTS finding_case_suggestion_members_finding_idx ON finding_case_suggestion_members(finding_id); -INSERT INTO finding_case_suggestion_members(suggestion_id,finding_id) - SELECT id,left_id FROM finding_case_suggestions ON CONFLICT DO NOTHING; -INSERT INTO finding_case_suggestion_members(suggestion_id,finding_id) - SELECT id,right_id FROM finding_case_suggestions ON CONFLICT DO NOTHING; --- left_id/right_id only anchor a group (ON DELETE CASCADE). Before one anchor --- finding is deleted, move the anchors to the two smallest surviving members so --- the rest of a 3+ member group is not cascade-deleted with it. -CREATE OR REPLACE FUNCTION finding_case_suggestion_reanchor() RETURNS trigger AS $$ -DECLARE - sid BIGINT; - l BIGINT; - r BIGINT; -BEGIN - FOR sid IN SELECT id FROM finding_case_suggestions WHERE left_id=OLD.id OR right_id=OLD.id LOOP - SELECT min(sm.finding_id) INTO l FROM finding_case_suggestion_members sm - WHERE sm.suggestion_id=sid AND sm.finding_id<>OLD.id AND EXISTS(SELECT 1 FROM findings f WHERE f.id=sm.finding_id); - SELECT min(sm.finding_id) INTO r FROM finding_case_suggestion_members sm - WHERE sm.suggestion_id=sid AND sm.finding_id>l AND sm.finding_id<>OLD.id AND EXISTS(SELECT 1 FROM findings f WHERE f.id=sm.finding_id); - IF r IS NOT NULL THEN - UPDATE finding_case_suggestions SET left_id=l,right_id=r WHERE id=sid; - END IF; - END LOOP; - RETURN OLD; -END $$ LANGUAGE plpgsql; -DROP TRIGGER IF EXISTS trg_finding_case_suggestion_reanchor ON findings; -CREATE TRIGGER trg_finding_case_suggestion_reanchor BEFORE DELETE ON findings - FOR EACH ROW EXECUTE FUNCTION finding_case_suggestion_reanchor(); -- Rejected/detached pairs are a durable human veto, also checked on group unions. CREATE TABLE IF NOT EXISTS finding_case_blocks ( left_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE, diff --git a/server/finding_case_reviews.go b/server/finding_case_reviews.go index 8303a20..16a9936 100644 --- a/server/finding_case_reviews.go +++ b/server/finding_case_reviews.go @@ -35,7 +35,7 @@ func (s *Server) toolCompleteFindingCaseReview() actool.CoreTool { "verdict": map[string]any{"type": "string", "enum": []string{"independent", "merged", "suggested"}}, "reason": strParam("判断理由,接口或读写影响不同不能单独证明不同根因"), "root_cause_evidence": strParam("原始报告/证据中支持实际共同或不同根因的具体材料;不确定则说明缺失证据及已保存建议")}, "finding_id", "verdict", "reason", "root_cause_evidence") - return wrTool("complete_finding_case_review", "历史整理结束前提交每条所选记录的根因判断并验收:必须已读取全部记录,疑似必须已有建议;归并只需已在活动文件夹中,统一报告可稍后补齐。", objSchema(map[string]any{"summary": strParam("整理结果总结"), "decisions": map[string]any{"type": "array", "items": decision}}, "summary", "decisions"), func(ctx context.Context, raw json.RawMessage) (actool.Result, error) { + return wrTool("complete_finding_case_review", "历史整理结束前提交每条所选记录的根因判断并验收:必须已读取全部记录,疑似必须已有建议,归并必须已有完整最新统一报告。验收失败不能宣布完成。", objSchema(map[string]any{"summary": strParam("整理结果总结"), "decisions": map[string]any{"type": "array", "items": decision}}, "summary", "decisions"), func(ctx context.Context, raw json.RawMessage) (actool.Result, error) { var a findingReviewConclusion if err := json.Unmarshal(raw, &a); err != nil { return actool.Errorf(err.Error()), nil @@ -101,7 +101,7 @@ func (s *Server) saveFindingReviewConclusion(ctx context.Context, conv int64, a } case "suggested": var pending bool - if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_suggestions s JOIN finding_case_suggestion_members m ON m.suggestion_id=s.id WHERE s.state='pending' AND m.finding_id=$1)`, id).Scan(&pending); err != nil { + if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_suggestions WHERE state='pending' AND (left_id=$1 OR right_id=$1))`, id).Scan(&pending); err != nil { return err } if !pending { @@ -115,12 +115,17 @@ func (s *Server) saveFindingReviewConclusion(ctx context.Context, conv int64, a return errors.New("无效判断类型") } if cid.Valid { + var report string + var version, rv int64 var active bool - if err := tx.QueryRow(`SELECT active FROM finding_cases WHERE id=$1 FOR SHARE`, cid.Int64).Scan(&active); err != nil { + if err := tx.QueryRow(`SELECT report,version,report_version,active FROM finding_cases WHERE id=$1 FOR SHARE`, cid.Int64).Scan(&report, &version, &rv, &active); err != nil { return err } - if !active { - return fmt.Errorf("文件夹 #%d 已关闭", cid.Int64) + if !active || rv != version { + return fmt.Errorf("文件夹 #%d 的统一报告待更新,请重新读取后更新", cid.Int64) + } + if err := validateUnifiedFindingReport(report); err != nil { + return err } } } diff --git a/server/finding_case_reviews_test.go b/server/finding_case_reviews_test.go index 589aee0..06e1b58 100644 --- a/server/finding_case_reviews_test.go +++ b/server/finding_case_reviews_test.go @@ -43,7 +43,7 @@ func TestUnifiedFindingReportRejectsIncompleteAndTruncated(t *testing.T) { } } -func TestFindingCaseReviewCompletionRequiresReadsNotUnifiedReport(t *testing.T) { +func TestFindingCaseReviewCompletionRequiresReadsAndFreshReport(t *testing.T) { s, task, ids := testCaseServer(t) runs, err := s.m.pg.CreateFindingCaseReviews(t.Context(), map[int64][]int64{task: ids}) if err != nil { @@ -71,9 +71,15 @@ func TestFindingCaseReviewCompletionRequiresReadsNotUnifiedReport(t *testing.T) if err != nil { t.Fatal(err) } - // A merged verdict no longer waits for the unified report; the folder shows it as pending. + if err := s.saveFindingReviewConclusion(ctx, conv, a); err == nil { + t.Fatal("missing unified report accepted") + } + c, _ := s.m.pg.GetFindingCase(cid) + if err := s.m.pg.UpdateFindingCaseReport(ctx, cid, c.Version, "orders", completeFindingReviewReport, "high", "verified impact"); err != nil { + t.Fatal(err) + } if err := s.saveFindingReviewConclusion(ctx, conv, a); err != nil { - t.Fatalf("merge blocked by missing unified report: %v", err) + t.Fatal(err) } if err := s.validateFindingReviewCompletion(conv); err != nil { t.Fatal(err) @@ -84,14 +90,8 @@ func TestFindingCaseReviewCompletionRequiresReadsNotUnifiedReport(t *testing.T) if _, err := s.m.pg.Exec(`UPDATE findings SET evidence='new proof' WHERE id=$1`, ids[0]); err != nil { t.Fatal(err) } - if err := s.validateFindingReviewCompletion(conv); err != nil { - t.Fatalf("stale unified report should not fail a merged review: %v", err) - } - if err := s.m.pg.RemoveFindingCaseMember(ctx, cid, ids[0], "different cause"); err != nil { - t.Fatal(err) - } if err := s.validateFindingReviewCompletion(conv); err == nil { - t.Fatal("merged verdict accepted after the record left the folder") + t.Fatal("stale group reported completed") } } @@ -138,7 +138,7 @@ func TestFindingCaseReviewSuggestionNeedsSavedSuggestion(t *testing.T) { if err := s.saveFindingReviewConclusion(ctx, conv, a); err == nil { t.Fatal("unpersisted suggestion accepted") } - if _, err := s.m.pg.SuggestFindingCase(ctx, task, []int64{ids[0], ids[1]}, "orders", "common component unknown"); err != nil { + if _, err := s.m.pg.SuggestFindingCase(ctx, task, ids[0], ids[1], "orders", "common component unknown"); err != nil { t.Fatal(err) } if err := s.saveFindingReviewConclusion(ctx, conv, a); err != nil { diff --git a/server/finding_cases.go b/server/finding_cases.go index 3f0b7d8..d8afaf8 100644 --- a/server/finding_cases.go +++ b/server/finding_cases.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "net/http" - "strconv" "strings" "github.com/Autumn-27/artex/db" @@ -204,22 +203,12 @@ func (s *Server) caseSuggestions(w http.ResponseWriter, r *http.Request) { } func (s *Server) resolveCaseSuggestion(w http.ResponseWriter, r *http.Request) { var a struct { - Accept bool `json:"accept"` - ExcludeIDs []string `json:"exclude_ids"` + Accept bool `json:"accept"` } if !decodeCaseRequest(w, r, &a) { return } - exclude := make([]int64, 0, len(a.ExcludeIDs)) - for _, v := range a.ExcludeIDs { - id, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) - if err != nil || id <= 0 { - caseHTTPError(w, errors.New("无效的排除记录编号")) - return - } - exclude = append(exclude, id) - } - id, err := s.m.pg.ResolveFindingCaseSuggestion(r.Context(), int64(atoiDefault(r.PathValue("id"), 0)), a.Accept, exclude) + id, err := s.m.pg.ResolveFindingCaseSuggestion(r.Context(), int64(atoiDefault(r.PathValue("id"), 0)), a.Accept) if err != nil { caseHTTPError(w, err) return @@ -264,10 +253,10 @@ 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", "只读检索同任务候选,返回摘要与资产;等级不同也可能重复。支持分页,按需读取详情。", []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"}}, + {"suggest_finding_merge", "证据不足时提交疑似重复建议,待人工确认;不是自动合并。只允许两条同任务记录。", []string{"task_id", "finding_ids", "title", "reason"}}, {"get_finding_case", "读取归并组和成员分页。先记录version,再读取成员的原报告、证据及流量;版本变化必须重新读取。", []string{"case_id"}}, {"update_finding_case_report", "保存统一报告及评级理由,version必须来自实际读取。保留各条原报告,不新建漏洞。", []string{"case_id", "version", "title", "report", "severity", "severity_reason"}}, } @@ -323,9 +312,8 @@ func (s *Server) performFindingCaseTool(ctx context.Context, name string, a find if scope != nil { scopes = append(scopes, scope) } - items, total, strong, err := s.m.pg.FindingCaseCandidates(fid, page, 20, scopes...) - return map[string]any{"items": items, "total": total, "strong_total": strong, "page": page, "page_size": 20, - "note": "按分数排序:strong=同漏洞类型且资产重叠,优先核对;weak=单一条件命中,仅作参考,没有具体共同点时不应据此建议归并"}, err + items, total, err := s.m.pg.FindingCaseCandidates(fid, page, 20, scopes...) + return map[string]any{"items": items, "total": total, "page": page, "page_size": 20}, err case "get_finding_record": f, err := s.m.pg.GetFinding(fid) if err != nil { @@ -353,11 +341,11 @@ func (s *Server) performFindingCaseTool(ctx context.Context, name string, a find return map[string]string{"case_id": i64s(id)}, err case "suggest_finding_merge": ids := a.ids() - if len(ids) < 2 || len(ids) > 20 { - return nil, errors.New("疑似重复分组需要2至20条记录") + if len(ids) != 2 { + return nil, errors.New("疑似重复建议需要两条记录") } - id, err := s.m.pg.SuggestFindingCase(ctx, parseProfileID(a.TaskID), ids, a.Title, a.Reason) - return map[string]any{"suggestion_id": i64s(id), "finding_ids": ids}, err + id, err := s.m.pg.SuggestFindingCase(ctx, parseProfileID(a.TaskID), ids[0], ids[1], a.Title, a.Reason) + return map[string]string{"suggestion_id": i64s(id)}, err case "get_finding_case": c, err := s.m.pg.GetFindingCase(cid) if err != nil { diff --git a/web/src/components/finding-case-list.tsx b/web/src/components/finding-case-list.tsx index b4deab1..105ce1d 100644 --- a/web/src/components/finding-case-list.tsx +++ b/web/src/components/finding-case-list.tsx @@ -192,18 +192,15 @@ function FindingMergeSuggestionCard({ onResolve, }: { suggestion: FindingCaseSuggestion; - onResolve: (s: FindingCaseSuggestion, accept: boolean, excludeIds?: string[]) => Promise; + onResolve: (s: FindingCaseSuggestion, accept: boolean) => Promise; }) { const [expanded, setExpanded] = React.useState(false); const [busy, setBusy] = React.useState(false); - const ids = s.finding_ids.length ? s.finding_ids : [s.left_id, s.right_id]; - const [excluded, setExcluded] = React.useState([]); - const included = ids.filter((id) => !excluded.includes(id)); async function resolve(accept: boolean) { if (busy) return; setBusy(true); try { - await onResolve(s, accept, accept ? excluded : []); + await onResolve(s, accept); } finally { setBusy(false); } @@ -212,37 +209,17 @@ function FindingMergeSuggestionCard({
- 疑似重复分组 · {ids.length} 条 · 待确认 + 疑似重复 · 待确认 任务 #{s.task_id}
{s.title} - - {ids.map((id) => ( - - - setExcluded((old) => - v === true ? old.filter((x) => x !== id) : [...old.filter((x) => x !== id), id], - ) - } - aria-label={`归并时包含上报 #${id}`} - /> - - 查看原报告 #{id} - - - ))} - {excluded.length > 0 ? ( - 已排除 {excluded.length} 条:确认后这些记录不会再与本组建议归并 - ) : null} + + + 查看原报告 #{s.left_id} + + + 查看原报告 #{s.right_id} +
@@ -261,12 +238,11 @@ function FindingMergeSuggestionCard({ -
@@ -295,20 +271,14 @@ export function FindingCaseReviewPanel({ taskId, onChange }: { taskId?: string; const [suggestions, setSuggestions] = React.useState([]); const [runs, setRuns] = React.useState([]); const [error, setError] = React.useState(""); - // Only the newest suggestions request (poll or post-resolve refresh) may write the list, - // so an older response cannot bring back a resolved group or another task's list. - const listSeq = React.useRef(0); - const currentTask = React.useRef(taskId); - currentTask.current = taskId; React.useEffect(() => { let active = true; let timer: ReturnType; async function load() { - const seq = ++listSeq.current; try { const [s, r] = await Promise.all([api.findingCaseSuggestions(taskId), api.findingCaseReviewRuns()]); if (active) { - if (seq === listSeq.current) setSuggestions(s); + setSuggestions(s); setRuns(r.filter((x) => !taskId || x.task_id === taskId)); setError(""); } @@ -324,21 +294,10 @@ export function FindingCaseReviewPanel({ taskId, onChange }: { taskId?: string; clearTimeout(timer); }; }, [taskId]); - async function resolve(s: FindingCaseSuggestion, accept: boolean, excludeIds: string[] = []) { + async function resolve(s: FindingCaseSuggestion, accept: boolean) { try { - const result = await api.resolveFindingCaseSuggestion(s.id, accept, excludeIds); - // A resolve that finishes after switching tasks must not touch the new task's list. - if (currentTask.current === taskId) { - const seq = ++listSeq.current; - setSuggestions((v) => v.filter((x) => x.id !== s.id)); - // Server-side resolution may also clear related groups; refresh instead of waiting for the poll. - api - .findingCaseSuggestions(taskId) - .then((v) => { - if (seq === listSeq.current && currentTask.current === taskId) setSuggestions(v); - }) - .catch(() => undefined); - } + const result = await api.resolveFindingCaseSuggestion(s.id, accept); + setSuggestions((v) => v.filter((x) => x.id !== s.id)); if (accept && result.case_id !== "0") { toast.success("已归并,请进入文件夹生成统一报告"); } else { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 821da2f..fedc913 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -645,11 +645,8 @@ export const api = { get( `/exploration/finding-case-suggestions${task ? `?task_id=${encodeURIComponent(task)}` : ""}`, ), - resolveFindingCaseSuggestion: (id: string, accept: boolean, excludeIds?: string[]) => - post<{ case_id: string }>(`/exploration/finding-case-suggestions/${id}`, { - accept, - ...(excludeIds?.length ? { exclude_ids: excludeIds } : {}), - }), + 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, diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index d730691..54e26a6 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -1737,7 +1737,6 @@ export interface FindingCaseSuggestion { title: string; reason: string; state: string; - finding_ids: string[]; } export interface FindingCaseReviewRun { conversation_id: number; From 09e90b417047cedad8defc8b2cdf22712affc2db Mon Sep 17 00:00:00 2001 From: RuoJi6 Date: Sat, 24 Oct 2026 21:55:45 +0800 Subject: [PATCH 4/7] feat(findings): grouped duplicate suggestions, ranked candidates, relaxed merged acceptance (Codex rewrite) Authored entirely by Codex CLI (codex exec, reasoning effort high), one session resumed across capacity fallbacks: - gpt-6.1-sol (schema + initial db work, stopped at capacity) - gpt-6-astra (db/server/agent/frontend, stopped at capacity) - gpt-6-sol (remaining items, tests, fixes; stopped at capacity after final checks) Schemes 1+2+3: prompt/acceptance (2..20, evidenced differences justify independent, merged no longer blocked by unified report), group suggestions with member table, superset reuse/subset superseded, veto-aware reject/exclusion, anchor re-anchoring trigger, sweep after merge/reject/remove, frontend group card with stale-response guard, candidate scoring with tier/hit_reasons/strong_total. --- agent/finding_cases.go | 10 +- agent/finding_cases_test.go | 2 +- db/finding_cases.go | 442 ++++++++++++++++++++--- db/finding_cases_test.go | 204 ++++++++++- db/schema.sql | 48 +++ server/finding_case_reviews.go | 19 +- server/finding_case_reviews_test.go | 15 +- server/finding_cases.go | 32 +- web/src/components/finding-case-list.tsx | 72 +++- web/src/lib/api.ts | 7 +- web/src/lib/mock/handler.ts | 3 +- web/src/lib/types.ts | 1 + 12 files changed, 740 insertions(+), 115 deletions(-) diff --git a/agent/finding_cases.go b/agent/finding_cases.go index 7e69412..3a84e96 100644 --- a/agent/finding_cases.go +++ b/agent/finding_cases.go @@ -45,13 +45,13 @@ func findingCaseWorkflow(key string, tools []actool.CoreTool) ([]actool.CoreTool } return tools, ` -【同一漏洞的多次上报整理】保留本次独立报告,不覆盖其他原报告。默认报告完成即停止的旧指引在此扩展:先保存本次报告,然后 search_finding_duplicates(finding_id=独立记录ID) 分页读取同任务候选摘要,按需 get_finding_record 查看证据。仅凭漏洞类型/标题相似或高中低等级不能归并;须核对目标、接口、参数、权限边界、根因及实际验证证据。 -【判断准则】接口、路径、后端、读/写影响、评级不同,都不能单独作为不重复的理由;同一登录入口、同一令牌或同一漏洞类型也不能单独证明同一根因。比较实际缺失的鉴权检查、对象权限边界、会话门或共同组件,并引用每条记录中的具体证据。读和写可能是同一权限检查缺失的不同影响;也可能是独立 handler 的独立缺陷。只有证据证明共同实际根因才自动归并,证据证明不同根因才判独立;存在关联但无法确认时必须 suggest_finding_merge,说明已知共同点、缺失证据和待确认问题,不用“路径不同/影响不同”代替根因判断。明确同一缺陷时 merge_finding_records(task_id,finding_ids,title,reason),不确定时 suggest_finding_merge 留待人工确认,不能强行归并。不重复上报、不删除记录、不重新探测目标。人工选择整理时只能判断所选记录,不能加入未选记录(既有组成员可作为报告上下文)。 -归并成功或已有case_id时,get_finding_case 记录实际version,分页读取全部成员,按需get_finding_record、get_finding_traffic读取原报告、PoC及已保存流量。update_finding_case_report保存完整统一报告(包含有内容的概述、影响、前置条件、复现步骤、证据、PoC、根因分析、修复建议章节,不得半篇截断)和基于已验证影响的severity及severity_reason;不能直接取成员最高等级。集中给出前置条件、完整执行顺序、输入输出如何衔接、可运行的完整脚本及命令,不把必要步骤分散到原报告;缺失条件、未验证步骤如实标明。版本冲突重新读取材料并生成,不能仅换version重试。各成员仍有独立报告、评级和证据。没有明确重复则保留独立记录。工具被关闭时不绕过配置。` +【同一漏洞的多次上报整理】保留本次独立报告,不覆盖其他原报告。默认报告完成即停止的旧指引在此扩展:先保存本次报告,然后 search_finding_duplicates(finding_id=独立记录ID) 分页读取同任务候选摘要(按 score 排序;strong=同漏洞类型且资产重叠,优先核对;weak=单一条件命中,仅作参考),按需 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重试。各成员仍有独立报告、评级和证据。工具被关闭时不绕过配置。` } const historicalFindingReviewGuidance = ` 【历史整理模式,优先于旧报告撰写及自动绑流量指引】本次只整理已经存在的上报。原始报告即使为空或待更新,也只记录缺失情况,绝不调用 update_finding_report、report_finding、bind_finding_traffic,不修改原始标题、评级、状态、证据或报告,不执行 Bash、写文件或重新探测目标。 -先逐条 get_finding_record 读取所选记录,已有原报告直接作为判断材料复用,不重复撰写。只在所选范围内检查同一实际缺陷,可按需读取已存在的证据。按上述根因准则形成明确归并/独立/疑似建议;无法确认时保存建议供人工确认。 -已有或新建文件夹必须读取全部成员并写入完整、最新的统一报告;统一报告是单独的材料,不覆盖原报告。最后必须调用 complete_finding_case_review,为每条所选 finding_id 提交 verdict(independent/merged/suggested)、reason 和 root_cause_evidence,并提供总结。根因证据应说明具体相同/不同的检查及证据来源;疑似建议明确待补证据。服务端验收通过后才可宣布完成。工具不可用或验收失败时如实报告,不能只用自然语言声称完成。` +先逐条 get_finding_record 读取所选记录,已有原报告直接作为判断材料复用,不重复撰写。只在所选范围内检查同一实际缺陷,可按需读取已存在的证据。结构化证据显示不同端点/路由、处理器或后端组件、参数或缺失检查属于不同代码路径时,可判 independent;仅类型、资产、标题、令牌或登录入口相似不够。只有具体共同点明确且关键根因证据缺失时才保存一个覆盖2..20条记录的疑似分组,没有具体共同点就保持 independent。历史整理只能处理 selected scope,必须遵守同一任务限制和人工否决。 +历史模式禁止调用 update_finding_report、report_finding、bind_finding_traffic,不修改原始标题、评级、状态、证据或报告,不执行 Bash、写文件或重新探测目标。已有或新建文件夹应读取全部成员并尽力用 update_finding_case_report 写入完整、最新的统一报告;归并不因报告暂缺而阻塞,未完成时文件夹显示“统一报告待更新”,可由后续运行或人工补齐。统一报告是单独的材料,不覆盖原报告。最后必须调用 complete_finding_case_review,为每条所选 finding_id 提交 verdict(independent/merged/suggested)、reason 和 root_cause_evidence,并提供总结。根因证据应说明具体相同/不同的检查及证据来源;疑似建议明确待补证据。服务端验收通过后才可宣布完成。人工整理只能判断 selected scope,不能加入未选记录。工具不可用或验收失败时如实报告,不能只用自然语言声称完成。` diff --git a/agent/finding_cases_test.go b/agent/finding_cases_test.go index a8177ea..0120f3d 100644 --- a/agent/finding_cases_test.go +++ b/agent/finding_cases_test.go @@ -46,7 +46,7 @@ func TestHistoricalFindingReviewFiltersMutationAndProbeTools(t *testing.T) { t.Fatal("historical mutation capability", tool.Name()) } } - if len(out) != 6 || def.FindingGuidance != "" || !strings.Contains(def.CaseGuidance, "历史整理模式") || !strings.Contains(def.CaseGuidance, "接口、路径、后端、读/写影响、评级不同") { + if len(out) != 6 || def.FindingGuidance != "" || !strings.Contains(def.CaseGuidance, "历史整理模式") || !strings.Contains(def.CaseGuidance, "端点/路由") || !strings.Contains(def.CaseGuidance, "2..20") { t.Fatal("missing historical isolation", def) } normal, _, cl := AugmentTools(t.Context(), "reporter", tools) diff --git a/db/finding_cases.go b/db/finding_cases.go index d7ec976..b87a2f4 100644 --- a/db/finding_cases.go +++ b/db/finding_cases.go @@ -41,13 +41,14 @@ type FindingCaseEvent struct { CreatedAt time.Time `json:"created_at"` } type FindingCaseSuggestion struct { - ID int64 `json:"id,string"` - TaskID int64 `json:"task_id,string"` - LeftID int64 `json:"left_id,string"` - RightID int64 `json:"right_id,string"` - Title string `json:"title"` - Reason string `json:"reason"` - State string `json:"state"` + ID int64 `json:"id,string"` + TaskID int64 `json:"task_id,string"` + LeftID int64 `json:"left_id,string"` + RightID int64 `json:"right_id,string"` + Title string `json:"title"` + Reason string `json:"reason"` + State string `json:"state"` + FindingIDs []string `json:"finding_ids"` } type FindingCaseRow struct { Case *FindingCase `json:"case,omitempty"` @@ -198,9 +199,9 @@ func lockCaseTask(tx *sql.Tx, taskID int64) error { return tx.QueryRow(`SELECT id FROM tasks WHERE id=$1 FOR UPDATE`, taskID).Scan(&id) } func (d *DB) MergeFindingCase(ctx context.Context, taskID int64, ids []int64, title, reason, actor string) (int64, error) { - return d.mergeFindingCase(ctx, taskID, ids, title, reason, actor, 0) + return d.mergeFindingCase(ctx, taskID, ids, title, reason, actor, 0, nil) } -func (d *DB) mergeFindingCase(ctx context.Context, taskID int64, ids []int64, title, reason, actor string, suggestionID int64) (int64, error) { +func (d *DB) mergeFindingCase(ctx context.Context, taskID int64, ids []int64, title, reason, actor string, suggestionID int64, dropped []int64) (int64, error) { ids, err := sortedFindingIDs(ids) if err != nil { return 0, err @@ -308,11 +309,62 @@ func (d *DB) mergeFindingCase(ctx context.Context, taskID int64, ids []int64, ti return err } } - _, err := tx.Exec(`UPDATE finding_case_suggestions SET state='accepted' WHERE state='pending' AND left_id IN(SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb)) AND right_id IN(SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb))`, string(raw)) - return err + for _, excluded := range dropped { + if all[excluded] { + return fmt.Errorf("排除的记录 #%d 已与所选记录在同一文件夹,请先移出该文件夹后再确认", excluded) + } + for _, kept := range ids { + 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 { + return err + } + } + } + if suggestionID > 0 { + if _, err := tx.Exec(`UPDATE finding_case_suggestions SET state='accepted' WHERE id=$1 AND state='pending'`, suggestionID); err != nil { + return err + } + } + return sweepFindingCaseSuggestions(tx, taskID) }) return cid, err } + +// sweepFindingCaseSuggestions reconciles pending cards after a merge or veto. +// Cross-task member rows are ignored so a malformed legacy row cannot leak into +// a task's suggestions. +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 + 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 + 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 + 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 { + 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 + OR EXISTS (SELECT 1 + FROM finding_case_suggestion_members a + JOIN finding_case_suggestion_members b + ON b.suggestion_id=a.suggestion_id AND a.finding_id < b.finding_id + JOIN findings fa ON fa.id=a.finding_id AND fa.task_id=s.task_id + JOIN findings fb ON fb.id=b.finding_id AND fb.task_id=s.task_id + JOIN finding_case_blocks bl ON bl.left_id=a.finding_id AND bl.right_id=b.finding_id + WHERE a.suggestion_id=s.id))`, taskID) + return err +} func (d *DB) RemoveFindingCaseMember(ctx context.Context, cid, fid int64, reason string) error { c, err := d.GetFindingCase(cid) if err != nil { @@ -355,8 +407,10 @@ func (d *DB) RemoveFindingCaseMember(ctx context.Context, cid, fid int64, reason if _, err := tx.Exec(`INSERT INTO finding_case_events(case_id,action,actor,reason,finding_ids) VALUES($1,'detach','human',$2,jsonb_build_array($3::bigint))`, cid, reason, fid); err != nil { return err } - _, err := tx.Exec(`DELETE FROM finding_case_members WHERE case_id=$1 AND finding_id=$2`, cid, fid) - return err + if _, err := tx.Exec(`DELETE FROM finding_case_members WHERE case_id=$1 AND finding_id=$2`, cid, fid); err != nil { + return err + } + return sweepFindingCaseSuggestions(tx, origin) }) } func (d *DB) UpdateFindingCaseReport(ctx context.Context, cid, version int64, title, report, severity, reason string) error { @@ -382,81 +436,345 @@ func (d *DB) UpdateFindingCaseReport(ctx context.Context, cid, version int64, ti return err }) } -func (d *DB) SuggestFindingCase(ctx context.Context, taskID, left, right int64, title, reason string) (int64, error) { + +type suggestionQuery interface { + Query(string, ...any) (*sql.Rows, error) +} + +// suggestionMembers reads only surviving members in the suggestion's task. The +// anchor fallback keeps rows created by older binaries resolvable if a database +// was upgraded before the idempotent backfill ran. +func suggestionMembers(q suggestionQuery, suggestionID, taskID int64) ([]int64, error) { + rows, err := q.Query(`SELECT sm.finding_id + FROM finding_case_suggestion_members sm + JOIN findings f ON f.id=sm.finding_id AND f.task_id=$2 + WHERE sm.suggestion_id=$1 ORDER BY sm.finding_id`, suggestionID, taskID) + if err != nil { + return nil, err + } + ids := []int64{} + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + rows.Close() + return nil, err + } + ids = append(ids, id) + } + err = rows.Err() + rows.Close() + if err != nil { + return nil, err + } + if len(ids) > 0 { + return ids, nil + } + rows, err = q.Query(`SELECT x.finding_id FROM ( + SELECT s.left_id AS finding_id FROM finding_case_suggestions s WHERE s.id=$1 + UNION SELECT s.right_id FROM finding_case_suggestions s WHERE s.id=$1 + ) x JOIN findings f ON f.id=x.finding_id AND f.task_id=$2 ORDER BY x.finding_id`, suggestionID, taskID) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +// SuggestFindingCase stores a pending group of 2..20 same-task findings. It +// refreshes an exact group, reuses a pending superset, and supersedes strict +// subsets while holding the task advisory lock. +func (d *DB) SuggestFindingCase(ctx context.Context, taskID int64, findingIDs []int64, title, reason string) (int64, error) { if strings.TrimSpace(title) == "" || strings.TrimSpace(reason) == "" { return 0, errors.New("建议需要名称和具体证据理由") } - ids, err := sortedFindingIDs([]int64{left, right}) + if len(findingIDs) > 20 { + return 0, errors.New("疑似重复分组最多20条记录") + } + seen := make(map[int64]bool, len(findingIDs)) + for _, fid := range findingIDs { + if seen[fid] { + return 0, errors.New("疑似重复分组中的记录编号必须唯一") + } + seen[fid] = true + } + ids, err := sortedFindingIDs(findingIDs) if err != nil { return 0, err } - var id int64 + var suggestionID int64 err = d.WithEvidenceTx(ctx, func(tx *sql.Tx) error { if err := lockCaseTask(tx, taskID); err != nil { return err } + caseIDs := map[int64]bool{} + sameActiveCase := true + var firstCase int64 for _, fid := range ids { var tid sql.NullInt64 - if err := tx.QueryRow(`SELECT task_id FROM findings WHERE id=$1`, fid).Scan(&tid); err != nil { + var cid int64 + if err := tx.QueryRow(`SELECT f.task_id,COALESCE(CASE WHEN c.active THEN m.case_id ELSE 0 END,0) + FROM findings f LEFT JOIN finding_case_members m ON m.finding_id=f.id + LEFT JOIN finding_cases c ON c.id=m.case_id WHERE f.id=$1`, fid).Scan(&tid, &cid); err != nil { return err } if !tid.Valid || tid.Int64 != taskID { return errors.New("只能建议同一任务的归并") } + if cid == 0 { + sameActiveCase = false + } else if firstCase == 0 { + firstCase = cid + caseIDs[cid] = true + } else if firstCase != cid { + sameActiveCase = false + } } - var blocked bool - if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_blocks WHERE left_id=$1 AND right_id=$2)`, ids[0], ids[1]).Scan(&blocked); err != nil { + if sameActiveCase && firstCase > 0 && len(caseIDs) == 1 { + return errors.New("已在同一文件夹") + } + + raw, _ := json.Marshal(ids) + rows, err := tx.Query(`SELECT left_id,right_id FROM finding_case_blocks + WHERE left_id IN (SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb)) + AND right_id IN (SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb)) + ORDER BY left_id,right_id`, string(raw)) + if err != nil { return err } - if blocked { - return errors.New("人工已否决该归并") + blocked := []string{} + for rows.Next() { + var left, right int64 + if err := rows.Scan(&left, &right); err != nil { + rows.Close() + return err + } + blocked = append(blocked, fmt.Sprintf("#%d/#%d", left, right)) } - return tx.QueryRow(`INSERT INTO finding_case_suggestions(task_id,left_id,right_id,title,reason) VALUES($1,$2,$3,$4,$5) ON CONFLICT(left_id,right_id) DO UPDATE SET reason=EXCLUDED.reason WHERE finding_case_suggestions.state='pending' RETURNING id`, taskID, ids[0], ids[1], title, reason).Scan(&id) + err = rows.Err() + rows.Close() + if err != nil { + return err + } + if len(blocked) > 0 { + return fmt.Errorf("人工已否决这些记录的归并:%s;请去掉其中一方后重新提交", strings.Join(blocked, "、")) + } + + type pendingGroup struct { + id int64 + members []int64 + title string + reason string + } + rows, err = tx.Query(`SELECT id,title,reason FROM finding_case_suggestions + WHERE task_id=$1 AND state='pending' ORDER BY id`, taskID) + if err != nil { + return err + } + groups := []pendingGroup{} + for rows.Next() { + var g pendingGroup + if err := rows.Scan(&g.id, &g.title, &g.reason); err != nil { + rows.Close() + return err + } + groups = append(groups, g) + } + err = rows.Err() + rows.Close() + if err != nil { + return err + } + for i := range groups { + groups[i].members, err = suggestionMembers(tx, groups[i].id, taskID) + if err != nil { + return err + } + } + want := map[int64]bool{} + for _, fid := range ids { + want[fid] = true + } + contains := func(group []int64, set map[int64]bool) bool { + for _, fid := range group { + if !set[fid] { + return false + } + } + return true + } + exact, superset := int64(0), int64(0) + supersede := []int64{} + for _, g := range groups { + gm := map[int64]bool{} + for _, fid := range g.members { + gm[fid] = true + } + switch { + case len(g.members) == len(ids) && contains(ids, gm) && contains(g.members, want): + if exact == 0 { + exact = g.id + } else { + supersede = append(supersede, g.id) + } + case len(g.members) > len(ids) && contains(ids, gm): + if superset == 0 { + superset = g.id + } + case len(g.members) < len(ids) && contains(g.members, want): + supersede = append(supersede, g.id) + } + } + for _, sid := range supersede { + if _, err := tx.Exec(`UPDATE finding_case_suggestions SET state='superseded' WHERE id=$1 AND state='pending'`, sid); err != nil { + return err + } + } + if exact > 0 { + suggestionID = exact + _, err = tx.Exec(`UPDATE finding_case_suggestions SET title=$2,reason=$3 WHERE id=$1`, exact, title, reason) + return err + } + if superset > 0 { + suggestionID = superset + return nil + } + if err := tx.QueryRow(`INSERT INTO finding_case_suggestions(task_id,left_id,right_id,title,reason) + VALUES($1,$2,$3,$4,$5) RETURNING id`, taskID, ids[0], ids[1], title, reason).Scan(&suggestionID); err != nil { + return err + } + for _, fid := range ids { + if _, err := tx.Exec(`INSERT INTO finding_case_suggestion_members(suggestion_id,finding_id) VALUES($1,$2) ON CONFLICT DO NOTHING`, suggestionID, fid); err != nil { + return err + } + } + return nil }) - return id, err + return suggestionID, err } + func (d *DB) FindingCaseSuggestions(taskID int64) ([]FindingCaseSuggestion, error) { - rows, err := d.Query(`SELECT id,task_id,left_id,right_id,title,reason,state FROM finding_case_suggestions WHERE state='pending' AND ($1::bigint=0 OR task_id=$1) ORDER BY id DESC`, taskID) + rows, err := d.Query(`SELECT id,task_id,left_id,right_id,title,reason,state + FROM finding_case_suggestions WHERE state='pending' AND ($1::bigint=0 OR task_id=$1) ORDER BY id DESC`, taskID) if err != nil { return nil, err } - defer rows.Close() - out := []FindingCaseSuggestion{} + items := []FindingCaseSuggestion{} for rows.Next() { var s FindingCaseSuggestion if err := rows.Scan(&s.ID, &s.TaskID, &s.LeftID, &s.RightID, &s.Title, &s.Reason, &s.State); err != nil { + rows.Close() return nil, err } + items = append(items, s) + } + err = rows.Err() + rows.Close() + if err != nil { + return nil, err + } + out := []FindingCaseSuggestion{} + for _, s := range items { + ids, err := suggestionMembers(d, s.ID, s.TaskID) + if err != nil { + return nil, err + } + if len(ids) < 2 { + continue + } + s.LeftID, s.RightID = ids[0], ids[1] + for _, fid := range ids { + s.FindingIDs = append(s.FindingIDs, strconv.FormatInt(fid, 10)) + } out = append(out, s) } - return out, rows.Err() + return out, nil } -func (d *DB) ResolveFindingCaseSuggestion(ctx context.Context, id int64, accept bool) (int64, error) { + +func (d *DB) ResolveFindingCaseSuggestion(ctx context.Context, id int64, accept bool, exclude []int64) (int64, error) { var s FindingCaseSuggestion - if err := d.QueryRow(`SELECT task_id,left_id,right_id,title,reason,state FROM finding_case_suggestions WHERE id=$1`, id).Scan(&s.TaskID, &s.LeftID, &s.RightID, &s.Title, &s.Reason, &s.State); err != nil { + if err := d.QueryRow(`SELECT task_id,title,reason,state FROM finding_case_suggestions WHERE id=$1`, id).Scan(&s.TaskID, &s.Title, &s.Reason, &s.State); err != nil { return 0, err } if s.State != "pending" { return 0, ErrFindingCaseConflict } + members, err := suggestionMembers(d, id, s.TaskID) + if err != nil { + return 0, err + } + if len(members) < 2 { + return 0, errors.New("疑似重复分组成员不足") + } + memberSet := map[int64]bool{} + for _, fid := range members { + memberSet[fid] = true + } + excludeSet := map[int64]bool{} + for _, fid := range exclude { + if !memberSet[fid] { + return 0, fmt.Errorf("排除记录 #%d 不属于该分组", fid) + } + excludeSet[fid] = true + } + kept := []int64{} + for _, fid := range members { + if !excludeSet[fid] { + kept = append(kept, fid) + } + } if accept { - return d.mergeFindingCase(ctx, s.TaskID, []int64{s.LeftID, s.RightID}, s.Title, s.Reason, "human", id) + if len(kept) < 2 { + return 0, errors.New("至少保留两条记录") + } + return d.mergeFindingCase(ctx, s.TaskID, kept, s.Title, s.Reason, "human", id, exclude) + } + if len(exclude) > 0 { + return 0, errors.New("否决分组时不能排除记录") } return 0, d.WithEvidenceTx(ctx, func(tx *sql.Tx) error { if err := lockCaseTask(tx, s.TaskID); err != nil { return err } - res, err := tx.Exec(`UPDATE finding_case_suggestions SET state='rejected' WHERE id=$1 AND state='pending'`, id) + var state string + if err := tx.QueryRow(`SELECT state FROM finding_case_suggestions WHERE id=$1 FOR UPDATE`, id).Scan(&state); err != nil { + return err + } + if state != "pending" { + return ErrFindingCaseConflict + } + members, err := suggestionMembers(tx, id, s.TaskID) if err != nil { return err } - if n, _ := res.RowsAffected(); n != 1 { - return ErrFindingCaseConflict + for i := 0; i < len(members); i++ { + for j := i + 1; j < len(members); j++ { + var leftCase, rightCase sql.NullInt64 + if err := tx.QueryRow(`SELECT m.case_id FROM finding_case_members m JOIN finding_cases c ON c.id=m.case_id AND c.active WHERE m.finding_id=$1`, members[i]).Scan(&leftCase); err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if err := tx.QueryRow(`SELECT m.case_id FROM finding_case_members m JOIN finding_cases c ON c.id=m.case_id AND c.active WHERE m.finding_id=$1`, members[j]).Scan(&rightCase); err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if leftCase.Valid && rightCase.Valid && leftCase.Int64 == rightCase.Int64 { + continue + } + 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`, members[i], members[j]); err != nil { + return err + } + } } - if _, err := tx.Exec(`INSERT INTO finding_case_blocks(left_id,right_id,reason) VALUES($1,$2,'人工否决') ON CONFLICT DO NOTHING`, s.LeftID, s.RightID); err != nil { + if _, err := tx.Exec(`UPDATE finding_case_suggestions SET state='rejected' WHERE id=$1`, id); err != nil { return err } - return nil + return sweepFindingCaseSuggestions(tx, s.TaskID) }) } @@ -479,13 +797,13 @@ func (d *DB) DistinctFindingStats(taskID string) (*FindingDistinctStats, error) } // Compact candidate pages exclude PoC/report bodies and do not treat severity as identity. -func (d *DB) FindingCaseCandidates(fid int64, page, size int, scope ...[]int64) ([]map[string]any, int, error) { +func (d *DB) FindingCaseCandidates(fid int64, page, size int, scope ...[]int64) ([]map[string]any, int, int, error) { f, err := d.FindingCaseSummary(fid) if err != nil { - return nil, 0, err + return nil, 0, 0, err } if f == nil || f.TaskID == nil { - return nil, 0, errors.New("漏洞任务不可用") + return nil, 0, 0, errors.New("漏洞任务不可用") } if page < 1 { page = 1 @@ -505,32 +823,58 @@ func (d *DB) FindingCaseCandidates(fid int64, page, size int, scope ...[]int64) if len(scope) > 0 { raw, err := json.Marshal(scope[0]) if err != nil { - return nil, 0, err + return nil, 0, 0, err } where += ` AND f.id IN (SELECT jsonb_array_elements_text($6::jsonb)::bigint)` args = append(args, string(raw)) } - var total int - if err := d.QueryRow(`SELECT count(*) FROM findings f`+where, args...).Scan(&total); err != nil { - return nil, 0, err + classHit := `(trim(f.vulnclass)<>'' AND lower(f.vulnclass)=lower($3))` + assetHit := `EXISTS(SELECT 1 FROM jsonb_array_elements(COALESCE(NULLIF(f.asset_ids,'null'::jsonb),'[]'::jsonb)) a WHERE a.value IN (SELECT value FROM jsonb_array_elements($5::jsonb)))` + nameHit := `(trim(f.name)<>'' AND lower(f.name)=lower($4))` + scoreExpr := fmt.Sprintf(`(CASE WHEN %s THEN 2 ELSE 0 END + CASE WHEN %s THEN 2 ELSE 0 END + CASE WHEN %s THEN 1 ELSE 0 END + CASE WHEN (%s AND %s) THEN 3 ELSE 0 END)`, classHit, assetHit, nameHit, classHit, assetHit) + strongExpr := fmt.Sprintf(`(%s AND %s)`, classHit, assetHit) + base := `WITH candidates AS (SELECT f.id,f.name,f.vulnclass,f.severity,left(f.summary,400) AS summary,f.asset_ids, + COALESCE(m.case_id,0) AS case_id, + ` + scoreExpr + ` AS score, + ` + strongExpr + ` AS strong, + ARRAY_REMOVE(ARRAY[ + CASE WHEN ` + classHit + ` THEN '同漏洞类型' END, + CASE WHEN ` + assetHit + ` THEN '资产重叠' END, + CASE WHEN ` + nameHit + ` THEN '同名称' END + ]::text[], NULL) AS hit_reasons + FROM findings f LEFT JOIN finding_case_members m ON m.finding_id=f.id` + where + `)` + var total, strongTotal int + if err := d.QueryRow(base+` SELECT count(*),count(*) FILTER(WHERE strong) FROM candidates`, args...).Scan(&total, &strongTotal); err != nil { + return nil, 0, 0, err } args = append(args, size, (page-1)*size) - rows, err := d.Query(`SELECT f.id,f.name,f.vulnclass,f.severity,left(f.summary,400),f.asset_ids,COALESCE(m.case_id,0) FROM findings f LEFT JOIN finding_case_members m ON m.finding_id=f.id`+where+fmt.Sprintf(` ORDER BY f.id DESC LIMIT $%d OFFSET $%d`, len(args)-1, len(args)), args...) + rows, err := d.Query(base+fmt.Sprintf(` SELECT id,name,vulnclass,severity,summary,asset_ids,case_id,score,strong,to_json(hit_reasons) + FROM candidates ORDER BY score DESC,id DESC LIMIT $%d OFFSET $%d`, len(args)-1, len(args)), args...) if err != nil { - return nil, 0, err + return nil, 0, 0, err } defer rows.Close() out := []map[string]any{} for rows.Next() { - var id, cid int64 + var id, cid, score int64 var name, class, severity, summary string var assetIDs json.RawMessage - if err := rows.Scan(&id, &name, &class, &severity, &summary, &assetIDs, &cid); err != nil { - return nil, 0, err + var strong bool + var hitReasonsRaw json.RawMessage + if err := rows.Scan(&id, &name, &class, &severity, &summary, &assetIDs, &cid, &score, &strong, &hitReasonsRaw); err != nil { + return nil, 0, 0, err } - out = append(out, map[string]any{"finding_id": strconv.FormatInt(id, 10), "name": name, "vulnclass": class, "severity": severity, "summary": summary, "asset_ids": assetIDs, "case_id": strconv.FormatInt(cid, 10)}) + var hitReasons []string + if err := json.Unmarshal(hitReasonsRaw, &hitReasons); err != nil { + return nil, 0, 0, err + } + tier := "weak" + if strong { + tier = "strong" + } + out = append(out, map[string]any{"finding_id": strconv.FormatInt(id, 10), "name": name, "vulnclass": class, "severity": severity, "summary": summary, "asset_ids": assetIDs, "case_id": strconv.FormatInt(cid, 10), "score": int(score), "hit_reasons": hitReasons, "tier": tier}) } - return out, total, rows.Err() + return out, total, strongTotal, rows.Err() } // Batch task-list counters avoid an additional database round trip per task. diff --git a/db/finding_cases_test.go b/db/finding_cases_test.go index 48398a5..5f4df1c 100644 --- a/db/finding_cases_test.go +++ b/db/finding_cases_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "reflect" + "strings" "sync" "testing" ) @@ -118,11 +119,14 @@ func TestFindingCaseScopeSuggestionsAndDeletion(t *testing.T) { if _, err := d.MergeFindingCase(ctx, task.ID, []int64{ids[0], fid}, "x", "x", "reporter"); err == nil { t.Fatal("cross task accepted") } - sid, err := d.SuggestFindingCase(ctx, task.ID, ids[0], ids[1], "order", "uncertain") + if _, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[0], fid}, "x", "x"); err == nil { + t.Fatal("cross task suggestion accepted") + } + sid, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[0], ids[1]}, "order", "uncertain") if err != nil { t.Fatal(err) } - if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, false); err != nil { + if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, false, nil); err != nil { t.Fatal(err) } if _, err := d.MergeFindingCase(ctx, task.ID, ids[:2], "x", "x", "reporter"); err == nil { @@ -233,7 +237,7 @@ func TestFindingCaseTaskDeletionAndRepeatedDetach(t *testing.T) { func TestFindingCaseSuggestionResolutionIsAtomic(t *testing.T) { d, task, ids := caseTestDB(t) ctx := context.Background() - sid, err := d.SuggestFindingCase(ctx, task.ID, ids[0], ids[1], "orders", "uncertain") + sid, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[0], ids[1]}, "orders", "uncertain") if err != nil { t.Fatal(err) } @@ -241,7 +245,7 @@ func TestFindingCaseSuggestionResolutionIsAtomic(t *testing.T) { var wg sync.WaitGroup for _, accept := range []bool{true, false} { wg.Add(1) - go func(a bool) { defer wg.Done(); _, e := d.ResolveFindingCaseSuggestion(ctx, sid, a); results <- e }(accept) + go func(a bool) { defer wg.Done(); _, e := d.ResolveFindingCaseSuggestion(ctx, sid, a, nil); results <- e }(accept) } wg.Wait() close(results) @@ -269,7 +273,7 @@ func TestFindingCaseCandidatesAreCompactAndRelated(t *testing.T) { if _, err := d.Exec(`UPDATE findings SET vulnclass='SQLi',name='search injection' WHERE id=$1`, ids[3]); err != nil { t.Fatal(err) } - items, total, err := d.FindingCaseCandidates(ids[0], 1, 1) + items, total, _, err := d.FindingCaseCandidates(ids[0], 1, 1) if err != nil || total != 2 || len(items) != 1 { t.Fatalf("candidate pagination %d %+v %v", total, items, err) } @@ -286,7 +290,7 @@ func TestFindingCaseCandidatesAreCompactAndRelated(t *testing.T) { if err := d.RemoveFindingCaseMember(context.Background(), cid, ids[1], "different cause"); err != nil { t.Fatal(err) } - _, total, err = d.FindingCaseCandidates(ids[0], 1, 20) + _, total, _, err = d.FindingCaseCandidates(ids[0], 1, 20) if err != nil || total != 1 { t.Fatalf("veto excluded %d %v", total, err) } @@ -355,3 +359,191 @@ func TestFindingCaseViewContextAndAssetFiltering(t *testing.T) { t.Fatalf("original severity order %+v %v", rows, err) } } + +func TestFindingCaseSuggestionClusterLifecycle(t *testing.T) { + d, task, ids := caseTestDB(t) + ctx := context.Background() + sid, err := d.SuggestFindingCase(ctx, task.ID, ids[:3], "orders", "same handler evidence") + if err != nil { + t.Fatal(err) + } + items, err := d.FindingCaseSuggestions(task.ID) + if err != nil || len(items) != 1 || len(items[0].FindingIDs) != 3 { + t.Fatalf("cluster listing %+v %v", items, err) + } + if reused, err := d.SuggestFindingCase(ctx, task.ID, ids[:2], "weaker", "subset"); err != nil || reused != sid { + t.Fatalf("pending superset was not reused: %d %v", reused, err) + } + if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, true, nil); err != nil { + t.Fatal(err) + } + var state string + if err := d.QueryRow(`SELECT state FROM finding_case_suggestions WHERE id=$1`, sid).Scan(&state); err != nil || state != "accepted" { + t.Fatalf("cluster state %q %v", state, err) + } + for _, fid := range ids[:3] { + if cid, err := d.FindingCaseID(fid); err != nil || cid == 0 { + t.Fatalf("member %d not merged: %d %v", fid, cid, err) + } + } +} + +func TestFindingCaseSuggestionSubsetSuperseded(t *testing.T) { + d, task, ids := caseTestDB(t) + ctx := context.Background() + subset, err := d.SuggestFindingCase(ctx, task.ID, ids[:2], "orders", "partial evidence") + if err != nil { + t.Fatal(err) + } + superset, err := d.SuggestFindingCase(ctx, task.ID, ids[:3], "orders", "complete group evidence") + if err != nil { + t.Fatal(err) + } + if subset == superset { + t.Fatal("subset suggestion was reused instead of superseded") + } + var state string + if err := d.QueryRow(`SELECT state FROM finding_case_suggestions WHERE id=$1`, subset).Scan(&state); err != nil || state != "superseded" { + t.Fatalf("subset state %q %v", state, err) + } +} + +func TestFindingCaseSuggestionAcceptExclusionAndReject(t *testing.T) { + d, task, ids := caseTestDB(t) + ctx := context.Background() + sid, err := d.SuggestFindingCase(ctx, task.ID, ids[:3], "orders", "same component") + if err != nil { + t.Fatal(err) + } + if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, true, []int64{ids[2]}); err != nil { + t.Fatal(err) + } + var blocks int + if err := d.QueryRow(`SELECT count(*) FROM finding_case_blocks WHERE left_id=$1 AND right_id=$2`, ids[0], ids[2]).Scan(&blocks); err != nil || blocks != 1 { + t.Fatalf("excluded member was not vetoed: %d %v", blocks, err) + } + if cid, _ := d.FindingCaseID(ids[2]); cid != 0 { + t.Fatalf("excluded member entered case %d", cid) + } +} + +func TestFindingCaseSuggestionRejectVetoesAllPairs(t *testing.T) { + d, task, ids := caseTestDB(t) + ctx := context.Background() + sid, err := d.SuggestFindingCase(ctx, task.ID, ids[:3], "orders", "same component") + if err != nil { + t.Fatal(err) + } + if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, false, nil); err != nil { + t.Fatal(err) + } + var count int + if err := d.QueryRow(`SELECT count(*) FROM finding_case_blocks WHERE left_id=ANY($1::bigint[]) AND right_id=ANY($1::bigint[])`, ids[:3]).Scan(&count); err != nil || count != 3 { + t.Fatalf("reject did not veto all pairs: %d %v", count, err) + } +} + +func TestFindingCaseSuggestionRejectSkipsExistingFolderAndBlockedSuggest(t *testing.T) { + d, task, ids := caseTestDB(t) + ctx := context.Background() + if _, err := d.MergeFindingCase(ctx, task.ID, ids[:2], "orders", "same handler", "human"); err != nil { + t.Fatal(err) + } + sid, err := d.SuggestFindingCase(ctx, task.ID, ids[:3], "orders", "same handler") + if err != nil { + t.Fatal(err) + } + if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, false, nil); err != nil { + t.Fatal(err) + } + var blocked int + if err := d.QueryRow(`SELECT count(*) FROM finding_case_blocks WHERE left_id=$1 AND right_id=$2`, ids[0], ids[1]).Scan(&blocked); err != nil || blocked != 0 { + t.Fatalf("same-folder pair was vetoed: %d %v", blocked, err) + } + if _, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[0], ids[2]}, "orders", "retry"); err == nil || !strings.Contains(err.Error(), fmt.Sprintf("#%d/#%d", ids[0], ids[2])) { + t.Fatalf("blocked pair was accepted or unnamed: %v", err) + } +} + +func TestFindingCaseSuggestionExclusionCannotPullExistingFolder(t *testing.T) { + d, task, ids := caseTestDB(t) + ctx := context.Background() + if _, err := d.MergeFindingCase(ctx, task.ID, ids[:2], "orders", "same handler", "human"); err != nil { + t.Fatal(err) + } + sid, err := d.SuggestFindingCase(ctx, task.ID, ids[:3], "orders", "same handler") + if err != nil { + t.Fatal(err) + } + if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, true, []int64{ids[1]}); err == nil { + t.Fatal("excluded member was pulled through its existing folder") + } + if cid, _ := d.FindingCaseID(ids[2]); cid != 0 { + t.Fatalf("failed exclusion merged remaining record into case %d", cid) + } + var state string + if err := d.QueryRow(`SELECT state FROM finding_case_suggestions WHERE id=$1`, sid).Scan(&state); err != nil || state != "pending" { + t.Fatalf("failed exclusion changed suggestion state: %q %v", state, err) + } +} + +func TestFindingCaseSuggestionAnchorDeletionAndLegacyPair(t *testing.T) { + d, task, ids := caseTestDB(t) + ctx := context.Background() + sid, err := d.SuggestFindingCase(ctx, task.ID, ids[:3], "orders", "same handler") + if err != nil { + t.Fatal(err) + } + if _, err := d.DeleteFinding(ids[0]); err != nil { + t.Fatal(err) + } + items, err := d.FindingCaseSuggestions(task.ID) + if err != nil || len(items) != 1 || items[0].ID != sid || items[0].LeftID != ids[1] || items[0].RightID != ids[2] { + t.Fatalf("anchor deletion lost group: %+v %v", items, err) + } + if got := items[0].FindingIDs; len(got) != 2 || got[0] != fmt.Sprint(ids[1]) || got[1] != fmt.Sprint(ids[2]) { + t.Fatalf("surviving members %+v", got) + } + if _, err := d.Exec(`DELETE FROM finding_case_suggestion_members WHERE suggestion_id=$1`, sid); err != nil { + t.Fatal(err) + } + if _, err := d.Exec(`UPDATE finding_case_suggestions SET left_id=$2,right_id=$3 WHERE id=$1`, sid, ids[1], ids[3]); err != nil { + t.Fatal(err) + } + legacy, err := d.FindingCaseSuggestions(task.ID) + if err != nil || len(legacy) != 1 || len(legacy[0].FindingIDs) != 2 { + t.Fatalf("legacy pair was not listed: %+v %v", legacy, err) + } + if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, true, nil); err != nil { + t.Fatal(err) + } +} + +func TestFindingCaseCandidatesScoreAndTier(t *testing.T) { + d, _, ids := caseTestDB(t) + if _, err := d.Exec(`UPDATE findings SET vulnclass='IDOR',name='order access',asset_ids='[101]'::jsonb WHERE id=$1`, ids[0]); err != nil { + t.Fatal(err) + } + if _, err := d.Exec(`UPDATE findings SET vulnclass='idor',name='different',asset_ids='[101]'::jsonb WHERE id=$1`, ids[1]); err != nil { + t.Fatal(err) + } + if _, err := d.Exec(`UPDATE findings SET vulnclass='SQLi',name='different',asset_ids='[101]'::jsonb WHERE id=$1`, ids[2]); err != nil { + t.Fatal(err) + } + if _, err := d.Exec(`UPDATE findings SET vulnclass='SQLi',name='order access',asset_ids='[]'::jsonb WHERE id=$1`, ids[3]); err != nil { + t.Fatal(err) + } + items, total, strong, err := d.FindingCaseCandidates(ids[0], 1, 20) + if err != nil || total != 3 || strong != 1 || len(items) != 3 { + t.Fatalf("candidate totals %d %d %+v %v", total, strong, items, err) + } + if items[0]["finding_id"] != fmt.Sprint(ids[1]) || items[0]["score"] != 7 || items[0]["tier"] != "strong" || !reflect.DeepEqual(items[0]["hit_reasons"], []string{"同漏洞类型", "资产重叠"}) { + t.Fatalf("strong candidate %+v", items[0]) + } + if items[1]["finding_id"] != fmt.Sprint(ids[2]) || items[1]["score"] != 2 || items[1]["tier"] != "weak" { + t.Fatalf("asset candidate %+v", items[1]) + } + if items[2]["finding_id"] != fmt.Sprint(ids[3]) || items[2]["score"] != 1 { + t.Fatalf("name candidate %+v", items[2]) + } +} diff --git a/db/schema.sql b/db/schema.sql index 1fe2311..737fadb 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1488,6 +1488,54 @@ CREATE TABLE IF NOT EXISTS finding_case_suggestions ( UNIQUE(left_id,right_id), CHECK(left_id < right_id) ); +-- A suggestion is a group. left_id/right_id remain compatibility anchors for +-- old clients and are kept at the two smallest surviving member ids. +ALTER TABLE finding_case_suggestions DROP CONSTRAINT IF EXISTS finding_case_suggestions_left_id_right_id_key; +CREATE TABLE IF NOT EXISTS finding_case_suggestion_members ( + suggestion_id BIGINT NOT NULL REFERENCES finding_case_suggestions(id) ON DELETE CASCADE, + finding_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE, + PRIMARY KEY (suggestion_id, finding_id) +); +CREATE INDEX IF NOT EXISTS idx_finding_case_suggestion_members_finding + ON finding_case_suggestion_members(finding_id); +INSERT INTO finding_case_suggestion_members(suggestion_id, finding_id) + SELECT id, left_id FROM finding_case_suggestions ON CONFLICT DO NOTHING; +INSERT INTO finding_case_suggestion_members(suggestion_id, finding_id) + SELECT id, right_id FROM finding_case_suggestions ON CONFLICT DO NOTHING; +-- Anchors have cascading foreign keys. Re-anchor a group before an anchor +-- finding is deleted so a 3+ member suggestion survives the cascade. +CREATE OR REPLACE FUNCTION finding_case_suggestion_reanchor() RETURNS trigger +LANGUAGE plpgsql AS $$ +DECLARE + sid BIGINT; + new_left BIGINT; + new_right BIGINT; +BEGIN + FOR sid IN + SELECT id FROM finding_case_suggestions + WHERE left_id = OLD.id OR right_id = OLD.id + LOOP + SELECT min(m.finding_id) INTO new_left + FROM finding_case_suggestion_members m + JOIN findings f ON f.id = m.finding_id + WHERE m.suggestion_id = sid AND m.finding_id <> OLD.id; + SELECT min(m.finding_id) INTO new_right + FROM finding_case_suggestion_members m + JOIN findings f ON f.id = m.finding_id + WHERE m.suggestion_id = sid AND m.finding_id <> OLD.id + AND m.finding_id > new_left; + IF new_left IS NOT NULL AND new_right IS NOT NULL THEN + UPDATE finding_case_suggestions + SET left_id = new_left, right_id = new_right + WHERE id = sid; + END IF; + END LOOP; + RETURN OLD; +END $$; +DROP TRIGGER IF EXISTS finding_case_suggestion_reanchor ON findings; +CREATE TRIGGER finding_case_suggestion_reanchor +BEFORE DELETE ON findings FOR EACH ROW +EXECUTE FUNCTION finding_case_suggestion_reanchor(); -- Rejected/detached pairs are a durable human veto, also checked on group unions. CREATE TABLE IF NOT EXISTS finding_case_blocks ( left_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE, diff --git a/server/finding_case_reviews.go b/server/finding_case_reviews.go index 16a9936..8716ce4 100644 --- a/server/finding_case_reviews.go +++ b/server/finding_case_reviews.go @@ -33,9 +33,9 @@ func (s *Server) toolCompleteFindingCaseReview() actool.CoreTool { decision := objSchema(map[string]any{ "finding_id": idParam("所选独立漏洞记录 ID"), "verdict": map[string]any{"type": "string", "enum": []string{"independent", "merged", "suggested"}}, - "reason": strParam("判断理由,接口或读写影响不同不能单独证明不同根因"), + "reason": strParam("判断理由,引用已证据化的共同点或不同代码路径;仅类型、标题或资产相同不足以证明共同根因"), "root_cause_evidence": strParam("原始报告/证据中支持实际共同或不同根因的具体材料;不确定则说明缺失证据及已保存建议")}, "finding_id", "verdict", "reason", "root_cause_evidence") - return wrTool("complete_finding_case_review", "历史整理结束前提交每条所选记录的根因判断并验收:必须已读取全部记录,疑似必须已有建议,归并必须已有完整最新统一报告。验收失败不能宣布完成。", objSchema(map[string]any{"summary": strParam("整理结果总结"), "decisions": map[string]any{"type": "array", "items": decision}}, "summary", "decisions"), func(ctx context.Context, raw json.RawMessage) (actool.Result, error) { + return wrTool("complete_finding_case_review", "历史整理结束前提交每条所选记录的根因判断并验收:必须已读取全部记录,疑似必须已有待确认分组建议;归并只需已在活动文件夹中,统一报告可稍后补齐。验收失败不能宣布完成。", objSchema(map[string]any{"summary": strParam("整理结果总结"), "decisions": map[string]any{"type": "array", "items": decision}}, "summary", "decisions"), func(ctx context.Context, raw json.RawMessage) (actool.Result, error) { var a findingReviewConclusion if err := json.Unmarshal(raw, &a); err != nil { return actool.Errorf(err.Error()), nil @@ -101,7 +101,9 @@ func (s *Server) saveFindingReviewConclusion(ctx context.Context, conv int64, a } case "suggested": var pending bool - if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_suggestions WHERE state='pending' AND (left_id=$1 OR right_id=$1))`, id).Scan(&pending); err != nil { + if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_suggestions s + JOIN finding_case_suggestion_members sm ON sm.suggestion_id=s.id + WHERE s.state='pending' AND sm.finding_id=$1)`, id).Scan(&pending); err != nil { return err } if !pending { @@ -115,17 +117,12 @@ func (s *Server) saveFindingReviewConclusion(ctx context.Context, conv int64, a return errors.New("无效判断类型") } if cid.Valid { - var report string - var version, rv int64 var active bool - if err := tx.QueryRow(`SELECT report,version,report_version,active FROM finding_cases WHERE id=$1 FOR SHARE`, cid.Int64).Scan(&report, &version, &rv, &active); err != nil { + if err := tx.QueryRow(`SELECT active FROM finding_cases WHERE id=$1 FOR SHARE`, cid.Int64).Scan(&active); err != nil { return err } - if !active || rv != version { - return fmt.Errorf("文件夹 #%d 的统一报告待更新,请重新读取后更新", cid.Int64) - } - if err := validateUnifiedFindingReport(report); err != nil { - return err + if !active { + return fmt.Errorf("文件夹 #%d 已关闭", cid.Int64) } } } diff --git a/server/finding_case_reviews_test.go b/server/finding_case_reviews_test.go index 06e1b58..5134a78 100644 --- a/server/finding_case_reviews_test.go +++ b/server/finding_case_reviews_test.go @@ -43,7 +43,7 @@ func TestUnifiedFindingReportRejectsIncompleteAndTruncated(t *testing.T) { } } -func TestFindingCaseReviewCompletionRequiresReadsAndFreshReport(t *testing.T) { +func TestFindingCaseReviewCompletionRequiresReadsNotFreshReport(t *testing.T) { s, task, ids := testCaseServer(t) runs, err := s.m.pg.CreateFindingCaseReviews(t.Context(), map[int64][]int64{task: ids}) if err != nil { @@ -71,13 +71,6 @@ func TestFindingCaseReviewCompletionRequiresReadsAndFreshReport(t *testing.T) { if err != nil { t.Fatal(err) } - if err := s.saveFindingReviewConclusion(ctx, conv, a); err == nil { - t.Fatal("missing unified report accepted") - } - c, _ := s.m.pg.GetFindingCase(cid) - if err := s.m.pg.UpdateFindingCaseReport(ctx, cid, c.Version, "orders", completeFindingReviewReport, "high", "verified impact"); err != nil { - t.Fatal(err) - } if err := s.saveFindingReviewConclusion(ctx, conv, a); err != nil { t.Fatal(err) } @@ -90,8 +83,8 @@ func TestFindingCaseReviewCompletionRequiresReadsAndFreshReport(t *testing.T) { if _, err := s.m.pg.Exec(`UPDATE findings SET evidence='new proof' WHERE id=$1`, ids[0]); err != nil { t.Fatal(err) } - if err := s.validateFindingReviewCompletion(conv); err == nil { - t.Fatal("stale group reported completed") + if err := s.validateFindingReviewCompletion(conv); err != nil { + t.Fatalf("stale unified report should not block merged conclusion: %v", err) } } @@ -138,7 +131,7 @@ func TestFindingCaseReviewSuggestionNeedsSavedSuggestion(t *testing.T) { if err := s.saveFindingReviewConclusion(ctx, conv, a); err == nil { t.Fatal("unpersisted suggestion accepted") } - if _, err := s.m.pg.SuggestFindingCase(ctx, task, ids[0], ids[1], "orders", "common component unknown"); err != nil { + if _, err := s.m.pg.SuggestFindingCase(ctx, task, []int64{ids[0], ids[1]}, "orders", "common component unknown"); err != nil { t.Fatal(err) } if err := s.saveFindingReviewConclusion(ctx, conv, a); err != nil { diff --git a/server/finding_cases.go b/server/finding_cases.go index d8afaf8..fa6bc33 100644 --- a/server/finding_cases.go +++ b/server/finding_cases.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "net/http" + "strconv" "strings" "github.com/Autumn-27/artex/db" @@ -203,12 +204,22 @@ func (s *Server) caseSuggestions(w http.ResponseWriter, r *http.Request) { } func (s *Server) resolveCaseSuggestion(w http.ResponseWriter, r *http.Request) { var a struct { - Accept bool `json:"accept"` + Accept bool `json:"accept"` + ExcludeIDs []string `json:"exclude_ids"` } if !decodeCaseRequest(w, r, &a) { return } - id, err := s.m.pg.ResolveFindingCaseSuggestion(r.Context(), int64(atoiDefault(r.PathValue("id"), 0)), a.Accept) + exclude := make([]int64, 0, len(a.ExcludeIDs)) + for _, raw := range a.ExcludeIDs { + id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil || id <= 0 { + caseHTTPError(w, errors.New("无效的排除记录编号")) + return + } + exclude = append(exclude, id) + } + id, err := s.m.pg.ResolveFindingCaseSuggestion(r.Context(), int64(atoiDefault(r.PathValue("id"), 0)), a.Accept, exclude) if err != nil { caseHTTPError(w, err) return @@ -253,10 +264,10 @@ func (s *Server) findingCaseTools() []actool.CoreTool { name, desc string required []string }{ - {"search_finding_duplicates", "只读检索同任务候选,返回摘要与资产;等级不同也可能重复。支持分页,按需读取详情。", []string{"finding_id"}}, + {"search_finding_duplicates", "只读检索同任务候选摘要与资产,按分数排序并返回 score、tier、hit_reasons:strong=同漏洞类型且资产重叠,weak=单一条件命中仅作参考。等级不同也可能重复。支持分页,按需读取详情。", []string{"finding_id"}}, {"get_finding_record", "读取独立漏洞完整原始证据和报告,返回探索节点编号以便追溯执行记录。", []string{"finding_id"}}, {"merge_finding_records", "证据明确证明同一缺陷时归并;保留所有原始记录。不跨任务、不改变原评级,不可绕过人工否决。", []string{"task_id", "finding_ids", "title", "reason"}}, - {"suggest_finding_merge", "证据不足时提交疑似重复建议,待人工确认;不是自动合并。只允许两条同任务记录。", []string{"task_id", "finding_ids", "title", "reason"}}, + {"suggest_finding_merge", "证据存在具体共同点但缺少关键证据时,提交一个疑似重复分组(2..20 条同任务记录,一次覆盖全部疑似成员,不要拆成两两建议),待人工确认;不是自动合并。仅类型/资产相同不应提交。", []string{"task_id", "finding_ids", "title", "reason"}}, {"get_finding_case", "读取归并组和成员分页。先记录version,再读取成员的原报告、证据及流量;版本变化必须重新读取。", []string{"case_id"}}, {"update_finding_case_report", "保存统一报告及评级理由,version必须来自实际读取。保留各条原报告,不新建漏洞。", []string{"case_id", "version", "title", "report", "severity", "severity_reason"}}, } @@ -312,8 +323,9 @@ func (s *Server) performFindingCaseTool(ctx context.Context, name string, a find if scope != nil { scopes = append(scopes, scope) } - items, total, err := s.m.pg.FindingCaseCandidates(fid, page, 20, scopes...) - return map[string]any{"items": items, "total": total, "page": page, "page_size": 20}, err + 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 case "get_finding_record": f, err := s.m.pg.GetFinding(fid) if err != nil { @@ -341,11 +353,11 @@ func (s *Server) performFindingCaseTool(ctx context.Context, name string, a find return map[string]string{"case_id": i64s(id)}, err case "suggest_finding_merge": ids := a.ids() - if len(ids) != 2 { - return nil, errors.New("疑似重复建议需要两条记录") + if len(ids) < 2 || len(ids) > 20 { + return nil, errors.New("疑似重复分组需要2至20条记录") } - id, err := s.m.pg.SuggestFindingCase(ctx, parseProfileID(a.TaskID), ids[0], ids[1], a.Title, a.Reason) - return map[string]string{"suggestion_id": i64s(id)}, err + id, err := s.m.pg.SuggestFindingCase(ctx, parseProfileID(a.TaskID), ids, a.Title, a.Reason) + return map[string]any{"suggestion_id": i64s(id), "finding_ids": ids}, err case "get_finding_case": c, err := s.m.pg.GetFindingCase(cid) if err != nil { diff --git a/web/src/components/finding-case-list.tsx b/web/src/components/finding-case-list.tsx index 105ce1d..8ad3a62 100644 --- a/web/src/components/finding-case-list.tsx +++ b/web/src/components/finding-case-list.tsx @@ -192,15 +192,19 @@ function FindingMergeSuggestionCard({ onResolve, }: { suggestion: FindingCaseSuggestion; - onResolve: (s: FindingCaseSuggestion, accept: boolean) => Promise; + onResolve: (s: FindingCaseSuggestion, accept: boolean, excludeIds?: string[]) => Promise; }) { 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 [excluded, setExcluded] = React.useState([]); + const included = ids.filter((id) => !excluded.includes(id)); async function resolve(accept: boolean) { if (busy) return; + if (accept && included.length < 2) return; setBusy(true); try { - await onResolve(s, accept); + await onResolve(s, accept, accept ? excluded : []); } finally { setBusy(false); } @@ -209,17 +213,30 @@ function FindingMergeSuggestionCard({
- 疑似重复 · 待确认 + 疑似重复分组 · {ids.length} 条 · 待确认 任务 #{s.task_id}
{s.title} - - - 查看原报告 #{s.left_id} - - - 查看原报告 #{s.right_id} - + + {ids.map((id) => ( + + + setExcluded((current) => + value === true + ? current.filter((item) => item !== id) + : [...current.filter((item) => item !== id), id], + ) + } + aria-label={`归并时包含上报 #${id}`} + /> + + 查看原报告 #{id} + + + ))}
@@ -238,11 +255,12 @@ function FindingMergeSuggestionCard({ -
@@ -271,21 +289,32 @@ export function FindingCaseReviewPanel({ taskId, onChange }: { taskId?: string; const [suggestions, setSuggestions] = React.useState([]); const [runs, setRuns] = React.useState([]); const [error, setError] = React.useState(""); + const [reload, setReload] = React.useState(0); + const requestSeq = React.useRef(0); + const taskKey = taskId ?? ""; + const taskKeyRef = React.useRef(taskKey); + taskKeyRef.current = 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()]); - if (active) { + if (active && seq === requestSeq.current && requestedTask === taskKeyRef.current) { setSuggestions(s); setRuns(r.filter((x) => !taskId || x.task_id === taskId)); setError(""); } } catch (e) { - if (active) setError((e as Error).message); + if (active && seq === requestSeq.current && requestedTask === taskKeyRef.current) + setError((e as Error).message); } finally { - if (active) timer = setTimeout(load, 5000); + if (active && seq === requestSeq.current && requestedTask === taskKeyRef.current) + timer = setTimeout(load, 5000); } } void load(); @@ -293,11 +322,15 @@ export function FindingCaseReviewPanel({ taskId, onChange }: { taskId?: string; active = false; clearTimeout(timer); }; - }, [taskId]); - async function resolve(s: FindingCaseSuggestion, accept: boolean) { + }, [reload, taskId, taskKey]); + async function resolve(s: FindingCaseSuggestion, accept: boolean, excludeIds?: string[]) { + const seq = ++requestSeq.current; + const requestedTask = taskKey; try { - const result = await api.resolveFindingCaseSuggestion(s.id, accept); + 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 { @@ -305,6 +338,7 @@ export function FindingCaseReviewPanel({ taskId, onChange }: { taskId?: string; } onChange?.(); } catch (e) { + if (seq === requestSeq.current && requestedTask === taskKeyRef.current) setReload((v) => v + 1); toast.error((e as Error).message); } } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index fedc913..1a41d47 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -645,8 +645,11 @@ export const api = { get( `/exploration/finding-case-suggestions${task ? `?task_id=${encodeURIComponent(task)}` : ""}`, ), - resolveFindingCaseSuggestion: (id: string, accept: boolean) => - post<{ case_id: string }>(`/exploration/finding-case-suggestions/${id}`, { accept }), + resolveFindingCaseSuggestion: (id: string, accept: boolean, excludeIds?: string[]) => + post<{ case_id: string }>(`/exploration/finding-case-suggestions/${id}`, { + accept, + ...(excludeIds && excludeIds.length > 0 ? { exclude_ids: excludeIds } : {}), + }), reviewFindingCases: (ids: string[]) => post<{ runs: { conversation_id: number; task_id: string }[] }>("/exploration/finding-case-review", { finding_ids: ids, diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index 2f15a69..0250d64 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -2467,7 +2467,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re const from = q.get("from") || ""; const to = q.get("to") || ""; if (status && !["pending", "allowed", "denied", "timeout"].includes(status)) throw new Error("无效审批状态"); - if (decisionSource && !["model", "rule", "manual", "unknown"].includes(decisionSource)) throw new Error("无效判定来源"); + if (decisionSource && !["model", "rule", "manual", "unknown"].includes(decisionSource)) + throw new Error("无效判定来源"); if ([...search].length > 200) throw new Error("搜索关键词不能超过 200 个字符"); if (search.startsWith("#") && !/^#[1-9]\d*$/.test(search)) throw new Error("审批编号必须为 # 后接正整数"); for (const value of [from, to]) { diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 54e26a6..85cffe4 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -1734,6 +1734,7 @@ export interface FindingCaseSuggestion { task_id: string; left_id: string; right_id: string; + finding_ids: string[]; title: string; reason: string; state: string; From 5b342f9f78aa90778f4b6da7e9af84e7ab9f434f Mon Sep 17 00:00:00 2001 From: RuoJi6 Date: Sat, 24 Oct 2026 22:47:16 +0800 Subject: [PATCH 5/7] fix(findings): address Grok review r1 (Codex gpt-6-astra) Authored by Codex CLI (gpt-6-astra, reasoning high) after 3 gpt-6.1-sol capacity failures. Scope sweep aggregates per suggestion, veto only kept suggestion members on exclusion, refresh finding-case tool schemas on existing DBs, clarify weak tier wording, keep card state across reloads, filter stale exclusions, always reload after confirm, guard task-switch toasts. --- agent/finding_cases.go | 2 +- db/finding_cases.go | 18 ++++++--- db/finding_cases_test.go | 51 ++++++++++++++++++++++++ server/finding_cases.go | 10 +++-- server/orchestration.go | 2 +- web/src/components/finding-case-list.tsx | 50 ++++++++++++++++------- 6 files changed, 109 insertions(+), 24 deletions(-) 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 ( From 54f43fcd3f2bfff1dd7897e971771467f772c66f Mon Sep 17 00:00:00 2001 From: RuoJi6 Date: Sat, 24 Oct 2026 23:17:26 +0800 Subject: [PATCH 6/7] fix(findings): address Grok review r2 server batch (Codex gpt-6.1-sol) Authored by Codex CLI (gpt-6.1-sol, reasoning high, first attempt). Historical review task message follows the new suggest rule and treats the unified report as best-effort; v3 tool refresh no longer re-adds a removed reporter binding. --- server/finding_case_reviews.go | 2 +- server/finding_case_reviews_test.go | 37 +++++++++- server/finding_cases.go | 18 ++++- server/finding_cases_test.go | 109 ++++++++++++++++++++++++++++ 4 files changed, 162 insertions(+), 4 deletions(-) diff --git a/server/finding_case_reviews.go b/server/finding_case_reviews.go index 8716ce4..e0ea191 100644 --- a/server/finding_case_reviews.go +++ b/server/finding_case_reviews.go @@ -34,7 +34,7 @@ func (s *Server) toolCompleteFindingCaseReview() actool.CoreTool { "finding_id": idParam("所选独立漏洞记录 ID"), "verdict": map[string]any{"type": "string", "enum": []string{"independent", "merged", "suggested"}}, "reason": strParam("判断理由,引用已证据化的共同点或不同代码路径;仅类型、标题或资产相同不足以证明共同根因"), - "root_cause_evidence": strParam("原始报告/证据中支持实际共同或不同根因的具体材料;不确定则说明缺失证据及已保存建议")}, "finding_id", "verdict", "reason", "root_cause_evidence") + "root_cause_evidence": strParam("原始报告/证据中支持实际共同或不同根因的具体材料;suggested 须说明具体共同点、缺失的关键证据及已保存分组;没有具体共同点应判 independent")}, "finding_id", "verdict", "reason", "root_cause_evidence") return wrTool("complete_finding_case_review", "历史整理结束前提交每条所选记录的根因判断并验收:必须已读取全部记录,疑似必须已有待确认分组建议;归并只需已在活动文件夹中,统一报告可稍后补齐。验收失败不能宣布完成。", objSchema(map[string]any{"summary": strParam("整理结果总结"), "decisions": map[string]any{"type": "array", "items": decision}}, "summary", "decisions"), func(ctx context.Context, raw json.RawMessage) (actool.Result, error) { var a findingReviewConclusion if err := json.Unmarshal(raw, &a); err != nil { diff --git a/server/finding_case_reviews_test.go b/server/finding_case_reviews_test.go index 5134a78..2b15441 100644 --- a/server/finding_case_reviews_test.go +++ b/server/finding_case_reviews_test.go @@ -4,11 +4,46 @@ import ( "context" "encoding/json" "fmt" - "github.com/Autumn-27/artex/intercept" "strings" "testing" + + "github.com/Autumn-27/artex/intercept" ) +func TestFindingCaseReviewTaskMessagePolicy(t *testing.T) { + message := fmt.Sprintf(findingCaseReviewTaskMessage, `[101,102,103]`) + for _, want := range []string{ + "finding_ids=[101,102,103]", + "根因明确相同则直接归并", + "相同端点、处理器/后端组件、参数或缺失检查", + "缺少确认共同根因的关键证据", + "一个覆盖2..20条同任务疑似成员的分组", + "不要拆成两两建议", + "仅类型或资产相同不足以建议", + "没有具体共同点时保持 independent", + "不同代码路径时可判 independent", + "尽力更新统一报告", + "统一报告暂缺或待更新不阻塞 complete_finding_case_review", + "不归并未选记录", + "不探测目标", + "遵守人工否决", + } { + if !strings.Contains(message, want) { + t.Errorf("task message missing policy: %s", want) + } + } + if strings.Contains(message, "不确定提交建议") { + t.Fatal("task message suggests solely because of uncertainty") + } + schema, err := json.Marshal((&Server{}).toolCompleteFindingCaseReview().InputSchema()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(schema), "没有具体共同点应判 independent") || strings.Contains(string(schema), "不确定则说明缺失证据及已保存建议") { + t.Fatal("completion schema must not require suggestions solely because of uncertainty") + } +} + const completeFindingReviewReport = `# 同一权限缺陷 ## 概述 同一对象权限检查缺失。 diff --git a/server/finding_cases.go b/server/finding_cases.go index 46200cc..aecc436 100644 --- a/server/finding_cases.go +++ b/server/finding_cases.go @@ -397,6 +397,8 @@ func (s *Server) seedFindingCaseTools() { } names := []string{} previous, _, _ := s.m.pg.GetSetting("finding_case_tools_v1") + // v3 refreshes descriptions and schemas without repeating the v2 binding migration. + previousReview, _, _ := s.m.pg.GetSetting("finding_case_tools_v2_review") for _, t := range s.findingCaseTools() { schema, _ := json.Marshal(t.InputSchema()) if err := s.m.pg.RefreshToolDefaults(t.Name(), t.Description(), schema); err != nil { @@ -405,15 +407,27 @@ func (s *Server) seedFindingCaseTools() { if err := s.m.pg.SeedTool(t.Name(), t.Description(), schema, json.RawMessage(`["reporter"]`)); err != nil { return } - if previous != "true" || t.Name() == "complete_finding_case_review" { + needsBinding := previous != "true" + if t.Name() == "complete_finding_case_review" { + needsBinding = previousReview != "true" + } + if needsBinding { names = append(names, t.Name()) } } if err := s.m.pg.AddAgentToToolBinding("reporter", names); err != nil { return } + if previousReview != "true" { + if err := s.m.pg.SetSetting("finding_case_tools_v2_review", "true"); err != nil { + return + } + } _ = s.m.pg.SetSetting(flag, "true") } + +const findingCaseReviewTaskMessage = "人工选择整理(只读原始记录):本任务所选 finding_ids=%s。逐条读取已有报告,仅判断同一实际根因;根因明确相同则直接归并。只有证据指向相同端点、处理器/后端组件、参数或缺失检查等具体共同点,但缺少确认共同根因的关键证据时,才提交一个覆盖2..20条同任务疑似成员的分组,不要拆成两两建议;仅类型或资产相同不足以建议,没有具体共同点时保持 independent,不要因为不确定就提交建议。证据表明属于不同代码路径时可判 independent。已有或新建组应尽力更新统一报告;统一报告暂缺或待更新不阻塞 complete_finding_case_review,可由后续运行或人工补齐。禁止生成或覆盖原始报告、改评级状态、补绑证据,不归并未选记录,不探测目标,遵守人工否决。结束前调用 complete_finding_case_review 提交逐条判断并通过验收。" + func (s *Server) reviewFindingCases(w http.ResponseWriter, r *http.Request) { var a findingCaseRequest if !decodeCaseRequest(w, r, &a) { @@ -479,7 +493,7 @@ func (s *Server) reviewFindingCases(w http.ResponseWriter, r *http.Request) { taskID, fids := reservation.TaskID, reservation.FindingIDs taskContext := taskContexts[taskID] raw, _ := json.Marshal(fids) - msg := fmt.Sprintf("人工选择整理(只读原始记录):本任务所选 finding_ids=%s。逐条读取已有报告,仅判断同一实际根因,明确则归并,不确定提交建议;已有组更新统一报告。禁止生成或覆盖原始报告、改评级状态、补绑证据,不归并未选记录,不探测目标。结束前调用 complete_finding_case_review 提交逐条判断并通过验收。", raw) + msg := fmt.Sprintf(findingCaseReviewTaskMessage, raw) item := triggeredRun{agentKey: "reporter", title: "漏洞整理 · task#" + i64s(taskID), message: msg, taskID: taskID, taskDesc: taskContext.taskDesc, taskGoal: taskContext.taskGoal, conversationID: reservation.ConversationID} cfg := s.readTriggerBehavior("reporter") s.queueMu.Lock() diff --git a/server/finding_cases_test.go b/server/finding_cases_test.go index 09037d8..d02917b 100644 --- a/server/finding_cases_test.go +++ b/server/finding_cases_test.go @@ -42,6 +42,115 @@ func testCaseServer(t *testing.T) (*Server, int64, []int64) { t.Cleanup(func() { _, _ = pg.Exec(`DELETE FROM findings WHERE task_id=$1`, task.ID); _ = pg.DeleteTask(task.ID) }) return &Server{m: &Manager{pg: pg}}, task.ID, ids } + +func TestFindingCaseToolMigrationPreservesBindings(t *testing.T) { + s, _, _ := testCaseServer(t) + pg := s.m.pg + for _, flag := range []string{"finding_case_tools_v1", "finding_case_tools_v2_review", "finding_case_tools_v3_review"} { + value, exists, err := pg.GetSetting(flag) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if exists { + if err := pg.SetSetting(flag, value); err != nil { + t.Error(err) + } + } else if _, err := pg.Exec(`DELETE FROM settings WHERE key=$1`, flag); err != nil { + t.Error(err) + } + }) + } + for _, tool := range s.findingCaseTools() { + key := tool.Name() + old, err := pg.GetTool(key) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if old == nil { + if _, err := pg.Exec(`DELETE FROM tools WHERE key=$1`, key); err != nil { + t.Error(err) + } + return + } + bindings, _ := json.Marshal(old.Agents) + if err := pg.UpdateTool(key, old.Description, old.Schema, bindings, old.Enabled); err != nil { + t.Error(err) + } + }) + if err := pg.SeedTool(key, tool.Description(), nil, nil); err != nil { + t.Fatal(err) + } + } + for _, tc := range []struct { + name string + v1, v2 bool + }{ + {name: "initial"}, + {name: "v1_to_v3", v1: true}, + {name: "v2_to_v3", v1: true, v2: true}, + {name: "v2_without_v1_flag", v2: true}, + } { + t.Run(tc.name, func(t *testing.T) { + for flag, value := range map[string]bool{ + "finding_case_tools_v1": tc.v1, + "finding_case_tools_v2_review": tc.v2, + "finding_case_tools_v3_review": false, + } { + if value { + if err := pg.SetSetting(flag, "true"); err != nil { + t.Fatal(err) + } + } else if _, err := pg.Exec(`DELETE FROM settings WHERE key=$1`, flag); err != nil { + t.Fatal(err) + } + } + for _, tool := range s.findingCaseTools() { + if err := pg.UpdateTool(tool.Name(), "old description", json.RawMessage(`{}`), json.RawMessage(`["custom-agent"]`), false); err != nil { + t.Fatal(err) + } + } + s.seedFindingCaseTools() + for _, tool := range s.findingCaseTools() { + row, err := pg.GetTool(tool.Name()) + if err != nil || row == nil { + t.Fatal("missing tool", tool.Name(), err) + } + wantReporter := !tc.v1 + if tool.Name() == "complete_finding_case_review" { + wantReporter = !tc.v2 + } + if contains(row.Agents, "reporter") != wantReporter || !contains(row.Agents, "custom-agent") || row.Enabled { + t.Fatalf("binding/enabled configuration changed incorrectly: %+v", row) + } + if row.Description != tool.Description() || len(row.Schema) <= 2 { + t.Fatalf("description/schema not refreshed: %+v", row) + } + } + for _, flag := range []string{"finding_case_tools_v2_review", "finding_case_tools_v3_review"} { + if value, _, err := pg.GetSetting(flag); err != nil || value != "true" { + t.Fatalf("migration flag %s not saved: %q, %v", flag, value, err) + } + } + if err := pg.RemoveAgentFromTool("reporter", "complete_finding_case_review"); err != nil { + t.Fatal(err) + } + // A refresh retry must not replay a completed legacy binding migration. + if tc.v1 { + if err := pg.SetSetting("finding_case_tools_v3_review", "false"); err != nil { + t.Fatal(err) + } + } + s.seedFindingCaseTools() + row, err := pg.GetTool("complete_finding_case_review") + if err != nil || row == nil || contains(row.Agents, "reporter") { + t.Fatal("refresh restored an operator-removed binding", row, err) + } + }) + } +} + func TestFindingCaseAPIPaginationAndTools(t *testing.T) { s, task, ids := testCaseServer(t) ctx := context.Background() From a108044c010d5b4e7c515eee9a6c4b4a63f0484c Mon Sep 17 00:00:00 2001 From: RuoJi6 Date: Sat, 24 Oct 2026 23:37:58 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix(findings):=20address=20Grok=20review=20?= =?UTF-8?q?r3=20=E2=80=94=20preserve=20tool=20bindings=20on=20v3=20refresh?= =?UTF-8?q?=20(Codex=20gpt-6.1-sol)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authored by Codex CLI (gpt-6.1-sol, reasoning high, first attempt). --- server/finding_cases.go | 15 +++++++------- server/finding_cases_test.go | 38 ++++++++++++++++++++---------------- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/server/finding_cases.go b/server/finding_cases.go index aecc436..c4466e6 100644 --- a/server/finding_cases.go +++ b/server/finding_cases.go @@ -404,21 +404,20 @@ func (s *Server) seedFindingCaseTools() { 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 previousReview == "true" { + continue + } if err := s.m.pg.SeedTool(t.Name(), t.Description(), schema, json.RawMessage(`["reporter"]`)); err != nil { return } - needsBinding := previous != "true" - if t.Name() == "complete_finding_case_review" { - needsBinding = previousReview != "true" - } - if needsBinding { + if previous != "true" || t.Name() == "complete_finding_case_review" { names = append(names, t.Name()) } } - if err := s.m.pg.AddAgentToToolBinding("reporter", names); err != nil { - return - } if previousReview != "true" { + if err := s.m.pg.AddAgentToToolBinding("reporter", names); err != nil { + return + } if err := s.m.pg.SetSetting("finding_case_tools_v2_review", "true"); err != nil { return } diff --git a/server/finding_cases_test.go b/server/finding_cases_test.go index d02917b..23be7b7 100644 --- a/server/finding_cases_test.go +++ b/server/finding_cases_test.go @@ -84,9 +84,10 @@ func TestFindingCaseToolMigrationPreservesBindings(t *testing.T) { } } for _, tc := range []struct { - name string - v1, v2 bool + name string + v1, v2, fresh bool }{ + {name: "fresh", fresh: true}, {name: "initial"}, {name: "v1_to_v3", v1: true}, {name: "v2_to_v3", v1: true, v2: true}, @@ -107,7 +108,11 @@ func TestFindingCaseToolMigrationPreservesBindings(t *testing.T) { } } for _, tool := range s.findingCaseTools() { - if err := pg.UpdateTool(tool.Name(), "old description", json.RawMessage(`{}`), json.RawMessage(`["custom-agent"]`), false); err != nil { + if tc.fresh { + if _, err := pg.Exec(`DELETE FROM tools WHERE key=$1`, tool.Name()); err != nil { + t.Fatal(err) + } + } else if err := pg.UpdateTool(tool.Name(), "old description", json.RawMessage(`{}`), json.RawMessage(`["custom-agent"]`), false); err != nil { t.Fatal(err) } } @@ -117,11 +122,8 @@ func TestFindingCaseToolMigrationPreservesBindings(t *testing.T) { if err != nil || row == nil { t.Fatal("missing tool", tool.Name(), err) } - wantReporter := !tc.v1 - if tool.Name() == "complete_finding_case_review" { - wantReporter = !tc.v2 - } - if contains(row.Agents, "reporter") != wantReporter || !contains(row.Agents, "custom-agent") || row.Enabled { + wantReporter := !tc.v2 && (!tc.v1 || tool.Name() == "complete_finding_case_review") + if contains(row.Agents, "reporter") != wantReporter || contains(row.Agents, "custom-agent") == tc.fresh || row.Enabled != tc.fresh { t.Fatalf("binding/enabled configuration changed incorrectly: %+v", row) } if row.Description != tool.Description() || len(row.Schema) <= 2 { @@ -133,19 +135,21 @@ func TestFindingCaseToolMigrationPreservesBindings(t *testing.T) { t.Fatalf("migration flag %s not saved: %q, %v", flag, value, err) } } - if err := pg.RemoveAgentFromTool("reporter", "complete_finding_case_review"); err != nil { - t.Fatal(err) - } - // A refresh retry must not replay a completed legacy binding migration. - if tc.v1 { - if err := pg.SetSetting("finding_case_tools_v3_review", "false"); err != nil { + for _, tool := range s.findingCaseTools() { + if err := pg.RemoveAgentFromTool("reporter", tool.Name()); err != nil { t.Fatal(err) } } + // A refresh retry must not replay a completed legacy binding migration. + if err := pg.SetSetting("finding_case_tools_v3_review", "false"); err != nil { + t.Fatal(err) + } s.seedFindingCaseTools() - row, err := pg.GetTool("complete_finding_case_review") - if err != nil || row == nil || contains(row.Agents, "reporter") { - t.Fatal("refresh restored an operator-removed binding", row, err) + for _, tool := range s.findingCaseTools() { + row, err := pg.GetTool(tool.Name()) + if err != nil || row == nil || contains(row.Agents, "reporter") { + t.Fatal("refresh restored an operator-removed binding", row, err) + } } }) }