From 7db7b71cdb45b8b4b1f5a57515ee754c5ee35596 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:23:11 +0800 Subject: [PATCH] fix(findings): protect historical reports and verify consolidation results --- README.md | 4 +- agent/assembly.go | 7 + agent/finding_cases.go | 41 +++- agent/finding_cases_test.go | 30 +++ db/finding_case_reviews.go | 81 ++++++++ db/finding_case_reviews_test.go | 86 ++++++++ db/finding_cases.go | 12 +- db/schema.sql | 4 + server/conversations.go | 14 ++ server/finding_case_reviews.go | 250 +++++++++++++++++++++++ server/finding_case_reviews_test.go | 170 +++++++++++++++ server/finding_cases.go | 90 +++++--- server/finding_cases_test.go | 6 +- server/finding_workflow.go | 5 + web/src/components/finding-case-list.tsx | 180 +++++++++++++--- 15 files changed, 906 insertions(+), 74 deletions(-) create mode 100644 db/finding_case_reviews.go create mode 100644 db/finding_case_reviews_test.go create mode 100644 server/finding_case_reviews.go create mode 100644 server/finding_case_reviews_test.go diff --git a/README.md b/README.md index 38b36b3..504896c 100644 --- a/README.md +++ b/README.md @@ -335,11 +335,11 @@ server { ### 同一漏洞归并与统一报告 -发现页默认「按漏洞」显示。同一任务内,Reporter 依据目标、接口参数、权限边界、根因及验证证据确认同一实际缺陷后形成文件夹;仅类型或名称相同不会作为自动归并依据。严重程度不同仍可能是同一漏洞。文件夹的 `严/高/中/低` 数量按原始记录统计,例如 `0/1/1/1`;独立漏洞统计采用另行评估的统一等级,尚未评估时显示「待评估」。原始上报内容、评级、状态、报告及证据不被覆盖。 +发现页默认「按漏洞」显示。同一任务内,Reporter 依据目标、接口参数、权限边界、根因及验证证据确认同一实际缺陷后形成文件夹;仅类型或名称相同不会作为自动归并依据。严重程度、接口、后端或读写影响不同仍可能是同一漏洞;反之,共用登录入口或令牌不能单独证明同一根因。有关联但共同根因无法确认时提交人工建议。文件夹的 `严/高/中/低` 数量按原始记录统计,例如 `0/1/1/1`;独立漏洞统计采用另行评估的统一等级,尚未评估时显示「待评估」。原始上报内容、评级、状态、报告及证据不被覆盖。 点击箭头展开原始记录,点击文件夹标题查看统一报告、原始记录及归并历史。证据不足的建议需要点击「确认归并」;否决或移出后,这些关系不会被 Agent 自动再次归并。移出成员不删除原漏洞,不足两条时解散文件夹并保留历史。继承任务的漏洞组保持只读。 -新上报由现有 Reporter 生成独立报告并检查重复。升级首次补充 Reporter 的归并工具和运行指引,保留自定义提示词及触发设置;已删除或停用的 Reporter 不会重建、启用。历史记录不会自动扫描:展开并选择原始记录,点击「整理所选」,按任务分别排队执行,可查看执行记录及失败信息。文件夹详情可重新生成统一报告;需要启用 Reporter 并配置可用模型。判断和写作会消耗模型 token,候选检索先分页返回摘要,详情证据按需读取,不重新探测目标。 +新上报由现有 Reporter 生成独立报告并检查重复。升级首次补充 Reporter 的归并工具和运行指引,保留自定义提示词及触发设置;已删除或停用的 Reporter 不会重建、启用。历史记录不会自动扫描:展开并选择原始记录,点击「整理所选」,按任务分别排队执行,可查看执行记录及失败信息。同一任务已有排队或运行中的整理时拒绝重复提交。历史整理以只读方式复用原报告,禁止覆盖原报告或补绑证据;已有报告为空或过期时如实说明,单独通过报告撰写流程处理。结束前需提交每条所选记录的根因判断,检查读取范围、建议、统一报告完整性及版本,通过验收才显示完成。文件夹详情可重新生成统一报告;需要启用 Reporter 并配置可用模型。判断和写作会消耗模型 token,候选检索先分页返回摘要,详情证据按需读取,不重新探测目标。 成员、报告或证据变化后统一报告标记待更新,写入时校验实际读取版本。导出默认每组一份统一报告及未归并记录,可勾选「包含原始子报告」;统一报告未生成或过期时附明确说明及全部原始材料。原始记录、按任务及按资产视图继续保留。升级沿用启动时幂等数据库迁移,无需执行独立 SQL。 diff --git a/agent/assembly.go b/agent/assembly.go index ae59901..115a6db 100644 --- a/agent/assembly.go +++ b/agent/assembly.go @@ -62,8 +62,15 @@ func AugmentTools(ctx context.Context, agentKey string, base []actool.CoreTool) if ToolResolve != nil { out = ToolResolve(ctx, agentKey, out) } + if agentKey == "reporter" && isFindingCaseReview(ctx) { + out = historicalFindingCaseTools(out) + } out, def.FindingGuidance = findingWorkflowTools(agentKey, out) out, def.CaseGuidance = findingCaseWorkflow(agentKey, out) + if agentKey == "reporter" && isFindingCaseReview(ctx) { + def.FindingGuidance = "" + def.CaseGuidance += historicalFindingReviewGuidance + } for i, t := range out { out[i] = guardPanic(t) } diff --git a/agent/finding_cases.go b/agent/finding_cases.go index 3a970fc..7e69412 100644 --- a/agent/finding_cases.go +++ b/agent/finding_cases.go @@ -1,6 +1,34 @@ package agent -import actool "github.com/Autumn-27/norma/tool" +import ( + "context" + actool "github.com/Autumn-27/norma/tool" +) + +type findingReviewKey struct{} + +// Historical consolidation has a narrower capability set than new reporting. +func WithFindingCaseReview(ctx context.Context) context.Context { + return context.WithValue(ctx, findingReviewKey{}, true) +} +func isFindingCaseReview(ctx context.Context) bool { + v, _ := ctx.Value(findingReviewKey{}).(bool) + return v +} + +func historicalFindingCaseTools(tools []actool.CoreTool) []actool.CoreTool { + allowed := map[string]bool{} + for _, name := range []string{"search_finding_duplicates", "get_finding_record", "merge_finding_records", "suggest_finding_merge", "get_finding_case", "update_finding_case_report", "complete_finding_case_review", "get_finding_traffic", "traffic_search", "traffic_get", "traffic_blob", "get_task_node_detail", "list_task_findings", "get_task_graph", "list_task_worker_traces", "get_task_worker_trace", "Read"} { + allowed[name] = true + } + out := make([]actool.CoreTool, 0, len(tools)) + for _, tool := range tools { + if allowed[tool.Name()] { + out = append(out, tool) + } + } + return out +} // This guidance is appended to the runtime prompt, including editable old prompts. // Never reintroduce unbound tools or recreate a deleted Reporter. @@ -17,6 +45,13 @@ func findingCaseWorkflow(key string, tools []actool.CoreTool) ([]actool.CoreTool } return tools, ` -【同一漏洞的多次上报整理】保留本次独立报告,不覆盖其他原报告。默认报告完成即停止的旧指引在此扩展:先保存本次报告,然后 search_finding_duplicates(finding_id=独立记录ID) 分页读取同任务候选摘要,按需 get_finding_record 查看证据。仅凭漏洞类型/标题相似或高中低等级不能归并;须核对目标、接口、参数、权限边界、根因及实际验证证据。明确同一缺陷时 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保存完整统一报告和基于已验证影响的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 读取所选记录,已有原报告直接作为判断材料复用,不重复撰写。只在所选范围内检查同一实际缺陷,可按需读取已存在的证据。按上述根因准则形成明确归并/独立/疑似建议;无法确认时保存建议供人工确认。 +已有或新建文件夹必须读取全部成员并写入完整、最新的统一报告;统一报告是单独的材料,不覆盖原报告。最后必须调用 complete_finding_case_review,为每条所选 finding_id 提交 verdict(independent/merged/suggested)、reason 和 root_cause_evidence,并提供总结。根因证据应说明具体相同/不同的检查及证据来源;疑似建议明确待补证据。服务端验收通过后才可宣布完成。工具不可用或验收失败时如实报告,不能只用自然语言声称完成。` diff --git a/agent/finding_cases_test.go b/agent/finding_cases_test.go index c52cd50..a8177ea 100644 --- a/agent/finding_cases_test.go +++ b/agent/finding_cases_test.go @@ -29,3 +29,33 @@ func TestFindingCaseGuidanceRespectsToolsAndTrafficSwitch(t *testing.T) { t.Fatal("custom prompt lost") } } + +func TestHistoricalFindingReviewFiltersMutationAndProbeTools(t *testing.T) { + oldAugment, oldResolve := ToolAugment, ToolResolve + ToolAugment, ToolResolve = nil, nil + t.Cleanup(func() { ToolAugment, ToolResolve = oldAugment, oldResolve }) + tools := []actool.CoreTool{} + for _, name := range []string{"update_finding_report", "bind_finding_traffic", "report_finding", "Bash", "Write", "Edit", "mcp__custom__write", "get_finding_record", "merge_finding_records", "search_finding_duplicates", "get_finding_case", "update_finding_case_report", "complete_finding_case_review"} { + tools = append(tools, writeTool(name, name, obj(nil), func(context.Context, json.RawMessage) (actool.Result, error) { return actool.Text("ok"), nil })) + } + out, def, cleanup := AugmentTools(WithFindingCaseReview(t.Context()), "reporter", tools) + defer cleanup() + for _, tool := range out { + switch tool.Name() { + case "update_finding_report", "bind_finding_traffic", "report_finding", "Bash", "Write", "Edit", "mcp__custom__write": + t.Fatal("historical mutation capability", tool.Name()) + } + } + 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) + defer cl() + found := map[string]bool{} + for _, tool := range normal { + found[tool.Name()] = true + } + if !found["update_finding_report"] || !found["Bash"] || !found["report_finding"] { + t.Fatal("normal reporting was restricted") + } +} diff --git a/db/finding_case_reviews.go b/db/finding_case_reviews.go new file mode 100644 index 0000000..7c10791 --- /dev/null +++ b/db/finding_case_reviews.go @@ -0,0 +1,81 @@ +package db + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "sort" +) + +var ErrFindingCaseReviewBusy = errors.New("该任务已有排队或运行中的漏洞整理,请等待完成或停止后重试") + +type FindingCaseReviewReservation struct { + TaskID int64 + ConversationID int64 + FindingIDs []int64 +} + +// Reserve all source tasks atomically, so a concurrent click cannot start a +// second review and a mixed-task request cannot partially enqueue its work. +func (d *DB) CreateFindingCaseReviews(ctx context.Context, batches map[int64][]int64) ([]FindingCaseReviewReservation, error) { + tasks := make([]int64, 0, len(batches)) + for task := range batches { + tasks = append(tasks, task) + } + sort.Slice(tasks, func(i, j int) bool { return tasks[i] < tasks[j] }) + out := []FindingCaseReviewReservation{} + err := d.WithEvidenceTx(ctx, func(tx *sql.Tx) error { + for _, task := range tasks { + if err := lockCaseTask(tx, task); err != nil { + return err + } + var busy bool + if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_review_runs WHERE task_id=$1 AND state IN ('queued','running'))`, task).Scan(&busy); err != nil { + return err + } + if busy { + return fmt.Errorf("%w(任务 #%d)", ErrFindingCaseReviewBusy, task) + } + } + for _, task := range tasks { + ids := []int64{} + seen := map[int64]bool{} + for _, id := range batches[task] { + if seen[id] { + continue + } + seen[id] = true + var source int64 + if err := tx.QueryRow(`SELECT task_id FROM findings WHERE id=$1`, id).Scan(&source); err != nil { + return err + } + if source != task { + return errors.New("所选漏洞不属于来源任务") + } + ids = append(ids, id) + } + if len(ids) == 0 { + return errors.New("请选择原始上报") + } + raw, err := json.Marshal(ids) + if err != nil { + return err + } + var conv int64 + if err := tx.QueryRow(`INSERT INTO conversations(agent_key,title) VALUES('reporter',$1) RETURNING id`, fmt.Sprintf("漏洞整理 · task#%d", task)).Scan(&conv); err != nil { + return err + } + if _, err := tx.Exec(`INSERT INTO finding_case_review_runs(conversation_id,task_id,finding_ids) VALUES($1,$2,$3)`, conv, task, string(raw)); err != nil { + return err + } + out = append(out, FindingCaseReviewReservation{TaskID: task, ConversationID: conv, FindingIDs: ids}) + } + return nil + }) + if err != nil { + return nil, err + } + return out, nil +} diff --git a/db/finding_case_reviews_test.go b/db/finding_case_reviews_test.go new file mode 100644 index 0000000..4f278da --- /dev/null +++ b/db/finding_case_reviews_test.go @@ -0,0 +1,86 @@ +package db + +import ( + "context" + "errors" + "sync" + "testing" +) + +func TestFindingCaseReviewConcurrentReservation(t *testing.T) { + d, task, ids := caseTestDB(t) + var wg sync.WaitGroup + results := make(chan error, 8) + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + _, err := d.CreateFindingCaseReviews(context.Background(), map[int64][]int64{task.ID: ids[:2]}) + results <- err + }() + } + wg.Wait() + close(results) + success := 0 + for err := range results { + if err == nil { + success++ + } else if !errors.Is(err, ErrFindingCaseReviewBusy) { + t.Fatal(err) + } + } + if success != 1 { + t.Fatalf("created %d reviews instead of one", success) + } + rows, err := d.Query(`SELECT conversation_id FROM finding_case_review_runs WHERE task_id=$1`, task.ID) + if err != nil { + t.Fatal(err) + } + var convs []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + t.Fatal(err) + } + convs = append(convs, id) + } + rows.Close() + t.Cleanup(func() { + for _, id := range convs { + d.Exec(`DELETE FROM conversations WHERE id=$1`, id) + } + }) + if _, err := d.Exec(`UPDATE finding_case_review_runs SET state='done' WHERE task_id=$1`, task.ID); err != nil { + t.Fatal(err) + } + next, err := d.CreateFindingCaseReviews(t.Context(), map[int64][]int64{task.ID: ids[2:]}) + if err != nil { + t.Fatal(err) + } + convs = append(convs, next[0].ConversationID) +} + +func TestFindingCaseReviewMixedRequestRollsBack(t *testing.T) { + d, task, ids := caseTestDB(t) + other, err := d.CreateTask("other review", "goal", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { d.Exec(`DELETE FROM findings WHERE task_id=$1`, other.ID); d.DeleteTask(other.ID) }) + fid, err := d.AddFinding(other.ID, 0, "IDOR", "other", "low", "other", "proof", "worker", nil) + if err != nil { + t.Fatal(err) + } + current, err := d.CreateFindingCaseReviews(t.Context(), map[int64][]int64{other.ID: {fid}}) + if err != nil { + t.Fatal(err) + } + defer d.Exec(`DELETE FROM conversations WHERE id=$1`, current[0].ConversationID) + if _, err := d.CreateFindingCaseReviews(t.Context(), map[int64][]int64{task.ID: ids, other.ID: {fid}}); !errors.Is(err, ErrFindingCaseReviewBusy) { + t.Fatal(err) + } + var count int + if err := d.QueryRow(`SELECT count(*) FROM finding_case_review_runs WHERE task_id=$1`, task.ID).Scan(&count); err != nil || count != 0 { + t.Fatalf("partial enqueue: %d %v", count, err) + } +} diff --git a/db/finding_cases.go b/db/finding_cases.go index e87b48e..c243d4a 100644 --- a/db/finding_cases.go +++ b/db/finding_cases.go @@ -479,7 +479,7 @@ func (d *DB) DistinctFindingStats(taskID string) (*FindingDistinctStats, error) } // Compact candidate pages exclude PoC/report bodies and do not treat severity as identity. -func (d *DB) FindingCaseCandidates(fid int64, page, size int) ([]map[string]any, int, 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, err @@ -502,12 +502,20 @@ func (d *DB) FindingCaseCandidates(fid int64, page, size int) ([]map[string]any, 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, err + } + where += ` AND f.id IN (SELECT jsonb_array_elements_text($6::jsonb)::bigint)` + args = append(args, string(raw)) + } var total int if err := d.QueryRow(`SELECT count(*) FROM findings f`+where, args...).Scan(&total); err != nil { return nil, 0, err } 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+` ORDER BY f.id DESC LIMIT $6 OFFSET $7`, 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, err } diff --git a/db/schema.sql b/db/schema.sql index 9928a9c..b784bce 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1471,3 +1471,7 @@ CREATE TABLE IF NOT EXISTS finding_case_review_runs ( ); ALTER TABLE finding_case_review_runs ADD COLUMN IF NOT EXISTS finding_ids JSONB NOT NULL DEFAULT '[]'; + +ALTER TABLE finding_case_review_runs ADD COLUMN IF NOT EXISTS reviewed_ids JSONB NOT NULL DEFAULT '[]'; +ALTER TABLE finding_case_review_runs ADD COLUMN IF NOT EXISTS conclusion JSONB NOT NULL DEFAULT '{}'; +CREATE INDEX IF NOT EXISTS idx_finding_case_reviews_active_task ON finding_case_review_runs(task_id) WHERE state IN ('queued','running'); diff --git a/server/conversations.go b/server/conversations.go index 84456b0..bb8c3e6 100644 --- a/server/conversations.go +++ b/server/conversations.go @@ -505,6 +505,15 @@ func (s *Server) runConversationTurn(ctx context.Context, cancel context.CancelC // turns may explain the sealed result; the result tool refuses to overwrite it. finishStatus, finishReason := "failed", "Agent 未能启动" if c.AgentKey == "reporter" { + manualReview, err := s.isHistoricalFindingReview(c.ID) + if err != nil { + finishReason = err.Error() + _, _ = s.m.pg.Exec(`UPDATE finding_case_review_runs SET state='failed',error=$2 WHERE conversation_id=$1`, c.ID, finishReason) + return + } + if manualReview { + ctx = agent.WithFindingCaseReview(ctx) + } defer func() { state := "done" reason := "" @@ -515,6 +524,11 @@ func (s *Server) runConversationTurn(ctx context.Context, cancel context.CancelC reason = "整理已停止或服务关闭" } } + if state == "done" && manualReview { + if err := s.validateFindingReviewCompletion(c.ID); err != nil { + state, reason = "failed", err.Error() + } + } _, _ = s.m.pg.Exec(`UPDATE finding_case_review_runs SET state=$2,error=$3 WHERE conversation_id=$1 AND state='running'`, c.ID, state, reason) }() } diff --git a/server/finding_case_reviews.go b/server/finding_case_reviews.go new file mode 100644 index 0000000..16a9936 --- /dev/null +++ b/server/finding_case_reviews.go @@ -0,0 +1,250 @@ +package server + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/Autumn-27/artex/intercept" + actool "github.com/Autumn-27/norma/tool" +) + +type findingReviewDecision struct { + FindingID int64 `json:"finding_id"` + Verdict string `json:"verdict"` + Reason string `json:"reason"` + RootCauseEvidence string `json:"root_cause_evidence"` +} +type findingReviewConclusion struct { + Summary string `json:"summary"` + Decisions []findingReviewDecision `json:"decisions"` +} + +func (s *Server) isHistoricalFindingReview(conv int64) (bool, error) { + var exists bool + err := s.m.pg.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_review_runs WHERE conversation_id=$1)`, conv).Scan(&exists) + return exists, err +} + +func (s *Server) toolCompleteFindingCaseReview() actool.CoreTool { + decision := objSchema(map[string]any{ + "finding_id": idParam("所选独立漏洞记录 ID"), + "verdict": map[string]any{"type": "string", "enum": []string{"independent", "merged", "suggested"}}, + "reason": strParam("判断理由,接口或读写影响不同不能单独证明不同根因"), + "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) { + var a findingReviewConclusion + if err := json.Unmarshal(raw, &a); err != nil { + return actool.Errorf(err.Error()), nil + } + conv := intercept.ConvIDFromContext(ctx) + if err := s.saveFindingReviewConclusion(ctx, conv, a); err != nil { + return actool.Errorf(err.Error()), nil + } + return actool.Text("历史整理验收通过;原始记录和报告保留,逐条根因判断已保存。"), nil + }) +} + +func (s *Server) saveFindingReviewConclusion(ctx context.Context, conv int64, a findingReviewConclusion) error { + if conv <= 0 { + return errors.New("仅用于有上下文的历史整理会话") + } + return s.m.pg.WithEvidenceTx(ctx, func(tx *sql.Tx) error { + var idsRaw, readsRaw json.RawMessage + var state string + if err := tx.QueryRow(`SELECT finding_ids,reviewed_ids,state FROM finding_case_review_runs WHERE conversation_id=$1 FOR UPDATE`, conv).Scan(&idsRaw, &readsRaw, &state); err != nil { + return err + } + if state != "running" { + return errors.New("整理会话未运行,不能提交验收") + } + var ids, reads []int64 + if err := json.Unmarshal(idsRaw, &ids); err != nil { + return err + } + if err := json.Unmarshal(readsRaw, &reads); err != nil { + return err + } + read := map[int64]bool{} + for _, id := range reads { + read[id] = true + } + decisions := map[int64]findingReviewDecision{} + for _, d := range a.Decisions { + if _, ok := decisions[d.FindingID]; ok { + return errors.New("重复的判断编号") + } + decisions[d.FindingID] = d + } + if strings.TrimSpace(a.Summary) == "" || len(ids) != len(decisions) { + return errors.New("须提供总结和覆盖全部所选记录的逐条判断") + } + for _, id := range ids { + d, ok := decisions[id] + if !ok || !read[id] { + return fmt.Errorf("请先 get_finding_record 读取并判断所选记录 #%d", id) + } + if strings.TrimSpace(d.Reason) == "" || strings.TrimSpace(d.RootCauseEvidence) == "" { + return fmt.Errorf("记录 #%d 缺少判断理由或根因证据", id) + } + var cid sql.NullInt64 + if err := tx.QueryRow(`SELECT m.case_id FROM findings f LEFT JOIN finding_case_members m ON m.finding_id=f.id WHERE f.id=$1`, id).Scan(&cid); err != nil { + return err + } + switch d.Verdict { + case "merged": + if !cid.Valid { + return fmt.Errorf("记录 #%d 尚未归并", id) + } + case "suggested": + var pending bool + if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_suggestions WHERE state='pending' AND (left_id=$1 OR right_id=$1))`, id).Scan(&pending); err != nil { + return err + } + if !pending { + return fmt.Errorf("记录 #%d 须先保存待确认建议", id) + } + case "independent": + if cid.Valid { + return fmt.Errorf("记录 #%d 已在文件夹中,不能声明为独立", id) + } + default: + return errors.New("无效判断类型") + } + if cid.Valid { + var report string + var version, rv int64 + var active bool + if err := tx.QueryRow(`SELECT report,version,report_version,active FROM finding_cases WHERE id=$1 FOR SHARE`, cid.Int64).Scan(&report, &version, &rv, &active); err != nil { + return err + } + if !active || rv != version { + return fmt.Errorf("文件夹 #%d 的统一报告待更新,请重新读取后更新", cid.Int64) + } + if err := validateUnifiedFindingReport(report); err != nil { + return err + } + } + } + raw, err := json.Marshal(a) + if err != nil { + return err + } + _, err = tx.Exec(`UPDATE finding_case_review_runs SET conclusion=$2 WHERE conversation_id=$1`, conv, string(raw)) + return err + }) +} + +func (s *Server) validateFindingReviewCompletion(conv int64) error { + var raw json.RawMessage + if err := s.m.pg.QueryRow(`SELECT conclusion FROM finding_case_review_runs WHERE conversation_id=$1`, conv).Scan(&raw); err != nil { + return err + } + var a findingReviewConclusion + if err := json.Unmarshal(raw, &a); err != nil { + return err + } + if len(a.Decisions) == 0 { + return errors.New("整理未通过验收:未提交 complete_finding_case_review 逐条根因判断;请查看执行记录") + } + // Recheck after the last model turn; concurrent evidence changes invalidate completion. + return s.saveFindingReviewConclusion(context.Background(), conv, a) +} + +// Require substantive sections and balanced fenced blocks. This validates report +// completeness, not the truth of a model's root-cause claim or a live exploit. +func validateUnifiedFindingReport(report string) error { + aliases := [][]string{{"概述", "overview", "summary"}, {"影响", "impact"}, {"前置", "prerequisite", "precondition"}, {"复现", "reproduction", "reproduce"}, {"证据", "evidence"}, {"poc", "完整脚本", "验证脚本", "proof of concept"}, {"根因", "root cause"}, {"修复", "remediation", "recommendation", "fix"}} + names := []string{"概述", "影响", "前置条件", "复现步骤", "证据", "PoC", "根因分析", "修复建议"} + found := make([]bool, len(aliases)) + current := -1 + hasContent := false + fence := "" + finish := func() { + if current >= 0 && hasContent { + found[current] = true + } + } + for _, line := range strings.Split(report, "\n") { + t := strings.TrimSpace(line) + if strings.HasPrefix(t, "```") || strings.HasPrefix(t, "~~~") { + marker := t[:3] + if fence == "" { + fence = marker + } else if fence == marker { + fence = "" + } + continue + } + if fence != "" { + if t != "" { + hasContent = true + } + continue + } + if strings.HasPrefix(t, "#") { + heading := strings.TrimLeft(t, "#") + if len(heading) == len(t) || !strings.HasPrefix(heading, " ") { + continue + } + // Subheadings belong to the current section unless they start another required section. + next := -1 + lower := strings.ToLower(heading) + for i, words := range aliases { + for _, word := range words { + if strings.Contains(lower, word) { + next = i + break + } + } + if next >= 0 { + break + } + } + if next >= 0 { + finish() + current = next + hasContent = false + } + continue + } + if t != "" && t != "---" && t != "***" && t != ">" { + hasContent = true + } + } + finish() + if fence != "" { + return errors.New("统一报告代码块未闭合,可能截断;请补全后再保存") + } + missing := []string{} + for i, ok := range found { + if !ok { + missing = append(missing, names[i]) + } + } + if len(missing) > 0 { + return fmt.Errorf("统一报告缺少有内容的章节:%s;请补全后保存", strings.Join(missing, "、")) + } + return nil +} + +func (s *Server) historicalFindingReviewSelection(ctx context.Context) ([]int64, error) { + conv := intercept.ConvIDFromContext(ctx) + if conv == 0 { + return nil, nil + } + var raw json.RawMessage + err := s.m.pg.QueryRow(`SELECT finding_ids FROM finding_case_review_runs WHERE conversation_id=$1`, conv).Scan(&raw) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + ids := []int64{} + err = json.Unmarshal(raw, &ids) + return ids, err +} diff --git a/server/finding_case_reviews_test.go b/server/finding_case_reviews_test.go new file mode 100644 index 0000000..06e1b58 --- /dev/null +++ b/server/finding_case_reviews_test.go @@ -0,0 +1,170 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "github.com/Autumn-27/artex/intercept" + "strings" + "testing" +) + +const completeFindingReviewReport = `# 同一权限缺陷 +## 概述 +同一对象权限检查缺失。 +## 影响 +已验证读取其他账号的订单。 +## 前置条件 +已存在的低权会话和订单编号。 +## 复现步骤 +对照自己的订单,再查看其他账号订单,响应证明同一检查缺失。 +## 证据 +原报告的保存响应及命令输出,未重新探测。 +## PoC +完整命令在原始证据中,此测试使用隔离材料。 +## 根因分析 +同一订单 handler 未验证订单所有者。 +## 修复建议 +统一验证请求者与订单所有者,并回归对象权限检查。 +` + +func TestUnifiedFindingReportRejectsIncompleteAndTruncated(t *testing.T) { + if err := validateUnifiedFindingReport(completeFindingReviewReport); err != nil { + t.Fatal(err) + } + for _, s := range []string{strings.Split(completeFindingReviewReport, "## 根因分析")[0], completeFindingReviewReport + "\n```bash\nunclosed", strings.Replace(completeFindingReviewReport, "统一验证请求者与订单所有者,并回归对象权限检查。", "", 1)} { + if err := validateUnifiedFindingReport(s); err == nil { + t.Fatal("incomplete report accepted") + } + } + // Section labels appearing inside a script are not real report sections. + if err := validateUnifiedFindingReport("## 概述\ntext\n```bash\n" + completeFindingReviewReport + "\n```"); err == nil { + t.Fatal("script comments counted as sections") + } +} + +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 { + t.Fatal(err) + } + conv := runs[0].ConversationID + defer s.m.pg.Exec(`DELETE FROM conversations WHERE id=$1`, conv) + if _, err := s.m.pg.Exec(`UPDATE finding_case_review_runs SET state='running' WHERE conversation_id=$1`, conv); err != nil { + t.Fatal(err) + } + ctx := intercept.WithConvID(context.Background(), conv) + a := findingReviewConclusion{Summary: "根据对象权限检查的具体证据判断"} + for _, id := range ids { + a.Decisions = append(a.Decisions, findingReviewDecision{FindingID: id, Verdict: "merged", Reason: "同一 handler", RootCauseEvidence: "各记录的 baseline/proof 指向同一对象所有者校验缺失"}) + } + if err := s.saveFindingReviewConclusion(ctx, conv, a); err == nil { + t.Fatal("unread findings accepted") + } + for _, id := range ids { + if _, err := s.performFindingCaseTool(ctx, "get_finding_record", findingCaseRequest{FindingID: json.RawMessage(fmt.Sprint(id))}); err != nil { + t.Fatal(err) + } + } + cid, err := s.m.pg.MergeFindingCase(ctx, task, ids, "orders", "same concrete check", "reporter") + if err != nil { + t.Fatal(err) + } + if err := s.saveFindingReviewConclusion(ctx, conv, a); err == nil { + t.Fatal("missing unified report accepted") + } + c, _ := s.m.pg.GetFindingCase(cid) + if err := s.m.pg.UpdateFindingCaseReport(ctx, cid, c.Version, "orders", completeFindingReviewReport, "high", "verified impact"); err != nil { + t.Fatal(err) + } + if err := s.saveFindingReviewConclusion(ctx, conv, a); err != nil { + t.Fatal(err) + } + if err := s.validateFindingReviewCompletion(conv); err != nil { + t.Fatal(err) + } + if err := s.checkCaseReviewScope(ctx, "update_finding_case_report", findingCaseRequest{CaseID: json.RawMessage(fmt.Sprint(cid))}); err == nil { + t.Fatal("sealed review can still mutate") + } + if _, err := s.m.pg.Exec(`UPDATE findings SET evidence='new proof' WHERE id=$1`, ids[0]); err != nil { + t.Fatal(err) + } + if err := s.validateFindingReviewCompletion(conv); err == nil { + t.Fatal("stale group reported completed") + } +} + +func TestFindingCaseReviewProtectsOriginalToolWrites(t *testing.T) { + s, f, _ := trafficEvidenceServer(t) + original, err := s.m.pg.GetFinding(f.FindingID) + if err != nil { + t.Fatal(err) + } + runs, err := s.m.pg.CreateFindingCaseReviews(t.Context(), map[int64][]int64{*original.TaskID: {original.ID}}) + if err != nil { + t.Fatal(err) + } + conv := runs[0].ConversationID + defer s.m.pg.Exec(`DELETE FROM conversations WHERE id=$1`, conv) + ctx := intercept.WithConvID(context.Background(), conv) + workflowCall(t, ctx, s.toolUpdateFindingReport(), map[string]any{"finding_id": f.NodeID, "report": "OVERWRITE", "evidence_version": original.EvidenceVersion}, true) + if err := s.agentFindingTrafficAccess(ctx, f.FindingID, true); err == nil { + t.Fatal("historical review can change evidence") + } + after, _ := s.m.pg.GetFinding(original.ID) + if after.Report != original.Report || after.EvidenceVersion != original.EvidenceVersion || after.Status != original.Status || after.Severity != original.Severity { + t.Fatal("original changed") + } + // Normal reporting is still able to write its own standalone report. + workflowCall(t, context.Background(), s.toolUpdateFindingReport(), map[string]any{"finding_id": f.NodeID, "report": "normal report", "evidence_version": original.EvidenceVersion}, false) +} + +func TestFindingCaseReviewSuggestionNeedsSavedSuggestion(t *testing.T) { + s, task, ids := testCaseServer(t) + runs, err := s.m.pg.CreateFindingCaseReviews(t.Context(), map[int64][]int64{task: ids[:2]}) + if err != nil { + t.Fatal(err) + } + conv := runs[0].ConversationID + defer s.m.pg.Exec(`DELETE FROM conversations WHERE id=$1`, conv) + s.m.pg.Exec(`UPDATE finding_case_review_runs SET state='running' WHERE conversation_id=$1`, conv) + ctx := intercept.WithConvID(context.Background(), conv) + a := findingReviewConclusion{Summary: "需人工确认共同组件"} + for _, id := range ids[:2] { + s.performFindingCaseTool(ctx, "get_finding_record", findingCaseRequest{FindingID: json.RawMessage(fmt.Sprint(id))}) + a.Decisions = append(a.Decisions, findingReviewDecision{FindingID: id, Verdict: "suggested", Reason: "同一边界但根因未确认", RootCauseEvidence: "原报告指向对象权限边界,缺少共同组件证据"}) + } + if err := s.saveFindingReviewConclusion(ctx, conv, a); err == nil { + t.Fatal("unpersisted suggestion accepted") + } + if _, err := s.m.pg.SuggestFindingCase(ctx, task, ids[0], ids[1], "orders", "common component unknown"); err != nil { + t.Fatal(err) + } + if err := s.saveFindingReviewConclusion(ctx, conv, a); err != nil { + t.Fatal(err) + } +} + +func TestFindingCaseReviewCandidatesStayWithinSelection(t *testing.T) { + s, task, ids := testCaseServer(t) + runs, err := s.m.pg.CreateFindingCaseReviews(t.Context(), map[int64][]int64{task: ids[:2]}) + if err != nil { + t.Fatal(err) + } + conv := runs[0].ConversationID + defer s.m.pg.Exec(`DELETE FROM conversations WHERE id=$1`, conv) + ctx := intercept.WithConvID(context.Background(), conv) + a := findingCaseRequest{FindingID: json.RawMessage(fmt.Sprint(ids[0]))} + v, err := s.performFindingCaseTool(ctx, "search_finding_duplicates", a) + if err != nil { + t.Fatal(err) + } + if v.(map[string]any)["total"].(int) != 1 { + t.Fatal("unselected candidates leaked", v) + } + v, err = s.performFindingCaseTool(context.Background(), "search_finding_duplicates", a) + if err != nil || v.(map[string]any)["total"].(int) != 2 { + t.Fatal("normal candidate search changed", v, err) + } +} diff --git a/server/finding_cases.go b/server/finding_cases.go index c2ba1e0..a2a3415 100644 --- a/server/finding_cases.go +++ b/server/finding_cases.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "net/http" - "strconv" "strings" "github.com/Autumn-27/artex/db" @@ -38,7 +37,7 @@ func (a findingCaseRequest) ids() []int64 { } func caseHTTPError(w http.ResponseWriter, err error) { code := 400 - if errors.Is(err, db.ErrFindingCaseConflict) { + if errors.Is(err, db.ErrFindingCaseConflict) || errors.Is(err, db.ErrFindingCaseReviewBusy) { code = 409 } writeErr(w, code, err.Error()) @@ -211,6 +210,10 @@ func (s *Server) updateCaseReport(w http.ResponseWriter, r *http.Request) { if !decodeCaseRequest(w, r, &a) { return } + if err := validateUnifiedFindingReport(a.Report); err != nil { + caseHTTPError(w, err) + return + } if err := s.m.pg.UpdateFindingCaseReport(r.Context(), c.ID, a.Version, a.Title, a.Report, a.Severity, a.SeverityReason); err != nil { caseHTTPError(w, err) return @@ -260,6 +263,7 @@ func (s *Server) findingCaseTools() []actool.CoreTool { out = append(out, wrTool(spec.name, spec.desc, schema, handler)) } } + out = append(out, s.toolCompleteFindingCaseReview()) return out } func (s *Server) performFindingCaseTool(ctx context.Context, name string, a findingCaseRequest) (any, error) { @@ -274,7 +278,15 @@ func (s *Server) performFindingCaseTool(ctx context.Context, name string, a find } switch name { case "search_finding_duplicates": - items, total, err := s.m.pg.FindingCaseCandidates(fid, page, 20) + scope, err := s.historicalFindingReviewSelection(ctx) + if err != nil { + return nil, err + } + var scopes [][]int64 + if scope != nil { + scopes = append(scopes, scope) + } + items, total, err := s.m.pg.FindingCaseCandidates(fid, page, 20, scopes...) return map[string]any{"items": items, "total": total, "page": page, "page_size": 20}, err case "get_finding_record": f, err := s.m.pg.GetFinding(fid) @@ -292,6 +304,11 @@ func (s *Server) performFindingCaseTool(ctx context.Context, name string, a find if cid > 0 { dto.CaseID = i64s(cid) } + if conv := intercept.ConvIDFromContext(ctx); conv > 0 { + if _, err := s.m.pg.Exec(`UPDATE finding_case_review_runs SET reviewed_ids=CASE WHEN reviewed_ids @> jsonb_build_array($2::bigint) THEN reviewed_ids ELSE reviewed_ids || jsonb_build_array($2::bigint) END WHERE conversation_id=$1 AND finding_ids @> jsonb_build_array($2::bigint)`, conv, f.ID); err != nil { + return nil, err + } + } return map[string]any{"finding": dto, "finding_node_id": f.NodeID}, nil case "merge_finding_records": id, err := s.m.pg.MergeFindingCase(ctx, parseProfileID(a.TaskID), a.ids(), a.Title, a.Reason, "reporter") @@ -318,6 +335,9 @@ func (s *Server) performFindingCaseTool(ctx context.Context, name string, a find } return map[string]any{"case": c, "members": members, "total": total, "page": page, "page_size": 20}, err case "update_finding_case_report": + if err := validateUnifiedFindingReport(a.Report); err != nil { + return nil, err + } err := s.m.pg.UpdateFindingCaseReport(ctx, cid, a.Version, a.Title, a.Report, a.Severity, a.SeverityReason) return map[string]bool{"ok": err == nil}, err } @@ -332,17 +352,20 @@ func (s *Server) seedFindingCaseTools() { if err != nil || a == nil { return } - const flag = "finding_case_tools_v1" + const flag = "finding_case_tools_v2_review" if v, _, _ := s.m.pg.GetSetting(flag); v == "true" { return } names := []string{} + previous, _, _ := s.m.pg.GetSetting("finding_case_tools_v1") for _, t := range s.findingCaseTools() { schema, _ := json.Marshal(t.InputSchema()) if err := s.m.pg.SeedTool(t.Name(), t.Description(), schema, json.RawMessage(`["reporter"]`)); err != nil { return } - names = append(names, t.Name()) + if previous != "true" || t.Name() == "complete_finding_case_review" { + names = append(names, t.Name()) + } } if err := s.m.pg.AddAgentToToolBinding("reporter", names); err != nil { return @@ -393,39 +416,36 @@ func (s *Server) reviewFindingCases(w http.ResponseWriter, r *http.Request) { } batches[*f.TaskID] = append(batches[*f.TaskID], fid) } - // Validate every source before starting any batch. + // Validate and snapshot every source before starting any batch. + taskContexts := map[int64]triggeredRun{} for taskID := range batches { - if _, ok := s.m.Task(i64s(taskID)); !ok { - writeErr(w, 409, "任务不可用") - return - } - } - runs := []map[string]any{} - for taskID, fids := range batches { - t, ok := s.m.Task(strconv.FormatInt(taskID, 10)) + t, ok := s.m.Task(i64s(taskID)) if !ok { writeErr(w, 409, "任务不可用") return } + + taskContexts[taskID] = triggeredRun{taskDesc: t.Description, taskGoal: t.Goal} + } + reservations, err := s.m.pg.CreateFindingCaseReviews(r.Context(), batches) + if err != nil { + caseHTTPError(w, err) + return + } + runs := []map[string]any{} + for _, reservation := range reservations { + taskID, fids := reservation.TaskID, reservation.FindingIDs + taskContext := taskContexts[taskID] raw, _ := json.Marshal(fids) - msg := fmt.Sprintf("人工选择整理:仅允许处理本任务所选 finding_ids=%s。先保留/生成各条独立报告,再在所选范围内判断同一缺陷,明确则归并,不确定提交建议,已成组则更新统一报告。不得归并未选择的记录,不探测目标。", raw) - c, err := s.m.pg.CreateConversation("reporter", "漏洞整理 · task#"+t.ID, nil) - if err != nil { - caseHTTPError(w, err) - return - } - if _, err := s.m.pg.Exec(`INSERT INTO finding_case_review_runs(conversation_id,task_id,finding_ids) VALUES($1,$2,$3)`, c.ID, taskID, string(raw)); err != nil { - caseHTTPError(w, err) - return - } - item := triggeredRun{agentKey: "reporter", title: c.Title, message: msg, taskID: taskID, taskDesc: t.Description, taskGoal: t.Goal, conversationID: c.ID} + msg := fmt.Sprintf("人工选择整理(只读原始记录):本任务所选 finding_ids=%s。逐条读取已有报告,仅判断同一实际根因,明确则归并,不确定提交建议;已有组更新统一报告。禁止生成或覆盖原始报告、改评级状态、补绑证据,不归并未选记录,不探测目标。结束前调用 complete_finding_case_review 提交逐条判断并通过验收。", raw) + item := triggeredRun{agentKey: "reporter", title: "漏洞整理 · task#" + i64s(taskID), message: msg, taskID: taskID, taskDesc: taskContext.taskDesc, taskGoal: taskContext.taskGoal, conversationID: reservation.ConversationID} cfg := s.readTriggerBehavior("reporter") s.queueMu.Lock() s.triggerCfg["reporter"] = cfg s.triggerQ["reporter"] = append(s.triggerQ["reporter"], item) s.pumpLocked("reporter") s.queueMu.Unlock() - runs = append(runs, map[string]any{"conversation_id": c.ID, "task_id": i64s(taskID)}) + runs = append(runs, map[string]any{"conversation_id": reservation.ConversationID, "task_id": i64s(taskID)}) } writeJSON(w, 202, map[string]any{"runs": runs}) } @@ -460,8 +480,8 @@ func (s *Server) checkCaseReviewScope(ctx context.Context, name string, a findin if conv == 0 { return nil } - var raw json.RawMessage - err := s.m.pg.QueryRow(`SELECT finding_ids FROM finding_case_review_runs WHERE conversation_id=$1`, conv).Scan(&raw) + var raw, conclusion json.RawMessage + err := s.m.pg.QueryRow(`SELECT finding_ids,conclusion FROM finding_case_review_runs WHERE conversation_id=$1`, conv).Scan(&raw, &conclusion) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil @@ -472,16 +492,22 @@ func (s *Server) checkCaseReviewScope(ctx context.Context, name string, a findin if err := json.Unmarshal(raw, &ids); err != nil { return err } + if string(conclusion) != "{}" && (name == "merge_finding_records" || name == "suggest_finding_merge" || name == "update_finding_case_report") { + return errors.New("整理判断已提交,不能在验收后继续修改") + } allowed := map[int64]bool{} for _, id := range ids { allowed[id] = true } switch name { - case "merge_finding_records", "suggest_finding_merge", "update_finding_report": - checkIDs := a.ids() - if name == "update_finding_report" { - checkIDs = []int64{parseProfileID(a.FindingID)} + case "search_finding_duplicates": + if !allowed[parseProfileID(a.FindingID)] { + return errors.New("历史整理只能检索所选记录的候选") } + case "update_finding_report", "bind_finding_traffic", "report_finding": + return errors.New("历史整理禁止修改原始报告、上报或证据;只能写归并关系及统一报告") + case "merge_finding_records", "suggest_finding_merge": + checkIDs := a.ids() for _, id := range checkIDs { if !allowed[id] { return errors.New("人工整理只能归并所选记录") diff --git a/server/finding_cases_test.go b/server/finding_cases_test.go index 3754c0f..91438fa 100644 --- a/server/finding_cases_test.go +++ b/server/finding_cases_test.go @@ -87,7 +87,7 @@ func TestFindingCaseAPIPaginationAndTools(t *testing.T) { } c := result.(map[string]any)["case"].(*db.FindingCase) a.Version = c.Version - a.Report = "complete reproducible report" + a.Report = completeFindingReviewReport a.Severity = "high" a.SeverityReason = "verified data disclosure" if _, err := s.performFindingCaseTool(ctx, "update_finding_case_report", a); err != nil { @@ -200,8 +200,8 @@ func TestFindingCaseSelectedReviewCannotRewriteOtherOriginal(t *testing.T) { if err := s.checkCaseReviewScope(ctx, "update_finding_report", findingCaseRequest{FindingID: json.RawMessage(fmt.Sprint(ids[1]))}); err == nil { t.Fatal("unselected original allowed") } - if err := s.checkCaseReviewScope(ctx, "update_finding_report", findingCaseRequest{FindingID: json.RawMessage(fmt.Sprint(ids[0]))}); err != nil { - t.Fatal(err) + if err := s.checkCaseReviewScope(ctx, "update_finding_report", findingCaseRequest{FindingID: json.RawMessage(fmt.Sprint(ids[0]))}); err == nil { + t.Fatal("selected original was allowed to be overwritten") } } diff --git a/server/finding_workflow.go b/server/finding_workflow.go index 3610753..277ff0a 100644 --- a/server/finding_workflow.go +++ b/server/finding_workflow.go @@ -116,6 +116,11 @@ func objectProperty(parent map[string]any, key string) map[string]any { } func (s *Server) agentFindingTrafficAccess(ctx context.Context, id int64, write bool) error { + if write { + if err := s.checkCaseReviewScope(ctx, "bind_finding_traffic", findingCaseRequest{}); err != nil { + return err + } + } if id <= 0 { return errors.New("finding_id 必须为独立漏洞记录 ID;不是探索节点 ID") } diff --git a/web/src/components/finding-case-list.tsx b/web/src/components/finding-case-list.tsx index 993e9f3..a2b9c40 100644 --- a/web/src/components/finding-case-list.tsx +++ b/web/src/components/finding-case-list.tsx @@ -4,7 +4,7 @@ import * as React from "react"; import Link from "next/link"; -import { ChevronRightIcon, FolderIcon } from "lucide-react"; +import { ChevronRightIcon, FolderIcon, FolderOpenIcon } from "lucide-react"; import { toast } from "sonner"; import { StatusBadge } from "@/components/status-badge"; @@ -12,8 +12,9 @@ import { TablePagination } from "@/components/table-pagination"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Spinner } from "@/components/ui/spinner"; import { api } from "@/lib/api"; import type { @@ -55,18 +56,23 @@ export function FindingCaseMemberRow({ onSelect, contextTask, matched = true, + nested = false, }: { finding: Finding; selected?: boolean; onSelect?: (id: string, checked: boolean) => void; contextTask?: string; matched?: boolean; + nested?: boolean; }) { const id = f.finding_id ?? f.id; return (
{s.reason}
+ ) : null} +{r.error}
: null} +待确认归并建议({suggestions.length})· 确认前原始记录分别保留
+ ) : null} {suggestions.map((s) => ( -原始子报告 · 各自等级和证据保留