mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 09:13:49 +08:00
revert(findings): reset dedup files to origin/main before Codex rewrite
Pure git restore (git checkout origin/main -- <files>) so the scheme 1+2+3 implementation can be re-authored entirely by Codex. No hand-written code.
This commit is contained in:
1 parent
0c346a7c0a
commit
b0d6ad5d76
11 files changed
+108
-649
No files matched your search
@@ -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,并提供总结。根因证据应说明具体相同/不同的检查及证据来源;疑似建议明确待补证据。服务端验收通过后才可宣布完成。工具不可用或验收失败时如实报告,不能只用自然语言声称完成。`
|
||||
@@ -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)
|
||||
|
||||
+46
-294
@@ -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<b.finding_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)`
|
||||
|
||||
// sweepFindingCaseSuggestions resolves pending suggestions after a merge or veto:
|
||||
// groups fully inside a merged folder are accepted and groups containing a vetoed
|
||||
// pair become obsolete, so one confirmation clears all related cards.
|
||||
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(DISTINCT m.case_id) FROM finding_case_suggestion_members sm JOIN finding_case_members m ON m.finding_id=sm.finding_id WHERE sm.suggestion_id=s.id)=1
|
||||
AND NOT EXISTS(SELECT 1 FROM finding_case_suggestion_members sm LEFT JOIN finding_case_members m ON m.finding_id=sm.finding_id WHERE sm.suggestion_id=s.id AND m.case_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 (`+pendingSuggestionHasBlockedPair+`
|
||||
OR (SELECT count(*) FROM finding_case_suggestion_members sm WHERE sm.suggestion_id=s.id)<2)`, taskID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SuggestFindingCase stores one pending group (2..20 same-task findings) for human
|
||||
// confirmation. An identical pending group is refreshed, a pending superset is
|
||||
// reused, and pending subsets are superseded by the new group.
|
||||
func (d *DB) SuggestFindingCase(ctx context.Context, taskID int64, findingIDs []int64, title, reason string) (int64, error) {
|
||||
func (d *DB) SuggestFindingCase(ctx context.Context, taskID, left, right int64, title, reason string) (int64, error) {
|
||||
if strings.TrimSpace(title) == "" || strings.TrimSpace(reason) == "" {
|
||||
return 0, errors.New("建议需要名称和具体证据理由")
|
||||
}
|
||||
ids, err := sortedFindingIDs(findingIDs)
|
||||
ids, err := sortedFindingIDs([]int64{left, right})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(ids) > 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.
|
||||
|
||||
+6
-213
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+10
-22
@@ -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 {
|
||||
|
||||
@@ -192,18 +192,15 @@ function FindingMergeSuggestionCard({
|
||||
onResolve,
|
||||
}: {
|
||||
suggestion: FindingCaseSuggestion;
|
||||
onResolve: (s: FindingCaseSuggestion, accept: boolean, excludeIds?: string[]) => Promise<void>;
|
||||
onResolve: (s: FindingCaseSuggestion, accept: boolean) => Promise<void>;
|
||||
}) {
|
||||
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<string[]>([]);
|
||||
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({
|
||||
<Card size="sm" className="min-w-0">
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">疑似重复分组 · {ids.length} 条 · 待确认</Badge>
|
||||
<Badge variant="outline">疑似重复 · 待确认</Badge>
|
||||
<CardDescription>任务 #{s.task_id}</CardDescription>
|
||||
</div>
|
||||
<CardTitle className="break-words">{s.title}</CardTitle>
|
||||
<CardDescription className="flex flex-col gap-1">
|
||||
{ids.map((id) => (
|
||||
<span key={id} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={!excluded.includes(id)}
|
||||
disabled={busy}
|
||||
onCheckedChange={(v) =>
|
||||
setExcluded((old) =>
|
||||
v === true ? old.filter((x) => x !== id) : [...old.filter((x) => x !== id), id],
|
||||
)
|
||||
}
|
||||
aria-label={`归并时包含上报 #${id}`}
|
||||
/>
|
||||
<Link
|
||||
className={cn(
|
||||
"underline underline-offset-4",
|
||||
excluded.includes(id) && "text-muted-foreground line-through",
|
||||
)}
|
||||
href={`/function/findings/detail/?id=${id}`}
|
||||
>
|
||||
查看原报告 #{id}
|
||||
</Link>
|
||||
</span>
|
||||
))}
|
||||
{excluded.length > 0 ? (
|
||||
<span className="text-xs">已排除 {excluded.length} 条:确认后这些记录不会再与本组建议归并</span>
|
||||
) : null}
|
||||
<CardDescription className="flex flex-wrap gap-3">
|
||||
<Link className="underline underline-offset-4" href={`/function/findings/detail/?id=${s.left_id}`}>
|
||||
查看原报告 #{s.left_id}
|
||||
</Link>
|
||||
<Link className="underline underline-offset-4" href={`/function/findings/detail/?id=${s.right_id}`}>
|
||||
查看原报告 #{s.right_id}
|
||||
</Link>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -261,12 +238,11 @@ function FindingMergeSuggestionCard({
|
||||
</Collapsible>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" disabled={busy || included.length < 2} onClick={() => resolve(true)}>
|
||||
{busy ? <Spinner /> : null}
|
||||
{included.length === ids.length ? "整组确认归并" : `确认归并所选(${included.length} 条)`}
|
||||
<Button variant="outline" size="sm" disabled={busy} onClick={() => resolve(true)}>
|
||||
{busy ? <Spinner /> : null}确认归并
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => resolve(false)}>
|
||||
整组不是同一漏洞
|
||||
不是同一漏洞
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
@@ -295,20 +271,14 @@ export function FindingCaseReviewPanel({ taskId, onChange }: { taskId?: string;
|
||||
const [suggestions, setSuggestions] = React.useState<FindingCaseSuggestion[]>([]);
|
||||
const [runs, setRuns] = React.useState<FindingCaseReviewRun[]>([]);
|
||||
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<typeof setTimeout>;
|
||||
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 {
|
||||
|
||||
+2
-5
@@ -645,11 +645,8 @@ export const api = {
|
||||
get<FindingCaseSuggestion[]>(
|
||||
`/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,
|
||||
|
||||
@@ -1737,7 +1737,6 @@ export interface FindingCaseSuggestion {
|
||||
title: string;
|
||||
reason: string;
|
||||
state: string;
|
||||
finding_ids: string[];
|
||||
}
|
||||
export interface FindingCaseReviewRun {
|
||||
conversation_id: number;
|
||||
|
||||
Reference in new issue
Block a user