mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 01:03:50 +08:00
Merge pull request #48 from RuoJi6/feat/finding-dedup-1-2-3
feat(findings): 降低重复确认噪音——分组疑似重复建议、候选打分排序、放宽归并验收(方案1+2+3)
This commit is contained in:
14 files changed
+1000
-129
No files matched your search
@@ -45,13 +45,13 @@ func findingCaseWorkflow(key string, tools []actool.CoreTool) ([]actool.CoreTool
|
|||||||
}
|
}
|
||||||
return tools, `
|
return tools, `
|
||||||
|
|
||||||
【同一漏洞的多次上报整理】保留本次独立报告,不覆盖其他原报告。默认报告完成即停止的旧指引在此扩展:先保存本次报告,然后 search_finding_duplicates(finding_id=独立记录ID) 分页读取同任务候选摘要,按需 get_finding_record 查看证据。仅凭漏洞类型/标题相似或高中低等级不能归并;须核对目标、接口、参数、权限边界、根因及实际验证证据。
|
【同一漏洞的多次上报整理】保留本次独立报告,不覆盖其他原报告。默认报告完成即停止的旧指引在此扩展:先保存本次报告,然后 search_finding_duplicates(finding_id=独立记录ID) 分页读取同任务候选摘要(按 score 排序;strong=同漏洞类型且资产重叠,优先核对;weak=未同时具备同漏洞类型和资产重叠,结合 hit_reasons 核对,仅作参考),按需 get_finding_record 查看证据。仅凭漏洞类型、标题、令牌、登录入口相似或高中低等级不能归并;须核对目标、接口、参数、权限边界、根因及实际验证证据。只能处理同一任务,尊重人工否决和 finding_case_blocks。
|
||||||
【判断准则】接口、路径、后端、读/写影响、评级不同,都不能单独作为不重复的理由;同一登录入口、同一令牌或同一漏洞类型也不能单独证明同一根因。比较实际缺失的鉴权检查、对象权限边界、会话门或共同组件,并引用每条记录中的具体证据。读和写可能是同一权限检查缺失的不同影响;也可能是独立 handler 的独立缺陷。只有证据证明共同实际根因才自动归并,证据证明不同根因才判独立;存在关联但无法确认时必须 suggest_finding_merge,说明已知共同点、缺失证据和待确认问题,不用“路径不同/影响不同”代替根因判断。明确同一缺陷时 merge_finding_records(task_id,finding_ids,title,reason),不确定时 suggest_finding_merge 留待人工确认,不能强行归并。不重复上报、不删除记录、不重新探测目标。人工选择整理时只能判断所选记录,不能加入未选记录(既有组成员可作为报告上下文)。
|
【判断准则】若证据表明端点/路由、处理器或后端组件、易受影响参数,或缺失检查属于不同代码路径,可以据此判定 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保存完整统一报告(包含有内容的概述、影响、前置条件、复现步骤、证据、PoC、根因分析、修复建议章节,不得半篇截断)和基于已验证影响的severity及severity_reason;不能直接取成员最高等级。集中给出前置条件、完整执行顺序、输入输出如何衔接、可运行的完整脚本及命令,不把必要步骤分散到原报告;缺失条件、未验证步骤如实标明。版本冲突重新读取材料并生成,不能仅换version重试。各成员仍有独立报告、评级和证据。没有明确重复则保留独立记录。工具被关闭时不绕过配置。`
|
归并成功或已有case_id时,get_finding_case 记录实际version,分页读取全部成员,按需 get_finding_record、get_finding_traffic 读取原报告、PoC及已保存流量。根因明确时归并不被统一报告阻塞,应随后尽力用 update_finding_case_report 保存统一报告;如果本次无法完成,文件夹显示“统一报告待更新”,由后续 reporter 运行或人工补齐。统一报告在保存时仍须包含有内容的概述、影响、前置条件、复现步骤、证据、PoC、根因分析、修复建议章节,不得半篇截断;评级及 severity_reason 必须基于已验证影响,不能直接取成员最高等级。集中给出前置条件、完整执行顺序、输入输出如何衔接、可运行的完整脚本及命令,不把必要步骤分散到原报告;缺失条件、未验证步骤如实标明。版本冲突重新读取材料并生成,不能仅换version重试。各成员仍有独立报告、评级和证据。工具被关闭时不绕过配置。`
|
||||||
}
|
}
|
||||||
|
|
||||||
const historicalFindingReviewGuidance = `
|
const historicalFindingReviewGuidance = `
|
||||||
|
|
||||||
【历史整理模式,优先于旧报告撰写及自动绑流量指引】本次只整理已经存在的上报。原始报告即使为空或待更新,也只记录缺失情况,绝不调用 update_finding_report、report_finding、bind_finding_traffic,不修改原始标题、评级、状态、证据或报告,不执行 Bash、写文件或重新探测目标。
|
【历史整理模式,优先于旧报告撰写及自动绑流量指引】本次只整理已经存在的上报。原始报告即使为空或待更新,也只记录缺失情况,绝不调用 update_finding_report、report_finding、bind_finding_traffic,不修改原始标题、评级、状态、证据或报告,不执行 Bash、写文件或重新探测目标。
|
||||||
先逐条 get_finding_record 读取所选记录,已有原报告直接作为判断材料复用,不重复撰写。只在所选范围内检查同一实际缺陷,可按需读取已存在的证据。按上述根因准则形成明确归并/独立/疑似建议;无法确认时保存建议供人工确认。
|
先逐条 get_finding_record 读取所选记录,已有原报告直接作为判断材料复用,不重复撰写。只在所选范围内检查同一实际缺陷,可按需读取已存在的证据。结构化证据显示不同端点/路由、处理器或后端组件、参数或缺失检查属于不同代码路径时,可判 independent;仅类型、资产、标题、令牌或登录入口相似不够。只有具体共同点明确且关键根因证据缺失时才保存一个覆盖2..20条记录的疑似分组,没有具体共同点就保持 independent。历史整理只能处理 selected scope,必须遵守同一任务限制和人工否决。
|
||||||
已有或新建文件夹必须读取全部成员并写入完整、最新的统一报告;统一报告是单独的材料,不覆盖原报告。最后必须调用 complete_finding_case_review,为每条所选 finding_id 提交 verdict(independent/merged/suggested)、reason 和 root_cause_evidence,并提供总结。根因证据应说明具体相同/不同的检查及证据来源;疑似建议明确待补证据。服务端验收通过后才可宣布完成。工具不可用或验收失败时如实报告,不能只用自然语言声称完成。`
|
历史模式禁止调用 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,不能加入未选记录。工具不可用或验收失败时如实报告,不能只用自然语言声称完成。`
|
||||||
@@ -46,7 +46,7 @@ func TestHistoricalFindingReviewFiltersMutationAndProbeTools(t *testing.T) {
|
|||||||
t.Fatal("historical mutation capability", tool.Name())
|
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)
|
t.Fatal("missing historical isolation", def)
|
||||||
}
|
}
|
||||||
normal, _, cl := AugmentTools(t.Context(), "reporter", tools)
|
normal, _, cl := AugmentTools(t.Context(), "reporter", tools)
|
||||||
|
|||||||
+401
-49
@@ -41,13 +41,14 @@ type FindingCaseEvent struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
type FindingCaseSuggestion struct {
|
type FindingCaseSuggestion struct {
|
||||||
ID int64 `json:"id,string"`
|
ID int64 `json:"id,string"`
|
||||||
TaskID int64 `json:"task_id,string"`
|
TaskID int64 `json:"task_id,string"`
|
||||||
LeftID int64 `json:"left_id,string"`
|
LeftID int64 `json:"left_id,string"`
|
||||||
RightID int64 `json:"right_id,string"`
|
RightID int64 `json:"right_id,string"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
|
FindingIDs []string `json:"finding_ids"`
|
||||||
}
|
}
|
||||||
type FindingCaseRow struct {
|
type FindingCaseRow struct {
|
||||||
Case *FindingCase `json:"case,omitempty"`
|
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)
|
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) {
|
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)
|
ids, err := sortedFindingIDs(ids)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -208,6 +209,7 @@ func (d *DB) mergeFindingCase(ctx context.Context, taskID int64, ids []int64, ti
|
|||||||
if strings.TrimSpace(title) == "" || strings.TrimSpace(reason) == "" {
|
if strings.TrimSpace(title) == "" || strings.TrimSpace(reason) == "" {
|
||||||
return 0, errors.New("必须提供漏洞名称及基于证据的归并理由")
|
return 0, errors.New("必须提供漏洞名称及基于证据的归并理由")
|
||||||
}
|
}
|
||||||
|
keptIDs := append([]int64(nil), ids...)
|
||||||
var cid int64
|
var cid int64
|
||||||
err = d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
|
err = d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
|
||||||
if err := lockCaseTask(tx, taskID); err != nil {
|
if err := lockCaseTask(tx, taskID); err != nil {
|
||||||
@@ -308,11 +310,69 @@ func (d *DB) mergeFindingCase(ctx context.Context, taskID int64, ids []int64, ti
|
|||||||
return err
|
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))
|
for _, excluded := range dropped {
|
||||||
return err
|
if all[excluded] {
|
||||||
|
return fmt.Errorf("排除的记录 #%d 已与所选记录在同一文件夹,请先移出该文件夹后再确认", excluded)
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
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
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
WHERE sm.suggestion_id=s.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 {
|
func (d *DB) RemoveFindingCaseMember(ctx context.Context, cid, fid int64, reason string) error {
|
||||||
c, err := d.GetFindingCase(cid)
|
c, err := d.GetFindingCase(cid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -355,8 +415,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 {
|
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
|
return err
|
||||||
}
|
}
|
||||||
_, err := tx.Exec(`DELETE FROM finding_case_members WHERE case_id=$1 AND finding_id=$2`, cid, fid)
|
if _, err := tx.Exec(`DELETE FROM finding_case_members WHERE case_id=$1 AND finding_id=$2`, cid, fid); err != nil {
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
return sweepFindingCaseSuggestions(tx, origin)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
func (d *DB) UpdateFindingCaseReport(ctx context.Context, cid, version int64, title, report, severity, reason string) error {
|
func (d *DB) UpdateFindingCaseReport(ctx context.Context, cid, version int64, title, report, severity, reason string) error {
|
||||||
@@ -382,81 +444,345 @@ func (d *DB) UpdateFindingCaseReport(ctx context.Context, cid, version int64, ti
|
|||||||
return err
|
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) == "" {
|
if strings.TrimSpace(title) == "" || strings.TrimSpace(reason) == "" {
|
||||||
return 0, errors.New("建议需要名称和具体证据理由")
|
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 {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
var id int64
|
var suggestionID int64
|
||||||
err = d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
|
err = d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
|
||||||
if err := lockCaseTask(tx, taskID); err != nil {
|
if err := lockCaseTask(tx, taskID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
caseIDs := map[int64]bool{}
|
||||||
|
sameActiveCase := true
|
||||||
|
var firstCase int64
|
||||||
for _, fid := range ids {
|
for _, fid := range ids {
|
||||||
var tid sql.NullInt64
|
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
|
return err
|
||||||
}
|
}
|
||||||
if !tid.Valid || tid.Int64 != taskID {
|
if !tid.Valid || tid.Int64 != taskID {
|
||||||
return errors.New("只能建议同一任务的归并")
|
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 sameActiveCase && firstCase > 0 && len(caseIDs) == 1 {
|
||||||
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 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
|
return err
|
||||||
}
|
}
|
||||||
if blocked {
|
blocked := []string{}
|
||||||
return errors.New("人工已否决该归并")
|
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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
items := []FindingCaseSuggestion{}
|
||||||
out := []FindingCaseSuggestion{}
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var s FindingCaseSuggestion
|
var s FindingCaseSuggestion
|
||||||
if err := rows.Scan(&s.ID, &s.TaskID, &s.LeftID, &s.RightID, &s.Title, &s.Reason, &s.State); err != nil {
|
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
|
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)
|
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
|
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
|
return 0, err
|
||||||
}
|
}
|
||||||
if s.State != "pending" {
|
if s.State != "pending" {
|
||||||
return 0, ErrFindingCaseConflict
|
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 {
|
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 {
|
return 0, d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
|
||||||
if err := lockCaseTask(tx, s.TaskID); err != nil {
|
if err := lockCaseTask(tx, s.TaskID); err != nil {
|
||||||
return err
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if n, _ := res.RowsAffected(); n != 1 {
|
for i := 0; i < len(members); i++ {
|
||||||
return ErrFindingCaseConflict
|
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 err
|
||||||
}
|
}
|
||||||
return nil
|
return sweepFindingCaseSuggestions(tx, s.TaskID)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,13 +805,13 @@ func (d *DB) DistinctFindingStats(taskID string) (*FindingDistinctStats, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Compact candidate pages exclude PoC/report bodies and do not treat severity as identity.
|
// 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)
|
f, err := d.FindingCaseSummary(fid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, 0, err
|
||||||
}
|
}
|
||||||
if f == nil || f.TaskID == nil {
|
if f == nil || f.TaskID == nil {
|
||||||
return nil, 0, errors.New("漏洞任务不可用")
|
return nil, 0, 0, errors.New("漏洞任务不可用")
|
||||||
}
|
}
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
@@ -505,32 +831,58 @@ func (d *DB) FindingCaseCandidates(fid int64, page, size int, scope ...[]int64)
|
|||||||
if len(scope) > 0 {
|
if len(scope) > 0 {
|
||||||
raw, err := json.Marshal(scope[0])
|
raw, err := json.Marshal(scope[0])
|
||||||
if err != nil {
|
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)`
|
where += ` AND f.id IN (SELECT jsonb_array_elements_text($6::jsonb)::bigint)`
|
||||||
args = append(args, string(raw))
|
args = append(args, string(raw))
|
||||||
}
|
}
|
||||||
var total int
|
classHit := `(trim(f.vulnclass)<>'' AND lower(f.vulnclass)=lower($3))`
|
||||||
if err := d.QueryRow(`SELECT count(*) FROM findings f`+where, args...).Scan(&total); err != nil {
|
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)))`
|
||||||
return nil, 0, err
|
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)
|
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 {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, 0, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
out := []map[string]any{}
|
out := []map[string]any{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id, cid int64
|
var id, cid, score int64
|
||||||
var name, class, severity, summary string
|
var name, class, severity, summary string
|
||||||
var assetIDs json.RawMessage
|
var assetIDs json.RawMessage
|
||||||
if err := rows.Scan(&id, &name, &class, &severity, &summary, &assetIDs, &cid); err != nil {
|
var strong bool
|
||||||
return nil, 0, err
|
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.
|
// Batch task-list counters avoid an additional database round trip per task.
|
||||||
|
|||||||
+249
-6
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"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 {
|
if _, err := d.MergeFindingCase(ctx, task.ID, []int64{ids[0], fid}, "x", "x", "reporter"); err == nil {
|
||||||
t.Fatal("cross task accepted")
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := d.MergeFindingCase(ctx, task.ID, ids[:2], "x", "x", "reporter"); err == nil {
|
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) {
|
func TestFindingCaseSuggestionResolutionIsAtomic(t *testing.T) {
|
||||||
d, task, ids := caseTestDB(t)
|
d, task, ids := caseTestDB(t)
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -241,7 +245,7 @@ func TestFindingCaseSuggestionResolutionIsAtomic(t *testing.T) {
|
|||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for _, accept := range []bool{true, false} {
|
for _, accept := range []bool{true, false} {
|
||||||
wg.Add(1)
|
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()
|
wg.Wait()
|
||||||
close(results)
|
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 {
|
if _, err := d.Exec(`UPDATE findings SET vulnclass='SQLi',name='search injection' WHERE id=$1`, ids[3]); err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
if err != nil || total != 2 || len(items) != 1 {
|
||||||
t.Fatalf("candidate pagination %d %+v %v", total, items, err)
|
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 {
|
if err := d.RemoveFindingCaseMember(context.Background(), cid, ids[1], "different cause"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
_, total, err = d.FindingCaseCandidates(ids[0], 1, 20)
|
_, total, _, err = d.FindingCaseCandidates(ids[0], 1, 20)
|
||||||
if err != nil || total != 1 {
|
if err != nil || total != 1 {
|
||||||
t.Fatalf("veto excluded %d %v", total, err)
|
t.Fatalf("veto excluded %d %v", total, err)
|
||||||
}
|
}
|
||||||
@@ -355,3 +359,242 @@ func TestFindingCaseViewContextAndAssetFiltering(t *testing.T) {
|
|||||||
t.Fatalf("original severity order %+v %v", rows, err)
|
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 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()
|
||||||
|
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 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()
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1496,6 +1496,54 @@ CREATE TABLE IF NOT EXISTS finding_case_suggestions (
|
|||||||
UNIQUE(left_id,right_id),
|
UNIQUE(left_id,right_id),
|
||||||
CHECK(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.
|
-- Rejected/detached pairs are a durable human veto, also checked on group unions.
|
||||||
CREATE TABLE IF NOT EXISTS finding_case_blocks (
|
CREATE TABLE IF NOT EXISTS finding_case_blocks (
|
||||||
left_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE,
|
left_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ func (s *Server) toolCompleteFindingCaseReview() actool.CoreTool {
|
|||||||
decision := objSchema(map[string]any{
|
decision := objSchema(map[string]any{
|
||||||
"finding_id": idParam("所选独立漏洞记录 ID"),
|
"finding_id": idParam("所选独立漏洞记录 ID"),
|
||||||
"verdict": map[string]any{"type": "string", "enum": []string{"independent", "merged", "suggested"}},
|
"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")
|
"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) {
|
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
|
var a findingReviewConclusion
|
||||||
if err := json.Unmarshal(raw, &a); err != nil {
|
if err := json.Unmarshal(raw, &a); err != nil {
|
||||||
return actool.Errorf(err.Error()), nil
|
return actool.Errorf(err.Error()), nil
|
||||||
@@ -101,7 +101,9 @@ func (s *Server) saveFindingReviewConclusion(ctx context.Context, conv int64, a
|
|||||||
}
|
}
|
||||||
case "suggested":
|
case "suggested":
|
||||||
var pending bool
|
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
|
return err
|
||||||
}
|
}
|
||||||
if !pending {
|
if !pending {
|
||||||
@@ -115,17 +117,12 @@ func (s *Server) saveFindingReviewConclusion(ctx context.Context, conv int64, a
|
|||||||
return errors.New("无效判断类型")
|
return errors.New("无效判断类型")
|
||||||
}
|
}
|
||||||
if cid.Valid {
|
if cid.Valid {
|
||||||
var report string
|
|
||||||
var version, rv int64
|
|
||||||
var active bool
|
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
|
return err
|
||||||
}
|
}
|
||||||
if !active || rv != version {
|
if !active {
|
||||||
return fmt.Errorf("文件夹 #%d 的统一报告待更新,请重新读取后更新", cid.Int64)
|
return fmt.Errorf("文件夹 #%d 已关闭", cid.Int64)
|
||||||
}
|
|
||||||
if err := validateUnifiedFindingReport(report); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,46 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/Autumn-27/artex/intercept"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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 = `# 同一权限缺陷
|
const completeFindingReviewReport = `# 同一权限缺陷
|
||||||
## 概述
|
## 概述
|
||||||
同一对象权限检查缺失。
|
同一对象权限检查缺失。
|
||||||
@@ -43,7 +78,7 @@ func TestUnifiedFindingReportRejectsIncompleteAndTruncated(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFindingCaseReviewCompletionRequiresReadsAndFreshReport(t *testing.T) {
|
func TestFindingCaseReviewCompletionRequiresReadsNotFreshReport(t *testing.T) {
|
||||||
s, task, ids := testCaseServer(t)
|
s, task, ids := testCaseServer(t)
|
||||||
runs, err := s.m.pg.CreateFindingCaseReviews(t.Context(), map[int64][]int64{task: ids})
|
runs, err := s.m.pg.CreateFindingCaseReviews(t.Context(), map[int64][]int64{task: ids})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -71,13 +106,6 @@ func TestFindingCaseReviewCompletionRequiresReadsAndFreshReport(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
if err := s.saveFindingReviewConclusion(ctx, conv, a); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -90,8 +118,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 {
|
if _, err := s.m.pg.Exec(`UPDATE findings SET evidence='new proof' WHERE id=$1`, ids[0]); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := s.validateFindingReviewCompletion(conv); err == nil {
|
if err := s.validateFindingReviewCompletion(conv); err != nil {
|
||||||
t.Fatal("stale group reported completed")
|
t.Fatalf("stale unified report should not block merged conclusion: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +166,7 @@ func TestFindingCaseReviewSuggestionNeedsSavedSuggestion(t *testing.T) {
|
|||||||
if err := s.saveFindingReviewConclusion(ctx, conv, a); err == nil {
|
if err := s.saveFindingReviewConclusion(ctx, conv, a); err == nil {
|
||||||
t.Fatal("unpersisted suggestion accepted")
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := s.saveFindingReviewConclusion(ctx, conv, a); err != nil {
|
if err := s.saveFindingReviewConclusion(ctx, conv, a); err != nil {
|
||||||
|
|||||||
+43
-14
@@ -6,7 +6,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Autumn-27/artex/db"
|
"github.com/Autumn-27/artex/db"
|
||||||
@@ -203,12 +205,22 @@ func (s *Server) caseSuggestions(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
func (s *Server) resolveCaseSuggestion(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) resolveCaseSuggestion(w http.ResponseWriter, r *http.Request) {
|
||||||
var a struct {
|
var a struct {
|
||||||
Accept bool `json:"accept"`
|
Accept bool `json:"accept"`
|
||||||
|
ExcludeIDs []string `json:"exclude_ids"`
|
||||||
}
|
}
|
||||||
if !decodeCaseRequest(w, r, &a) {
|
if !decodeCaseRequest(w, r, &a) {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
caseHTTPError(w, err)
|
caseHTTPError(w, err)
|
||||||
return
|
return
|
||||||
@@ -253,10 +265,10 @@ func (s *Server) findingCaseTools() []actool.CoreTool {
|
|||||||
name, desc string
|
name, desc string
|
||||||
required []string
|
required []string
|
||||||
}{
|
}{
|
||||||
{"search_finding_duplicates", "只读检索同任务候选,返回摘要与资产;等级不同也可能重复。支持分页,按需读取详情。", []string{"finding_id"}},
|
{"search_finding_duplicates", "只读检索同任务候选摘要与资产,按分数排序并返回 score、tier、hit_reasons:strong=同漏洞类型且资产重叠,weak=未同时具备同漏洞类型和资产重叠,请结合 hit_reasons 核对,仅作参考。等级不同也可能重复。支持分页,按需读取详情。", []string{"finding_id"}},
|
||||||
{"get_finding_record", "读取独立漏洞完整原始证据和报告,返回探索节点编号以便追溯执行记录。", []string{"finding_id"}},
|
{"get_finding_record", "读取独立漏洞完整原始证据和报告,返回探索节点编号以便追溯执行记录。", []string{"finding_id"}},
|
||||||
{"merge_finding_records", "证据明确证明同一缺陷时归并;保留所有原始记录。不跨任务、不改变原评级,不可绕过人工否决。", []string{"task_id", "finding_ids", "title", "reason"}},
|
{"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"}},
|
{"get_finding_case", "读取归并组和成员分页。先记录version,再读取成员的原报告、证据及流量;版本变化必须重新读取。", []string{"case_id"}},
|
||||||
{"update_finding_case_report", "保存统一报告及评级理由,version必须来自实际读取。保留各条原报告,不新建漏洞。", []string{"case_id", "version", "title", "report", "severity", "severity_reason"}},
|
{"update_finding_case_report", "保存统一报告及评级理由,version必须来自实际读取。保留各条原报告,不新建漏洞。", []string{"case_id", "version", "title", "report", "severity", "severity_reason"}},
|
||||||
}
|
}
|
||||||
@@ -312,8 +324,9 @@ func (s *Server) performFindingCaseTool(ctx context.Context, name string, a find
|
|||||||
if scope != nil {
|
if scope != nil {
|
||||||
scopes = append(scopes, scope)
|
scopes = append(scopes, scope)
|
||||||
}
|
}
|
||||||
items, total, err := s.m.pg.FindingCaseCandidates(fid, page, 20, scopes...)
|
items, total, strongTotal, err := s.m.pg.FindingCaseCandidates(fid, page, 20, scopes...)
|
||||||
return map[string]any{"items": items, "total": total, "page": page, "page_size": 20}, err
|
return map[string]any{"items": items, "total": total, "strong_total": strongTotal, "page": page, "page_size": 20,
|
||||||
|
"note": "按分数排序;weak 表示未同时具备同漏洞类型和资产重叠,请结合 hit_reasons 核对,仅作参考,通常不应据此建议归并"}, err
|
||||||
case "get_finding_record":
|
case "get_finding_record":
|
||||||
f, err := s.m.pg.GetFinding(fid)
|
f, err := s.m.pg.GetFinding(fid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -341,11 +354,11 @@ func (s *Server) performFindingCaseTool(ctx context.Context, name string, a find
|
|||||||
return map[string]string{"case_id": i64s(id)}, err
|
return map[string]string{"case_id": i64s(id)}, err
|
||||||
case "suggest_finding_merge":
|
case "suggest_finding_merge":
|
||||||
ids := a.ids()
|
ids := a.ids()
|
||||||
if len(ids) != 2 {
|
if len(ids) < 2 || len(ids) > 20 {
|
||||||
return nil, errors.New("疑似重复建议需要两条记录")
|
return nil, errors.New("疑似重复分组需要2至20条记录")
|
||||||
}
|
}
|
||||||
id, err := s.m.pg.SuggestFindingCase(ctx, parseProfileID(a.TaskID), ids[0], ids[1], a.Title, a.Reason)
|
id, err := s.m.pg.SuggestFindingCase(ctx, parseProfileID(a.TaskID), ids, a.Title, a.Reason)
|
||||||
return map[string]string{"suggestion_id": i64s(id)}, err
|
return map[string]any{"suggestion_id": i64s(id), "finding_ids": ids}, err
|
||||||
case "get_finding_case":
|
case "get_finding_case":
|
||||||
c, err := s.m.pg.GetFindingCase(cid)
|
c, err := s.m.pg.GetFindingCase(cid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -378,14 +391,22 @@ func (s *Server) seedFindingCaseTools() {
|
|||||||
if err != nil || a == nil {
|
if err != nil || a == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const flag = "finding_case_tools_v2_review"
|
const flag = "finding_case_tools_v3_review"
|
||||||
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
|
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
names := []string{}
|
names := []string{}
|
||||||
previous, _, _ := s.m.pg.GetSetting("finding_case_tools_v1")
|
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() {
|
for _, t := range s.findingCaseTools() {
|
||||||
schema, _ := json.Marshal(t.InputSchema())
|
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 previousReview == "true" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err := s.m.pg.SeedTool(t.Name(), t.Description(), schema, json.RawMessage(`["reporter"]`)); err != nil {
|
if err := s.m.pg.SeedTool(t.Name(), t.Description(), schema, json.RawMessage(`["reporter"]`)); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -393,11 +414,19 @@ func (s *Server) seedFindingCaseTools() {
|
|||||||
names = append(names, t.Name())
|
names = append(names, t.Name())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := s.m.pg.AddAgentToToolBinding("reporter", names); err != nil {
|
if previousReview != "true" {
|
||||||
return
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ = s.m.pg.SetSetting(flag, "true")
|
_ = 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) {
|
func (s *Server) reviewFindingCases(w http.ResponseWriter, r *http.Request) {
|
||||||
var a findingCaseRequest
|
var a findingCaseRequest
|
||||||
if !decodeCaseRequest(w, r, &a) {
|
if !decodeCaseRequest(w, r, &a) {
|
||||||
@@ -463,7 +492,7 @@ func (s *Server) reviewFindingCases(w http.ResponseWriter, r *http.Request) {
|
|||||||
taskID, fids := reservation.TaskID, reservation.FindingIDs
|
taskID, fids := reservation.TaskID, reservation.FindingIDs
|
||||||
taskContext := taskContexts[taskID]
|
taskContext := taskContexts[taskID]
|
||||||
raw, _ := json.Marshal(fids)
|
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}
|
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")
|
cfg := s.readTriggerBehavior("reporter")
|
||||||
s.queueMu.Lock()
|
s.queueMu.Lock()
|
||||||
|
|||||||
@@ -42,6 +42,119 @@ 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) })
|
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
|
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, fresh bool
|
||||||
|
}{
|
||||||
|
{name: "fresh", fresh: true},
|
||||||
|
{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 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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.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 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFindingCaseAPIPaginationAndTools(t *testing.T) {
|
func TestFindingCaseAPIPaginationAndTools(t *testing.T) {
|
||||||
s, task, ids := testCaseServer(t)
|
s, task, ids := testCaseServer(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -524,7 +524,7 @@ func (s *Server) seedOrchestrationTools() {
|
|||||||
// reaches an old DB otherwise. Preserves each tool's agent binding + enabled flag.
|
// reaches an old DB otherwise. Preserves each tool's agent binding + enabled flag.
|
||||||
// Bump the flag whenever these tools' schemas/descriptions change in code.
|
// Bump the flag whenever these tools' schemas/descriptions change in code.
|
||||||
func (s *Server) refreshBuiltinToolSchemas() {
|
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" {
|
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,15 +192,29 @@ function FindingMergeSuggestionCard({
|
|||||||
onResolve,
|
onResolve,
|
||||||
}: {
|
}: {
|
||||||
suggestion: FindingCaseSuggestion;
|
suggestion: FindingCaseSuggestion;
|
||||||
onResolve: (s: FindingCaseSuggestion, accept: boolean) => Promise<void>;
|
onResolve: (s: FindingCaseSuggestion, accept: boolean, excludeIds?: string[]) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = React.useState(false);
|
const [expanded, setExpanded] = React.useState(false);
|
||||||
const [busy, setBusy] = React.useState(false);
|
const [busy, setBusy] = React.useState(false);
|
||||||
|
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<string[]>([]);
|
||||||
|
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) {
|
async function resolve(accept: boolean) {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
|
if (accept && included.length < 2) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await onResolve(s, accept);
|
await onResolve(s, accept, accept ? validExcluded : []);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -209,17 +223,30 @@ function FindingMergeSuggestionCard({
|
|||||||
<Card size="sm" className="min-w-0">
|
<Card size="sm" className="min-w-0">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Badge variant="outline">疑似重复 · 待确认</Badge>
|
<Badge variant="outline">疑似重复分组 · {ids.length} 条 · 待确认</Badge>
|
||||||
<CardDescription>任务 #{s.task_id}</CardDescription>
|
<CardDescription>任务 #{s.task_id}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="break-words">{s.title}</CardTitle>
|
<CardTitle className="break-words">{s.title}</CardTitle>
|
||||||
<CardDescription className="flex flex-wrap gap-3">
|
<CardDescription className="flex flex-col gap-1">
|
||||||
<Link className="underline underline-offset-4" href={`/function/findings/detail/?id=${s.left_id}`}>
|
{ids.map((id) => (
|
||||||
查看原报告 #{s.left_id}
|
<span key={id} className="flex items-center gap-2">
|
||||||
</Link>
|
<Checkbox
|
||||||
<Link className="underline underline-offset-4" href={`/function/findings/detail/?id=${s.right_id}`}>
|
checked={!excluded.includes(id)}
|
||||||
查看原报告 #{s.right_id}
|
disabled={busy}
|
||||||
</Link>
|
onCheckedChange={(value) =>
|
||||||
|
setExcluded((current) =>
|
||||||
|
value === true
|
||||||
|
? current.filter((item) => item !== id)
|
||||||
|
: [...current.filter((item) => item !== id), id],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
aria-label={`归并时包含上报 #${id}`}
|
||||||
|
/>
|
||||||
|
<Link className="underline underline-offset-4" href={`/function/findings/detail/?id=${id}`}>
|
||||||
|
查看原报告 #{id}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -238,11 +265,12 @@ function FindingMergeSuggestionCard({
|
|||||||
</Collapsible>
|
</Collapsible>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex flex-wrap gap-2">
|
<CardFooter className="flex flex-wrap gap-2">
|
||||||
<Button variant="outline" size="sm" disabled={busy} onClick={() => resolve(true)}>
|
<Button variant="outline" size="sm" disabled={busy || included.length < 2} onClick={() => resolve(true)}>
|
||||||
{busy ? <Spinner /> : null}确认归并
|
{busy ? <Spinner /> : null}
|
||||||
|
{included.length === ids.length ? "整组确认归并" : `确认归并所选(${included.length} 条)`}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => resolve(false)}>
|
<Button variant="ghost" size="sm" disabled={busy} onClick={() => resolve(false)}>
|
||||||
不是同一漏洞
|
整组不是同一漏洞
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -271,21 +299,39 @@ export function FindingCaseReviewPanel({ taskId, onChange }: { taskId?: string;
|
|||||||
const [suggestions, setSuggestions] = React.useState<FindingCaseSuggestion[]>([]);
|
const [suggestions, setSuggestions] = React.useState<FindingCaseSuggestion[]>([]);
|
||||||
const [runs, setRuns] = React.useState<FindingCaseReviewRun[]>([]);
|
const [runs, setRuns] = React.useState<FindingCaseReviewRun[]>([]);
|
||||||
const [error, setError] = 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;
|
||||||
|
const previousTaskKey = React.useRef(taskKey);
|
||||||
React.useEffect(() => {
|
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 active = true;
|
||||||
let timer: ReturnType<typeof setTimeout>;
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const [s, r] = await Promise.all([api.findingCaseSuggestions(taskId), api.findingCaseReviewRuns()]);
|
const [s, r] = await Promise.all([api.findingCaseSuggestions(taskId), api.findingCaseReviewRuns()]);
|
||||||
if (active) {
|
if (active && seq === requestSeq.current && requestedTask === taskKeyRef.current) {
|
||||||
setSuggestions(s);
|
setSuggestions(s);
|
||||||
setRuns(r.filter((x) => !taskId || x.task_id === taskId));
|
setRuns(r.filter((x) => !taskId || x.task_id === taskId));
|
||||||
setError("");
|
setError("");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (active) setError((e as Error).message);
|
if (active && seq === requestSeq.current && requestedTask === taskKeyRef.current)
|
||||||
|
setError((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
if (active) timer = setTimeout(load, 5000);
|
if (active && seq === requestSeq.current && requestedTask === taskKeyRef.current)
|
||||||
|
timer = setTimeout(load, 5000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void load();
|
void load();
|
||||||
@@ -293,19 +339,29 @@ export function FindingCaseReviewPanel({ taskId, onChange }: { taskId?: string;
|
|||||||
active = false;
|
active = false;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [taskId]);
|
}, [reload, taskId, taskKey]);
|
||||||
async function resolve(s: FindingCaseSuggestion, accept: boolean) {
|
async function resolve(s: FindingCaseSuggestion, accept: boolean, excludeIds?: string[]) {
|
||||||
|
const seq = ++requestSeq.current;
|
||||||
|
const requestedTask = taskKey;
|
||||||
try {
|
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));
|
if (requestedTask === taskKeyRef.current) {
|
||||||
if (accept && result.case_id !== "0") {
|
setReload((v) => v + 1);
|
||||||
toast.success("已归并,请进入文件夹生成统一报告");
|
if (seq === requestSeq.current) {
|
||||||
} else {
|
setSuggestions((v) => v.filter((x) => x.id !== s.id));
|
||||||
toast.success("已否决,不会自动再次归并");
|
if (accept && result.case_id !== "0") {
|
||||||
|
toast.success("已归并,请进入文件夹生成统一报告");
|
||||||
|
} else {
|
||||||
|
toast.success("已否决,不会自动再次归并");
|
||||||
|
}
|
||||||
|
onChange?.();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onChange?.();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
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 (
|
return (
|
||||||
|
|||||||
+5
-2
@@ -651,8 +651,11 @@ export const api = {
|
|||||||
get<FindingCaseSuggestion[]>(
|
get<FindingCaseSuggestion[]>(
|
||||||
`/exploration/finding-case-suggestions${task ? `?task_id=${encodeURIComponent(task)}` : ""}`,
|
`/exploration/finding-case-suggestions${task ? `?task_id=${encodeURIComponent(task)}` : ""}`,
|
||||||
),
|
),
|
||||||
resolveFindingCaseSuggestion: (id: string, accept: boolean) =>
|
resolveFindingCaseSuggestion: (id: string, accept: boolean, excludeIds?: string[]) =>
|
||||||
post<{ case_id: string }>(`/exploration/finding-case-suggestions/${id}`, { accept }),
|
post<{ case_id: string }>(`/exploration/finding-case-suggestions/${id}`, {
|
||||||
|
accept,
|
||||||
|
...(excludeIds && excludeIds.length > 0 ? { exclude_ids: excludeIds } : {}),
|
||||||
|
}),
|
||||||
reviewFindingCases: (ids: string[]) =>
|
reviewFindingCases: (ids: string[]) =>
|
||||||
post<{ runs: { conversation_id: number; task_id: string }[] }>("/exploration/finding-case-review", {
|
post<{ runs: { conversation_id: number; task_id: string }[] }>("/exploration/finding-case-review", {
|
||||||
finding_ids: ids,
|
finding_ids: ids,
|
||||||
|
|||||||
@@ -2587,7 +2587,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
|||||||
const from = q.get("from") || "";
|
const from = q.get("from") || "";
|
||||||
const to = q.get("to") || "";
|
const to = q.get("to") || "";
|
||||||
if (status && !["pending", "allowed", "denied", "timeout"].includes(status)) throw new Error("无效审批状态");
|
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].length > 200) throw new Error("搜索关键词不能超过 200 个字符");
|
||||||
if (search.startsWith("#") && !/^#[1-9]\d*$/.test(search)) throw new Error("审批编号必须为 # 后接正整数");
|
if (search.startsWith("#") && !/^#[1-9]\d*$/.test(search)) throw new Error("审批编号必须为 # 后接正整数");
|
||||||
for (const value of [from, to]) {
|
for (const value of [from, to]) {
|
||||||
|
|||||||
@@ -1782,6 +1782,7 @@ export interface FindingCaseSuggestion {
|
|||||||
task_id: string;
|
task_id: string;
|
||||||
left_id: string;
|
left_id: string;
|
||||||
right_id: string;
|
right_id: string;
|
||||||
|
finding_ids: string[];
|
||||||
title: string;
|
title: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
state: string;
|
state: string;
|
||||||
|
|||||||
Reference in new issue
Block a user