From 24d4726aeea638ef5ae3bd01d288a4ffc49187ce Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:43:53 +0800 Subject: [PATCH] feat(findings): consolidate duplicate reports into expandable cases --- CHANGELOG.md | 7 + README.md | 10 + agent/assembly.go | 3 + agent/deferred.go | 2 +- agent/finding_cases.go | 22 + agent/finding_cases_test.go | 31 + db/finding_cases.go | 567 ++++++++++++++++++ db/finding_cases_test.go | 291 +++++++++ db/findings.go | 5 + db/schema.sql | 87 +++ report/findings.go | 12 +- server/conversations.go | 45 +- server/dto.go | 11 + server/finding_cases.go | 506 ++++++++++++++++ server/finding_cases_export.go | 124 ++++ server/finding_cases_test.go | 323 ++++++++++ server/finding_traffic_export.go | 5 + server/orchestration.go | 16 +- server/server.go | 42 ++ .../(main)/function/findings/case/page.tsx | 242 ++++++++ .../(main)/function/findings/detail/page.tsx | 13 +- web/src/app/(main)/function/findings/page.tsx | 65 +- .../tasks/detail/_tabs/findings-tab.tsx | 80 ++- web/src/app/(main)/function/tasks/page.tsx | 22 +- web/src/app/globals.css | 12 + web/src/components/finding-case-list.tsx | 425 +++++++++++++ web/src/lib/api.ts | 47 +- web/src/lib/mock/handler.ts | 29 + web/src/lib/types.ts | 70 +++ 29 files changed, 3051 insertions(+), 63 deletions(-) create mode 100644 agent/finding_cases.go create mode 100644 agent/finding_cases_test.go create mode 100644 db/finding_cases.go create mode 100644 db/finding_cases_test.go create mode 100644 server/finding_cases.go create mode 100644 server/finding_cases_export.go create mode 100644 server/finding_cases_test.go create mode 100644 web/src/app/(main)/function/findings/case/page.tsx create mode 100644 web/src/components/finding-case-list.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 01e592c..2c8cbbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ ## [Unreleased] +### 同一漏洞的重复上报归并(保持程序版本 0.3.15) + +- Reporter 在生成独立报告后按同任务候选证据判断同一实际缺陷,明确重复时归并,证据不足时提交人工确认建议。 +- 新增可展开漏洞文件夹、统一报告、原始等级分布、独立漏洞/上报记录统计及归并历史;每条原报告、证据和流量继续保留。 +- 支持手动整理所选历史记录、移出成员与防止自动再次归并,继承任务保持只读。 +- 统一报告采用证据版本校验;导出默认统一报告,可包含原始子报告,未生成或过期时明确提示并保留原材料。 + ### 部署与发布维护(保持程序版本 0.3.15) - 迁移至独立的 `RuoJi6/ARTEX` 仓库,保留原提交历史与分支。 diff --git a/README.md b/README.md index 6f73fc4..2209e3d 100644 --- a/README.md +++ b/README.md @@ -314,6 +314,16 @@ server { ## 开发 +### 同一漏洞归并与统一报告 + +发现页默认「按漏洞」显示。同一任务内,Reporter 依据目标、接口参数、权限边界、根因及验证证据确认同一实际缺陷后形成文件夹;仅类型或名称相同不会作为自动归并依据。严重程度不同仍可能是同一漏洞。文件夹的 `严/高/中/低` 数量按原始记录统计,例如 `0/1/1/1`;独立漏洞统计采用另行评估的统一等级,尚未评估时显示「待评估」。原始上报内容、评级、状态、报告及证据不被覆盖。 + +点击箭头展开原始记录,点击文件夹标题查看统一报告、原始记录及归并历史。证据不足的建议需要点击「确认归并」;否决或移出后,这些关系不会被 Agent 自动再次归并。移出成员不删除原漏洞,不足两条时解散文件夹并保留历史。继承任务的漏洞组保持只读。 + +新上报由现有 Reporter 生成独立报告并检查重复。升级首次补充 Reporter 的归并工具和运行指引,保留自定义提示词及触发设置;已删除或停用的 Reporter 不会重建、启用。历史记录不会自动扫描:展开并选择原始记录,点击「整理所选」,按任务分别排队执行,可查看执行记录及失败信息。文件夹详情可重新生成统一报告;需要启用 Reporter 并配置可用模型。判断和写作会消耗模型 token,候选检索先分页返回摘要,详情证据按需读取,不重新探测目标。 + +成员、报告或证据变化后统一报告标记待更新,写入时校验实际读取版本。导出默认每组一份统一报告及未归并记录,可勾选「包含原始子报告」;统一报告未生成或过期时附明确说明及全部原始材料。原始记录、按任务及按资产视图继续保留。升级沿用启动时幂等数据库迁移,无需执行独立 SQL。 + ### 手动漏洞复测 任务详情的「复测」页签可分页选择本任务的漏洞、查看历次结论和证据,并手动发起复测。启动后保留当前页签,显示转圈图标和「复测中」;确认修复后同步更新漏洞状态。 diff --git a/agent/assembly.go b/agent/assembly.go index d28f207..ae59901 100644 --- a/agent/assembly.go +++ b/agent/assembly.go @@ -16,6 +16,8 @@ import ( // UnlockSkill unlocks a named skill's MCPs — hosts call it to rebuild the unlock set // from history on a resumed session (design doc C2). type DeferredInfo struct { + CaseGuidance string // independent of optional traffic binding + FindingGuidance string // derived from the final permitted tools, including DB overrides Deferred []string // all MCP tool names (schema withheld) GlobalNames []string // MCP names to list in the system-prompt block @@ -61,6 +63,7 @@ func AugmentTools(ctx context.Context, agentKey string, base []actool.CoreTool) out = ToolResolve(ctx, agentKey, out) } out, def.FindingGuidance = findingWorkflowTools(agentKey, out) + out, def.CaseGuidance = findingCaseWorkflow(agentKey, out) for i, t := range out { out[i] = guardPanic(t) } diff --git a/agent/deferred.go b/agent/deferred.go index 3789680..fb2adb0 100644 --- a/agent/deferred.go +++ b/agent/deferred.go @@ -16,7 +16,7 @@ import ( // skill loads. Returns a single plain segment + boundary 0 when there is no global // block to add. func deferredSystem(sysText string, def DeferredInfo) (system []string, boundary int) { - sysText += def.FindingGuidance + sysText += def.FindingGuidance + def.CaseGuidance block := actool.RenderDeferredToolsBlock(def.GlobalNames) if block == "" { return []string{sysText}, 0 diff --git a/agent/finding_cases.go b/agent/finding_cases.go new file mode 100644 index 0000000..3a970fc --- /dev/null +++ b/agent/finding_cases.go @@ -0,0 +1,22 @@ +package agent + +import actool "github.com/Autumn-27/norma/tool" + +// This guidance is appended to the runtime prompt, including editable old prompts. +// Never reintroduce unbound tools or recreate a deleted Reporter. +func findingCaseWorkflow(key string, tools []actool.CoreTool) ([]actool.CoreTool, string) { + if key != "reporter" { + return tools, "" + } + has := map[string]bool{} + for _, t := range tools { + has[t.Name()] = true + } + if !has["search_finding_duplicates"] || !has["merge_finding_records"] || !has["get_finding_case"] || !has["update_finding_case_report"] { + return tools, "" + } + 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重试。各成员仍有独立报告、评级和证据。没有明确重复则保留独立记录。工具被关闭时不绕过配置。` +} diff --git a/agent/finding_cases_test.go b/agent/finding_cases_test.go new file mode 100644 index 0000000..c52cd50 --- /dev/null +++ b/agent/finding_cases_test.go @@ -0,0 +1,31 @@ +package agent + +import ( + "context" + "encoding/json" + "strings" + "testing" + + actool "github.com/Autumn-27/norma/tool" +) + +func TestFindingCaseGuidanceRespectsToolsAndTrafficSwitch(t *testing.T) { + tools := []actool.CoreTool{} + for _, name := range []string{"search_finding_duplicates", "merge_finding_records", "get_finding_case", "update_finding_case_report"} { + tools = append(tools, writeTool(name, name, obj(nil), func(context.Context, json.RawMessage) (actool.Result, error) { return actool.Text("ok"), nil })) + } + _, guide := findingCaseWorkflow("reporter", tools) + if !strings.Contains(guide, "不能直接取成员最高等级") || !strings.Contains(guide, "独立报告") { + t.Fatal("missing preservation rules") + } + if _, guide := findingCaseWorkflow("worker", tools); guide != "" { + t.Fatal("worker changed") + } + if _, guide := findingCaseWorkflow("reporter", tools[:2]); guide != "" { + t.Fatal("unbound tools reintroduced") + } + system, _ := deferredSystem("CUSTOM PROMPT", DeferredInfo{CaseGuidance: guide}) + if !strings.HasPrefix(system[0], "CUSTOM PROMPT") || !strings.Contains(system[0], guide) { + t.Fatal("custom prompt lost") + } +} diff --git a/db/finding_cases.go b/db/finding_cases.go new file mode 100644 index 0000000..e87b48e --- /dev/null +++ b/db/finding_cases.go @@ -0,0 +1,567 @@ +package db + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "time" +) + +var ErrFindingCaseConflict = errors.New("漏洞组已变更,请重新读取证据后再操作") + +type FindingCase struct { + ID int64 `json:"id,string"` + TaskID *int64 `json:"task_id,string"` + Title string `json:"title"` + Reason string `json:"reason"` + Severity string `json:"severity"` + SeverityReason string `json:"severity_reason"` + Report string `json:"report,omitempty"` + Version int64 `json:"version"` + ReportVersion int64 `json:"report_version"` + Active bool `json:"active"` + Count int `json:"count"` + Critical int `json:"critical"` + High int `json:"high"` + Medium int `json:"medium"` + Low int `json:"low"` + CreatedAt time.Time `json:"created_at"` +} +type FindingCaseEvent struct { + ID int64 `json:"id,string"` + Action string `json:"action"` + Actor string `json:"actor"` + Reason string `json:"reason"` + FindingIDs json.RawMessage `json:"finding_ids"` + CreatedAt time.Time `json:"created_at"` +} +type FindingCaseSuggestion struct { + ID int64 `json:"id,string"` + TaskID int64 `json:"task_id,string"` + LeftID int64 `json:"left_id,string"` + RightID int64 `json:"right_id,string"` + Title string `json:"title"` + Reason string `json:"reason"` + State string `json:"state"` +} +type FindingCaseRow struct { + Case *FindingCase `json:"case,omitempty"` + FindingID int64 `json:"finding_id,string,omitempty"` + MatchedIDs []int64 `json:"matched_ids"` +} + +const caseColumns = `c.id,c.task_id,c.title,c.reason,c.severity,c.severity_reason,c.report,c.version,c.report_version,c.active,c.created_at` + +func scanCase(row interface{ Scan(...any) error }) (*FindingCase, error) { + c := new(FindingCase) + err := row.Scan(&c.ID, &c.TaskID, &c.Title, &c.Reason, &c.Severity, &c.SeverityReason, &c.Report, &c.Version, &c.ReportVersion, &c.Active, &c.CreatedAt) + return c, err +} +func (d *DB) GetFindingCase(id int64) (*FindingCase, error) { + c, err := scanCase(d.QueryRow(`SELECT `+caseColumns+` FROM finding_cases c WHERE id=$1`, id)) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + err = d.QueryRow(`SELECT count(*),count(*) FILTER(WHERE severity='critical'),count(*) FILTER(WHERE severity='high'),count(*) FILTER(WHERE severity='medium'),count(*) FILTER(WHERE severity='low') FROM findings f JOIN finding_case_members m ON m.finding_id=f.id WHERE m.case_id=$1`, id).Scan(&c.Count, &c.Critical, &c.High, &c.Medium, &c.Low) + return c, err +} +func (d *DB) FindingCaseID(id int64) (int64, error) { + var cid int64 + err := d.QueryRow(`SELECT case_id FROM finding_case_members WHERE finding_id=$1`, id).Scan(&cid) + if errors.Is(err, sql.ErrNoRows) { + return 0, nil + } + return cid, err +} +func (d *DB) FindingCaseMembers(id int64, page, size int) ([]*DBFinding, int, error) { + var count int + if err := d.QueryRow(`SELECT count(*) FROM finding_case_members WHERE case_id=$1`, id).Scan(&count); err != nil { + return nil, 0, err + } + rows, err := d.Query(`SELECT `+findingSelectCols+` FROM findings f LEFT JOIN tasks t ON t.id=f.task_id JOIN finding_case_members m ON m.finding_id=f.id WHERE m.case_id=$1 ORDER BY f.id LIMIT $2 OFFSET $3`, id, size, (page-1)*size) + if err != nil { + return nil, 0, err + } + defer rows.Close() + fs, err := scanFindings(rows) + return fs, count, err +} +func (d *DB) FindingCaseEvents(id int64) ([]FindingCaseEvent, error) { + rows, err := d.Query(`SELECT id,action,actor,reason,finding_ids,created_at FROM finding_case_events WHERE case_id=$1 ORDER BY id DESC`, id) + if err != nil { + return nil, err + } + defer rows.Close() + out := []FindingCaseEvent{} + for rows.Next() { + var e FindingCaseEvent + if err := rows.Scan(&e.ID, &e.Action, &e.Actor, &e.Reason, &e.FindingIDs, &e.CreatedAt); err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} +func (d *DB) ListFindingCases(filter FindingFilter, page, size int) ([]FindingCaseRow, int, error) { + var err error + filter, err = d.applyAssetScope(filter) + if err != nil { + return nil, 0, err + } + where, args := filter.where() + base := `WITH matched AS (SELECT f.id,f.severity,f.created_at,COALESCE(m.case_id,0) cid FROM findings f LEFT JOIN tasks t ON t.id=f.task_id LEFT JOIN finding_case_members m ON m.finding_id=f.id` + where + `), entities AS (SELECT CASE WHEN cid>0 THEN 'c:'||cid ELSE 'f:'||id END key,MAX(cid) cid,MIN(id) fid,jsonb_agg(id ORDER BY id) matched_ids,MAX(created_at) created FROM matched GROUP BY key)` + var total int + if err := d.QueryRow(base+` SELECT count(*) FROM entities`, args...).Scan(&total); err != nil { + return nil, 0, err + } + order := `e.created DESC,e.fid DESC` + if filter.Sort == "severity" { + order = `CASE COALESCE(c.severity,f.severity) WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END DESC,` + order + } + args = append(args, size, (page-1)*size) + rows, err := d.Query(base+fmt.Sprintf(` SELECT e.cid,e.fid,e.matched_ids FROM entities e LEFT JOIN finding_cases c ON c.id=e.cid LEFT JOIN findings f ON f.id=e.fid ORDER BY %s LIMIT $%d OFFSET $%d`, order, len(args)-1, len(args)), args...) + if err != nil { + return nil, 0, err + } + out := []FindingCaseRow{} + groups := []int64{} + for rows.Next() { + var cid, fid int64 + var raw []byte + if err := rows.Scan(&cid, &fid, &raw); err != nil { + rows.Close() + return nil, 0, err + } + r := FindingCaseRow{FindingID: fid} + if err := json.Unmarshal(raw, &r.MatchedIDs); err != nil { + rows.Close() + return nil, 0, err + } + out = append(out, r) + groups = append(groups, cid) + } + err = rows.Err() + rows.Close() + if err != nil { + return nil, 0, err + } + for i, cid := range groups { + if cid > 0 { + c, err := scanCase(d.QueryRow(`SELECT `+strings.Replace(caseColumns, "c.report,", "''::text,", 1)+` FROM finding_cases c WHERE id=$1`, cid)) + if err != nil { + return nil, 0, err + } + if err := d.QueryRow(`SELECT count(*),count(*) FILTER(WHERE severity='critical'),count(*) FILTER(WHERE severity='high'),count(*) FILTER(WHERE severity='medium'),count(*) FILTER(WHERE severity='low') FROM findings f JOIN finding_case_members m ON m.finding_id=f.id WHERE m.case_id=$1`, cid).Scan(&c.Count, &c.Critical, &c.High, &c.Medium, &c.Low); err != nil { + return nil, 0, err + } + out[i].Case = c + out[i].FindingID = 0 + } + } + return out, total, nil +} + +func sortedFindingIDs(ids []int64) ([]int64, error) { + set := map[int64]bool{} + out := []int64{} + for _, id := range ids { + if id <= 0 { + return nil, errors.New("无效漏洞编号") + } + if !set[id] { + set[id] = true + out = append(out, id) + } + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + if len(out) < 2 { + return nil, errors.New("至少需要两条漏洞记录") + } + return out, nil +} +func lockCaseTask(tx *sql.Tx, taskID int64) error { + if taskID <= 0 { + return errors.New("归并需要仍存在的同一任务") + } + if _, err := tx.Exec(`SELECT pg_advisory_xact_lock($1)`, int64(5100000000000000)+taskID); err != nil { + return err + } + var id int64 + return tx.QueryRow(`SELECT id FROM tasks WHERE id=$1 FOR UPDATE`, taskID).Scan(&id) +} +func (d *DB) MergeFindingCase(ctx context.Context, taskID int64, ids []int64, title, reason, actor string) (int64, error) { + return d.mergeFindingCase(ctx, taskID, ids, title, reason, actor, 0) +} +func (d *DB) mergeFindingCase(ctx context.Context, taskID int64, ids []int64, title, reason, actor string, suggestionID int64) (int64, error) { + ids, err := sortedFindingIDs(ids) + if err != nil { + return 0, err + } + if strings.TrimSpace(title) == "" || strings.TrimSpace(reason) == "" { + return 0, errors.New("必须提供漏洞名称及基于证据的归并理由") + } + var cid int64 + err = d.WithEvidenceTx(ctx, func(tx *sql.Tx) error { + if err := lockCaseTask(tx, taskID); err != nil { + return err + } + if suggestionID > 0 { + var state string + if err := tx.QueryRow(`SELECT state FROM finding_case_suggestions WHERE id=$1 FOR UPDATE`, suggestionID).Scan(&state); err != nil { + return err + } + if state != "pending" { + return ErrFindingCaseConflict + } + } + groupSet := map[int64]bool{} + for _, id := range ids { + var tid sql.NullInt64 + var group int64 + if err := tx.QueryRow(`SELECT f.task_id,COALESCE(m.case_id,0) FROM findings f LEFT JOIN finding_case_members m ON m.finding_id=f.id WHERE f.id=$1`, id).Scan(&tid, &group); err != nil { + return err + } + if !tid.Valid || tid.Int64 != taskID { + return errors.New("只能归并同一任务的漏洞") + } + if group > 0 { + groupSet[group] = true + } + } + groups := []int64{} + for g := range groupSet { + groups = append(groups, g) + } + sort.Slice(groups, func(i, j int) bool { return groups[i] < groups[j] }) + all := map[int64]bool{} + for _, id := range ids { + all[id] = true + } + for _, g := range groups { + rows, err := tx.Query(`SELECT finding_id FROM finding_case_members WHERE case_id=$1`, g) + if err != nil { + return err + } + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + rows.Close() + return err + } + all[id] = true + } + err = rows.Err() + rows.Close() + if err != nil { + return err + } + } + ids = ids[:0] + for id := range all { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + raw, _ := json.Marshal(ids) + var blocked bool + if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 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)))`, string(raw)).Scan(&blocked); err != nil { + return err + } + if blocked { + return errors.New("人工已否决或撤销这些记录的归并") + } + if len(groups) > 0 { + cid = groups[0] + } else { + if err := tx.QueryRow(`INSERT INTO finding_cases(task_id,origin_task_id,title,reason) VALUES($1,$1,$2,$3) RETURNING id`, taskID, title, reason).Scan(&cid); err != nil { + return err + } + } + var changed int64 + for _, id := range ids { + res, err := tx.Exec(`INSERT INTO finding_case_members(finding_id,case_id) VALUES($1,$2) ON CONFLICT(finding_id) DO UPDATE SET case_id=EXCLUDED.case_id WHERE finding_case_members.case_id<>EXCLUDED.case_id`, id, cid) + if err != nil { + return err + } + n, _ := res.RowsAffected() + changed += n + } + for _, g := range groups { + if g != cid { + if _, err := tx.Exec(`UPDATE finding_cases SET active=false,version=version+1 WHERE id=$1`, g); err != nil { + return err + } + } + } + if changed > 0 { + if _, err := tx.Exec(`UPDATE finding_cases SET version=version+1 WHERE id=$1`, cid); err != nil { + return err + } + if _, err := tx.Exec(`INSERT INTO finding_case_events(case_id,action,actor,reason,finding_ids) VALUES($1,'merge',$2,$3,$4)`, cid, actor, reason, string(raw)); err != nil { + return err + } + } + _, err := tx.Exec(`UPDATE finding_case_suggestions SET state='accepted' WHERE state='pending' AND left_id IN(SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb)) AND right_id IN(SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb))`, string(raw)) + return err + }) + return cid, err +} +func (d *DB) RemoveFindingCaseMember(ctx context.Context, cid, fid int64, reason string) error { + c, err := d.GetFindingCase(cid) + if err != nil { + return err + } + if c == nil { + return errors.New("归并组不存在") + } + return d.WithEvidenceTx(ctx, func(tx *sql.Tx) error { + var origin int64 + if err := tx.QueryRow(`SELECT origin_task_id FROM finding_cases WHERE id=$1`, cid).Scan(&origin); err != nil { + return err + } + if _, err := tx.Exec(`SELECT pg_advisory_xact_lock($1)`, int64(5100000000000000)+origin); err != nil { + return err + } + // Deleting a task retains its findings and folder history. + var live int64 + if err := tx.QueryRow(`SELECT id FROM tasks WHERE id=$1 FOR UPDATE`, origin).Scan(&live); err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + var current int64 + if err := tx.QueryRow(`SELECT case_id FROM finding_case_members WHERE finding_id=$1`, fid).Scan(¤t); err != nil { + if errors.Is(err, sql.ErrNoRows) { + var removed bool + // A repeated removal, or a last member released by dissolution, is idempotent. + err = tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_events WHERE case_id=$1 AND finding_ids @> jsonb_build_array($2::bigint))`, cid, fid).Scan(&removed) + if err == nil && removed { + return nil + } + } + return err + } + if current != cid { + return ErrFindingCaseConflict + } + if _, err := tx.Exec(`INSERT INTO finding_case_blocks(left_id,right_id,reason) SELECT LEAST($1,finding_id),GREATEST($1,finding_id),$3 FROM finding_case_members WHERE case_id=$2 AND finding_id<>$1 ON CONFLICT DO NOTHING`, fid, cid, reason); err != nil { + return err + } + if _, err := tx.Exec(`INSERT INTO finding_case_events(case_id,action,actor,reason,finding_ids) VALUES($1,'detach','human',$2,jsonb_build_array($3::bigint))`, cid, reason, fid); err != nil { + return err + } + _, err := tx.Exec(`DELETE FROM finding_case_members WHERE case_id=$1 AND finding_id=$2`, cid, fid) + return err + }) +} +func (d *DB) UpdateFindingCaseReport(ctx context.Context, cid, version int64, title, report, severity, reason string) error { + if !ValidSeverity(severity) || strings.TrimSpace(report) == "" || strings.TrimSpace(reason) == "" || strings.TrimSpace(title) == "" { + return errors.New("完整报告、名称、有效等级和评级依据必填") + } + return d.WithEvidenceTx(ctx, func(tx *sql.Tx) error { + c, err := scanCase(tx.QueryRow(`SELECT `+caseColumns+` FROM finding_cases c WHERE id=$1 FOR UPDATE`, cid)) + if err != nil { + return err + } + if !c.Active || c.Version != version { + return ErrFindingCaseConflict + } + if c.Title == title && c.Report == report && c.Severity == severity && c.SeverityReason == reason && c.ReportVersion == version { + return nil + } + _, err = tx.Exec(`UPDATE finding_cases SET title=$2,report=$3,severity=$4,severity_reason=$5,report_version=$6 WHERE id=$1`, cid, title, report, severity, reason, version) + if err != nil { + return err + } + _, err = tx.Exec(`INSERT INTO finding_case_events(case_id,action,actor,reason) VALUES($1,'report_updated','reporter',$2)`, cid, "统一报告已更新;评级依据:"+reason) + return err + }) +} +func (d *DB) SuggestFindingCase(ctx context.Context, taskID, left, right int64, title, reason string) (int64, error) { + if strings.TrimSpace(title) == "" || strings.TrimSpace(reason) == "" { + return 0, errors.New("建议需要名称和具体证据理由") + } + ids, err := sortedFindingIDs([]int64{left, right}) + if err != nil { + return 0, err + } + var id int64 + err = d.WithEvidenceTx(ctx, func(tx *sql.Tx) error { + if err := lockCaseTask(tx, taskID); err != nil { + return err + } + for _, fid := range ids { + var tid sql.NullInt64 + if err := tx.QueryRow(`SELECT task_id FROM findings WHERE id=$1`, fid).Scan(&tid); err != nil { + return err + } + if !tid.Valid || tid.Int64 != taskID { + return errors.New("只能建议同一任务的归并") + } + } + var blocked bool + if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_blocks WHERE left_id=$1 AND right_id=$2)`, ids[0], ids[1]).Scan(&blocked); err != nil { + return err + } + if blocked { + return errors.New("人工已否决该归并") + } + return tx.QueryRow(`INSERT INTO finding_case_suggestions(task_id,left_id,right_id,title,reason) VALUES($1,$2,$3,$4,$5) ON CONFLICT(left_id,right_id) DO UPDATE SET reason=EXCLUDED.reason WHERE finding_case_suggestions.state='pending' RETURNING id`, taskID, ids[0], ids[1], title, reason).Scan(&id) + }) + return id, err +} +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) + if err != nil { + return nil, err + } + defer rows.Close() + out := []FindingCaseSuggestion{} + for rows.Next() { + var s FindingCaseSuggestion + if err := rows.Scan(&s.ID, &s.TaskID, &s.LeftID, &s.RightID, &s.Title, &s.Reason, &s.State); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} +func (d *DB) ResolveFindingCaseSuggestion(ctx context.Context, id int64, accept bool) (int64, error) { + var s FindingCaseSuggestion + if err := d.QueryRow(`SELECT task_id,left_id,right_id,title,reason,state FROM finding_case_suggestions WHERE id=$1`, id).Scan(&s.TaskID, &s.LeftID, &s.RightID, &s.Title, &s.Reason, &s.State); err != nil { + return 0, err + } + if s.State != "pending" { + return 0, ErrFindingCaseConflict + } + if accept { + return d.mergeFindingCase(ctx, s.TaskID, []int64{s.LeftID, s.RightID}, s.Title, s.Reason, "human", id) + } + return 0, d.WithEvidenceTx(ctx, func(tx *sql.Tx) error { + if err := lockCaseTask(tx, s.TaskID); err != nil { + return err + } + res, err := tx.Exec(`UPDATE finding_case_suggestions SET state='rejected' WHERE id=$1 AND state='pending'`, id) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n != 1 { + return ErrFindingCaseConflict + } + if _, err := tx.Exec(`INSERT INTO finding_case_blocks(left_id,right_id,reason) VALUES($1,$2,'人工否决') ON CONFLICT DO NOTHING`, s.LeftID, s.RightID); err != nil { + return err + } + return nil + }) +} + +type FindingDistinctStats struct { + Total int `json:"total"` + Reports int `json:"reports"` + Critical int `json:"critical"` + High int `json:"high"` + Medium int `json:"medium"` + Low int `json:"low"` + Unassessed int `json:"unassessed"` +} + +func (d *DB) DistinctFindingStats(taskID string) (*FindingDistinctStats, error) { + filter := FindingFilter{TaskID: taskID} + where, args := filter.where() + s := new(FindingDistinctStats) + err := d.QueryRow(`WITH observations AS (SELECT f.id,f.severity,COALESCE(m.case_id,0) cid FROM findings f LEFT JOIN tasks t ON t.id=f.task_id LEFT JOIN finding_case_members m ON m.finding_id=f.id`+where+`), defects AS (SELECT severity FROM observations WHERE cid=0 UNION ALL SELECT c.severity FROM finding_cases c WHERE c.id IN(SELECT cid FROM observations WHERE cid>0)) SELECT count(*),(SELECT count(*) FROM observations),count(*) FILTER(WHERE severity='critical'),count(*) FILTER(WHERE severity='high'),count(*) FILTER(WHERE severity='medium'),count(*) FILTER(WHERE severity='low'),count(*) FILTER(WHERE severity='') FROM defects`, args...).Scan(&s.Total, &s.Reports, &s.Critical, &s.High, &s.Medium, &s.Low, &s.Unassessed) + return s, err +} + +// 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) { + f, err := d.FindingCaseSummary(fid) + if err != nil { + return nil, 0, err + } + if f == nil || f.TaskID == nil { + return nil, 0, errors.New("漏洞任务不可用") + } + if page < 1 { + page = 1 + } + if size < 1 || size > 100 { + size = 20 + } + assets, _ := json.Marshal(f.AssetIDs) + if f.AssetIDs == nil { + assets = []byte("[]") + } + // Type/asset/name only retrieve candidates; the Reporter must still prove the root cause. + where := ` WHERE f.task_id=$1 AND f.id<>$2 AND NOT EXISTS(SELECT 1 FROM finding_case_blocks b WHERE b.left_id=LEAST(f.id,$2::bigint) AND b.right_id=GREATEST(f.id,$2::bigint)) + AND ((trim($3::text)<>'' AND lower(f.vulnclass)=lower($3)) OR (trim($4::text)<>'' AND lower(f.name)=lower($4)) + OR EXISTS(SELECT 1 FROM jsonb_array_elements(COALESCE(NULLIF(f.asset_ids,'null'::jsonb),'[]'::jsonb)) a WHERE a.value IN(SELECT value FROM jsonb_array_elements($5::jsonb))))` + args := []any{*f.TaskID, fid, f.VulnClass, f.Name, string(assets)} + 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...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + out := []map[string]any{} + for rows.Next() { + var id, cid int64 + var name, class, severity, summary string + var assetIDs json.RawMessage + if err := rows.Scan(&id, &name, &class, &severity, &summary, &assetIDs, &cid); err != nil { + return nil, 0, err + } + out = append(out, map[string]any{"finding_id": strconv.FormatInt(id, 10), "name": name, "vulnclass": class, "severity": severity, "summary": summary, "asset_ids": assetIDs, "case_id": strconv.FormatInt(cid, 10)}) + } + return out, total, rows.Err() +} + +// Batch task-list counters avoid an additional database round trip per task. +func (d *DB) DistinctFindingStatsByTask() (map[string]*FindingDistinctStats, error) { + rows, err := d.Query(`WITH observations AS ( + SELECT f.task_id,CASE WHEN m.case_id IS NULL THEN 'f:'||f.id ELSE 'c:'||m.case_id END entity, + CASE WHEN m.case_id IS NULL THEN f.severity ELSE c.severity END severity + 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.task_id IS NOT NULL) + SELECT task_id,count(DISTINCT entity),count(*), + count(DISTINCT entity) FILTER(WHERE severity='critical'),count(DISTINCT entity) FILTER(WHERE severity='high'), + count(DISTINCT entity) FILTER(WHERE severity='medium'),count(DISTINCT entity) FILTER(WHERE severity='low'), + count(DISTINCT entity) FILTER(WHERE severity='') FROM observations GROUP BY task_id`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]*FindingDistinctStats{} + for rows.Next() { + var id int64 + s := new(FindingDistinctStats) + if err := rows.Scan(&id, &s.Total, &s.Reports, &s.Critical, &s.High, &s.Medium, &s.Low, &s.Unassessed); err != nil { + return nil, err + } + out[strconv.FormatInt(id, 10)] = s + } + return out, rows.Err() +} + +// Folder rows and candidate searches never fetch full report/PoC bodies. +func (d *DB) FindingCaseSummary(id int64) (*DBFinding, error) { + cols := strings.Replace(findingSelectCols, "f.evidence,", "''::text,", 1) + rows, err := d.Query(`SELECT `+cols+` FROM findings f LEFT JOIN tasks t ON t.id=f.task_id WHERE f.id=$1`, id) + if err != nil { + return nil, err + } + defer rows.Close() + fs, err := scanFindings(rows) + if err != nil || len(fs) == 0 { + return nil, err + } + return fs[0], nil +} diff --git a/db/finding_cases_test.go b/db/finding_cases_test.go new file mode 100644 index 0000000..fe8d163 --- /dev/null +++ b/db/finding_cases_test.go @@ -0,0 +1,291 @@ +package db + +import ( + "context" + "fmt" + "sync" + "testing" +) + +func caseTestDB(t *testing.T) (*DB, *Task, []int64) { + t.Helper() + d, err := Open(testDSN(t)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { d.Close() }) + task, err := d.CreateTask("case integration", "goal", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + ids := []int64{} + for _, sev := range []string{"high", "medium", "low", "high"} { + id, err := d.AddFinding(task.ID, 0, "IDOR", "order access", sev, "GET /orders/{id}", "original PoC "+sev, "worker", nil) + if err != nil { + t.Fatal(err) + } + ids = append(ids, id) + } + t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM findings WHERE task_id=$1`, task.ID); _ = d.DeleteTask(task.ID) }) + return d, task, ids +} +func TestFindingCaseMergePreservesRecordsAndVersions(t *testing.T) { + d, task, ids := caseTestDB(t) + ctx := context.Background() + id, err := d.MergeFindingCase(ctx, task.ID, ids[:3], "订单越权", "同一接口同一权限检查缺失", "reporter") + if err != nil { + t.Fatal(err) + } + c, err := d.GetFindingCase(id) + if err != nil { + t.Fatal(err) + } + if c.Count != 3 || c.High != 1 || c.Medium != 1 || c.Low != 1 { + t.Fatalf("counts %+v", c) + } + stats, err := d.DistinctFindingStats(fmt.Sprint(task.ID)) + if err != nil { + t.Fatal(err) + } + batch, err := d.DistinctFindingStatsByTask() + if err != nil || batch[fmt.Sprint(task.ID)] == nil || *batch[fmt.Sprint(task.ID)] != *stats { + t.Fatalf("batch stats %+v %v", batch, err) + } + if stats.Total != 2 || stats.Reports != 4 || stats.Unassessed != 1 { + t.Fatalf("stats %+v", stats) + } + again, err := d.MergeFindingCase(ctx, task.ID, ids[:3], "same", "same", "reporter") + if err != nil || again != id { + t.Fatalf("idempotency %d %v", again, err) + } + same, _ := d.GetFindingCase(id) + if same.Version != c.Version { + t.Fatal("idempotent merge invalidated report") + } + if err := d.UpdateFindingCaseReport(ctx, id, c.Version, "订单越权", "complete PoC", "high", "verified sensitive data"); err != nil { + t.Fatal(err) + } + for i, fid := range ids[:3] { + f, err := d.GetFinding(fid) + if err != nil { + t.Fatal(err) + } + if f.Severity != []string{"high", "medium", "low"}[i] || f.Evidence == "" || f.Status != FindingPending { + t.Fatalf("original overwritten %+v", f) + } + } + if _, err := d.Exec(`UPDATE findings SET evidence='new proof' WHERE id=$1`, ids[1]); err != nil { + t.Fatal(err) + } + if err := d.UpdateFindingCaseReport(ctx, id, c.Version, "x", "stale", "low", "x"); err != ErrFindingCaseConflict { + t.Fatalf("stale accepted %v", err) + } + if err := d.RemoveFindingCaseMember(ctx, id, ids[1], "different cause"); err != nil { + t.Fatal(err) + } + if _, err := d.MergeFindingCase(ctx, task.ID, ids[:3], "x", "x", "reporter"); err == nil { + t.Fatal("human veto ignored") + } + if err := d.RemoveFindingCaseMember(ctx, id, ids[2], "undo"); err != nil { + t.Fatal(err) + } + c, _ = d.GetFindingCase(id) + if c.Active { + t.Fatal("singleton remains group") + } + for _, fid := range ids[:3] { + g, err := d.FindingCaseID(fid) + if err != nil || g != 0 { + t.Fatalf("dangling membership %d %v", g, err) + } + } +} +func TestFindingCaseScopeSuggestionsAndDeletion(t *testing.T) { + d, task, ids := caseTestDB(t) + ctx := context.Background() + other, err := d.CreateTask("other", "goal", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + defer d.DeleteTask(other.ID) + fid, err := d.AddFinding(other.ID, 0, "IDOR", "other", "high", "same", "poc", "worker", nil) + if err != nil { + t.Fatal(err) + } + defer d.Exec(`DELETE FROM findings WHERE id=$1`, fid) + if _, err := d.MergeFindingCase(ctx, task.ID, []int64{ids[0], fid}, "x", "x", "reporter"); err == nil { + t.Fatal("cross task accepted") + } + sid, err := d.SuggestFindingCase(ctx, task.ID, ids[0], ids[1], "order", "uncertain") + if err != nil { + t.Fatal(err) + } + if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, false); err != nil { + t.Fatal(err) + } + if _, err := d.MergeFindingCase(ctx, task.ID, ids[:2], "x", "x", "reporter"); err == nil { + t.Fatal("rejection ignored") + } + cid, err := d.MergeFindingCase(ctx, task.ID, ids[2:], "x", "proof", "reporter") + if err != nil { + t.Fatal(err) + } + if _, err := d.DeleteFinding(ids[3]); err != nil { + t.Fatal(err) + } + c, _ := d.GetFindingCase(cid) + if c.Active { + t.Fatal("deletion did not dissolve group") + } + f, err := d.GetFinding(ids[2]) + if err != nil || f == nil { + t.Fatal("surviving finding lost") + } +} +func TestFindingCaseConcurrentUnion(t *testing.T) { + d, task, ids := caseTestDB(t) + ctx := context.Background() + var wg sync.WaitGroup + errs := make(chan error, 3) + for _, pair := range [][]int64{{ids[0], ids[1]}, {ids[1], ids[2]}, {ids[2], ids[3]}} { + pair := pair + wg.Add(1) + go func() { + defer wg.Done() + _, err := d.MergeFindingCase(ctx, task.ID, pair, "orders", "same authorization check", "reporter") + errs <- err + }() + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + group := int64(0) + for _, fid := range ids { + cid, err := d.FindingCaseID(fid) + if err != nil || cid == 0 { + t.Fatalf("membership %v", err) + } + if group != 0 && cid != group { + t.Fatal("overlapping parallel groups") + } + group = cid + } + c, _ := d.GetFindingCase(group) + if c.Count != 4 { + t.Fatalf("lost member %+v", c) + } + rows, total, err := d.ListFindingCases(FindingFilter{TaskID: fmt.Sprint(task.ID), Severity: "low"}, 1, 10) + if err != nil || total != 1 || len(rows) != 1 || rows[0].Case.Count != 4 || len(rows[0].MatchedIDs) != 1 { + t.Fatalf("filter/aggregation %d %+v %v", total, rows, err) + } +} + +func TestFindingCaseTaskDeletionAndRepeatedDetach(t *testing.T) { + d, task, ids := caseTestDB(t) + ctx := context.Background() + cid, err := d.MergeFindingCase(ctx, task.ID, ids[:3], "orders", "same missing permission", "reporter") + if err != nil { + t.Fatal(err) + } + // A source task can disappear without deleting standalone reports or breaking old links. + if err := d.DeleteTask(task.ID); err != nil { + t.Fatal(err) + } + defer func() { + for _, id := range ids { + _, _ = d.DeleteFinding(id) + } + }() + c, err := d.GetFindingCase(cid) + if err != nil || c == nil || c.TaskID != nil || c.Count != 3 { + t.Fatalf("retention %+v %v", c, err) + } + if err := d.RemoveFindingCaseMember(ctx, cid, ids[0], "undo"); err != nil { + t.Fatal(err) + } + if err := d.RemoveFindingCaseMember(ctx, cid, ids[0], "retry"); err != nil { + t.Fatal(err) + } + if err := d.RemoveFindingCaseMember(ctx, cid, ids[1], "undo"); err != nil { + t.Fatal(err) + } + if err := d.RemoveFindingCaseMember(ctx, cid, ids[2], "released by dissolution"); err != nil { + t.Fatal(err) + } + c, _ = d.GetFindingCase(cid) + if c.Active || c.Count != 0 { + t.Fatalf("folder not dissolved %+v", c) + } + for _, id := range ids[:3] { + f, e := d.GetFinding(id) + if e != nil || f == nil || f.Evidence == "" { + t.Fatalf("original lost %d %v", id, e) + } + } +} + +func TestFindingCaseSuggestionResolutionIsAtomic(t *testing.T) { + d, task, ids := caseTestDB(t) + ctx := context.Background() + sid, err := d.SuggestFindingCase(ctx, task.ID, ids[0], ids[1], "orders", "uncertain") + if err != nil { + t.Fatal(err) + } + results := make(chan error, 2) + var wg sync.WaitGroup + for _, accept := range []bool{true, false} { + wg.Add(1) + go func(a bool) { defer wg.Done(); _, e := d.ResolveFindingCaseSuggestion(ctx, sid, a); results <- e }(accept) + } + wg.Wait() + close(results) + success := 0 + for err := range results { + if err == nil { + success++ + } + } + if success != 1 { + t.Fatalf("%d successful conflicting resolutions", success) + } + var state string + if err := d.QueryRow(`SELECT state FROM finding_case_suggestions WHERE id=$1`, sid).Scan(&state); err != nil { + t.Fatal(err) + } + cid, _ := d.FindingCaseID(ids[0]) + if (state == "accepted") != (cid > 0) { + t.Fatalf("inconsistent resolution %s %d", state, cid) + } +} + +func TestFindingCaseCandidatesAreCompactAndRelated(t *testing.T) { + d, task, ids := caseTestDB(t) + if _, err := d.Exec(`UPDATE findings SET vulnclass='SQLi',name='search injection' WHERE id=$1`, ids[3]); err != nil { + t.Fatal(err) + } + items, total, err := d.FindingCaseCandidates(ids[0], 1, 1) + if err != nil || total != 2 || len(items) != 1 { + t.Fatalf("candidate pagination %d %+v %v", total, items, err) + } + if _, ok := items[0]["report"]; ok { + t.Fatal("report included in candidate summary") + } + if _, ok := items[0]["evidence"]; ok { + t.Fatal("evidence included in candidate summary") + } + cid, err := d.MergeFindingCase(context.Background(), task.ID, ids[:2], "orders", "proof", "reporter") + if err != nil { + t.Fatal(err) + } + if err := d.RemoveFindingCaseMember(context.Background(), cid, ids[1], "different cause"); err != nil { + t.Fatal(err) + } + _, total, err = d.FindingCaseCandidates(ids[0], 1, 20) + if err != nil || total != 1 { + t.Fatalf("veto excluded %d %v", total, err) + } +} diff --git a/db/findings.go b/db/findings.go index 56e5fa7..5906064 100644 --- a/db/findings.go +++ b/db/findings.go @@ -15,6 +15,9 @@ import ( // DBFinding is a row in the standalone findings table. It persists across task // deletion unless the caller explicitly requests related finding cleanup. type DBFinding struct { + ExportCaseID int64 // transient unified-export identity, never a finding handle + ExportMemberIDs []int64 + TrafficCount int EvidenceVersion int64 ReportEvidenceVersion int64 @@ -524,6 +527,8 @@ func (d *DB) ListFindingsForExport(f FindingFilter, ids []int64) ([]*DBFinding, // and vuln-class filter — computed server-side so it stays exact regardless of // pagination. type FindingStats struct { + Distinct *FindingDistinctStats `json:"distinct,omitempty"` + Total int `json:"total"` Pending int `json:"pending"` Critical int `json:"critical"` diff --git a/db/schema.sql b/db/schema.sql index a945b76..9928a9c 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1384,3 +1384,90 @@ CREATE INDEX IF NOT EXISTS idx_notification_deliveries_batch ON notification_deliveries(batch_id) WHERE batch_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_notification_deliveries_channel ON notification_deliveries(channel_id, id DESC); + +-- A case collects repeated observations of one defect without rewriting findings. +CREATE TABLE IF NOT EXISTS finding_cases ( + id BIGSERIAL PRIMARY KEY, + task_id BIGINT REFERENCES tasks(id) ON DELETE SET NULL, + origin_task_id BIGINT NOT NULL, + title TEXT NOT NULL, + reason TEXT NOT NULL, + severity TEXT NOT NULL DEFAULT '', + severity_reason TEXT NOT NULL DEFAULT '', + report TEXT NOT NULL DEFAULT '', + version BIGINT NOT NULL DEFAULT 1, + report_version BIGINT NOT NULL DEFAULT 0, + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE TABLE IF NOT EXISTS finding_case_members ( + finding_id BIGINT PRIMARY KEY REFERENCES findings(id) ON DELETE CASCADE, + case_id BIGINT NOT NULL REFERENCES finding_cases(id), + joined_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_finding_case_members_case ON finding_case_members(case_id); +CREATE TABLE IF NOT EXISTS finding_case_events ( + id BIGSERIAL PRIMARY KEY, + case_id BIGINT NOT NULL REFERENCES finding_cases(id), + action TEXT NOT NULL, + actor TEXT NOT NULL, + reason TEXT NOT NULL, + finding_ids JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE TABLE IF NOT EXISTS finding_case_suggestions ( + id BIGSERIAL PRIMARY KEY, + task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + left_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE, + right_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE, + title TEXT NOT NULL, + reason TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(left_id,right_id), + CHECK(left_id < right_id) +); +-- Rejected/detached pairs are a durable human veto, also checked on group unions. +CREATE TABLE IF NOT EXISTS finding_case_blocks ( + left_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE, + right_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE, + reason TEXT NOT NULL, + PRIMARY KEY(left_id,right_id), CHECK(left_id < right_id) +); +CREATE OR REPLACE FUNCTION finding_case_changed() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE cid BIGINT; remaining INTEGER; +BEGIN + IF TG_TABLE_NAME = 'findings' THEN + IF OLD IS NOT DISTINCT FROM NEW THEN RETURN NEW; END IF; + SELECT case_id INTO cid FROM finding_case_members WHERE finding_id=NEW.id; + ELSE + cid := OLD.case_id; + END IF; + IF cid IS NOT NULL THEN + UPDATE finding_cases SET version=version+1 WHERE id=cid; + IF TG_OP='DELETE' THEN + SELECT count(*) INTO remaining FROM finding_case_members WHERE case_id=cid; + INSERT INTO finding_case_events(case_id,action,actor,reason,finding_ids) + VALUES(cid,'member_deleted','system','成员移出或删除',jsonb_build_array(OLD.finding_id)); + IF remaining < 2 THEN + UPDATE finding_cases SET active=false WHERE id=cid; + DELETE FROM finding_case_members WHERE case_id=cid; + END IF; + END IF; + END IF; + RETURN NULL; +END $$; +DROP TRIGGER IF EXISTS finding_case_content_changed ON findings; +CREATE TRIGGER finding_case_content_changed AFTER UPDATE ON findings +FOR EACH ROW EXECUTE FUNCTION finding_case_changed(); +DROP TRIGGER IF EXISTS finding_case_member_deleted ON finding_case_members; +CREATE TRIGGER finding_case_member_deleted AFTER DELETE ON finding_case_members +FOR EACH ROW EXECUTE FUNCTION finding_case_changed(); +CREATE TABLE IF NOT EXISTS finding_case_review_runs ( + conversation_id BIGINT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE, + task_id BIGINT REFERENCES tasks(id) ON DELETE SET NULL, + state TEXT NOT NULL DEFAULT 'queued', + error TEXT NOT NULL DEFAULT '' +); + +ALTER TABLE finding_case_review_runs ADD COLUMN IF NOT EXISTS finding_ids JSONB NOT NULL DEFAULT '[]'; diff --git a/report/findings.go b/report/findings.go index a91fa63..dac541e 100644 --- a/report/findings.go +++ b/report/findings.go @@ -55,6 +55,9 @@ func FindingsMarkdown(fs []*db.DBFinding, generatedAt time.Time) string { } { fmt.Fprintf(&b, "| %s | %d |\n", s.label, counts[s.key]) } + if counts["unassessed"] > 0 { + fmt.Fprintf(&b, "| 待评估 | %d |\n", counts["unassessed"]) + } b.WriteString("\n") if len(items) == 0 { @@ -127,6 +130,9 @@ func FindingFilename(f *db.DBFinding) string { sev := nz(f.Severity, "info") title := findingTitle(f) name := fmt.Sprintf("%s_%s_#%d", sev, title, f.ID) + if f.ExportCaseID > 0 { + name = fmt.Sprintf("case_%d_%s_%s", f.ExportCaseID, sev, title) + } name = unsafeFilenameChars.ReplaceAllString(name, "_") name = strings.Trim(name, "._") if name == "" { @@ -151,8 +157,12 @@ func FindingsCSV(fs []*db.DBFinding) []byte { w := csv.NewWriter(&buf) _ = w.Write([]string{"ID", "名称", "类别", "严重等级", "状态", "所属任务", "发现时间", "概述", "流量证据数量", "流量证据ID"}) for _, f := range items { + id := fmt.Sprintf("%d", f.ID) + if f.ExportCaseID > 0 { + id = fmt.Sprintf("case:%d", f.ExportCaseID) + } _ = w.Write([]string{ - fmt.Sprintf("%d", f.ID), + id, findingTitle(f), f.VulnClass, nz(f.Severity, "info"), diff --git a/server/conversations.go b/server/conversations.go index ff57d04..84456b0 100644 --- a/server/conversations.go +++ b/server/conversations.go @@ -503,7 +503,21 @@ func (s *Server) runConversationTurn(ctx context.Context, cancel context.CancelC }() // Only the first turn executes a historical retest. Follow-up conversation // turns may explain the sealed result; the result tool refuses to overwrite it. - finishStatus, finishReason := "failed", "复测未能启动" + finishStatus, finishReason := "failed", "Agent 未能启动" + if c.AgentKey == "reporter" { + defer func() { + state := "done" + reason := "" + if finishStatus != "completed" || ctx.Err() != nil { + state = "failed" + reason = finishReason + if ctx.Err() != nil { + reason = "整理已停止或服务关闭" + } + } + _, _ = 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) + }() + } if c.AgentKey == db.FindingRetestAgentKey { // Read without the run cancellation so an immediate stop still seals pending. r, err := s.m.pg.FindingRetestForConversation(context.Background(), c.ID) @@ -668,11 +682,17 @@ func (s *Server) runAndPump(agentKey string, item triggeredRun) { // Caller holds queueMu. func (s *Server) nextTriggerRun(agentKey string, cfg triggerBehavior) triggeredRun { q := s.triggerQ[agentKey] - if cfg.runMode == "parallel" || cfg.mergeMode == "none" { + if q[0].conversationID > 0 || cfg.runMode == "parallel" || cfg.mergeMode == "none" { s.triggerQ[agentKey] = q[1:] return q[0] } - if cfg.mergeMode == "all" { + hasManual := false + for _, item := range q { + if item.conversationID > 0 { + hasManual = true + } + } + if cfg.mergeMode == "all" && !hasManual { s.triggerQ[agentKey] = q[:0:0] return mergeAllRuns(q) } @@ -685,7 +705,7 @@ func (s *Server) nextTriggerRun(agentKey string, cfg triggerBehavior) triggeredR group := []triggeredRun{head} rest := q[:0:0] // keep non-matching items in order for _, it := range q[1:] { - if it.mergeable && it.taskID == head.taskID { + if it.conversationID == 0 && it.mergeable && it.taskID == head.taskID { group = append(group, it) } else { rest = append(rest, it) @@ -798,12 +818,25 @@ func mergeAllRuns(items []triggeredRun) triggeredRun { func (s *Server) runTriggeredRun(item triggeredRun) { defer func() { if r := recover(); r != nil { + if item.conversationID > 0 { + _, _ = s.m.pg.Exec(`UPDATE finding_case_review_runs SET state='failed',error=$2 WHERE conversation_id=$1`, item.conversationID, fmt.Sprint(r)) + } log.Printf("[trigger] run for %s panicked: %v", item.agentKey, r) } }() pg := s.m.pg - c, err := pg.CreateConversation(item.agentKey, firstLine(item.title, 60), nil) - if err != nil { + var c *db.Conversation + var err error + if item.conversationID > 0 { + c, err = pg.GetConversation(item.conversationID) + _, _ = pg.Exec(`UPDATE finding_case_review_runs SET state='running' WHERE conversation_id=$1`, item.conversationID) + } else { + c, err = pg.CreateConversation(item.agentKey, firstLine(item.title, 60), nil) + } + if err != nil || c == nil { + if item.conversationID > 0 { + _, _ = pg.Exec(`UPDATE finding_case_review_runs SET state='failed',error='整理会话不可用' WHERE conversation_id=$1`, item.conversationID) + } log.Printf("[trigger] create conversation for %s failed: %v", item.agentKey, err) return } diff --git a/server/dto.go b/server/dto.go index 38c5211..bcb349f 100644 --- a/server/dto.go +++ b/server/dto.go @@ -30,6 +30,8 @@ func rawString(raw json.RawMessage) string { // ---- Task (frontend "Task") ---- created_at as RFC3339, plus a derived status. type TaskDTO struct { + DistinctFindings *db.FindingDistinctStats `json:"distinct_findings,omitempty"` + ID string `json:"id"` ExplorationID int64 `json:"exploration_id"` Name string `json:"name"` // 可选任务名称;空=未命名 @@ -303,6 +305,9 @@ func coverageAssetRefDTO(ref db.AssetRef) CoverageAssetRefDTO { // ---- Finding (frontend "Finding") ---- type FindingDTO struct { + CaseID string `json:"case_id,omitempty"` + MemberIDs []int64 `json:"member_ids,omitempty"` + TrafficCount int `json:"traffic_count"` EvidenceVersion int64 `json:"evidence_version"` ReportEvidenceVersion int64 `json:"report_evidence_version"` @@ -457,6 +462,12 @@ func findingFromDB(f *db.DBFinding, assets map[int64]*db.Asset) FindingDTO { Report: f.Report, TS: rfc3339(f.CreatedAt), } + if f.ExportCaseID > 0 { + d.ID = "case:" + i64s(f.ExportCaseID) + d.FindingID = "" + d.CaseID = i64s(f.ExportCaseID) + d.MemberIDs = f.ExportMemberIDs + } d.Assets = findingAssetDTOs(f.AssetIDs, assets) if f.TaskID != nil { d.TaskID = i64s(*f.TaskID) diff --git a/server/finding_cases.go b/server/finding_cases.go new file mode 100644 index 0000000..c2ba1e0 --- /dev/null +++ b/server/finding_cases.go @@ -0,0 +1,506 @@ +package server + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/Autumn-27/artex/db" + "github.com/Autumn-27/artex/intercept" + actool "github.com/Autumn-27/norma/tool" +) + +type findingCaseRequest struct { + TaskID json.RawMessage `json:"task_id"` + FindingID json.RawMessage `json:"finding_id"` + IDs []json.RawMessage `json:"finding_ids"` + CaseID json.RawMessage `json:"case_id"` + Version int64 `json:"version"` + Title string `json:"title"` + Reason string `json:"reason"` + Report string `json:"report"` + Severity string `json:"severity"` + SeverityReason string `json:"severity_reason"` + Page int `json:"page"` +} + +func (a findingCaseRequest) ids() []int64 { + out := []int64{} + for _, raw := range a.IDs { + out = append(out, parseProfileID(raw)) + } + return out +} +func caseHTTPError(w http.ResponseWriter, err error) { + code := 400 + if errors.Is(err, db.ErrFindingCaseConflict) { + code = 409 + } + writeErr(w, code, err.Error()) +} +func decodeCaseRequest(w http.ResponseWriter, r *http.Request, a any) bool { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + if err := json.NewDecoder(r.Body).Decode(a); err != nil { + writeErr(w, 400, "无效请求正文") + return false + } + return true +} +func (s *Server) caseFindingDTO(f *db.DBFinding) FindingDTO { + return findingFromDB(f, s.resolveAssetIDs(f.AssetIDs)) +} +func (s *Server) listFindingCases(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + page := findingPaginationParam(q.Get("page"), 1, 0) + size := findingPaginationParam(q.Get("limit"), 20, 100) + rows, total, err := s.m.pg.ListFindingCases(findingFilterFromQuery(q), page, size) + if err != nil { + caseHTTPError(w, err) + return + } + items := []map[string]any{} + for _, row := range rows { + v := map[string]any{"case": row.Case, "matched_ids": row.MatchedIDs} + if row.FindingID > 0 { + f, err := s.m.pg.FindingCaseSummary(row.FindingID) + if err != nil { + caseHTTPError(w, err) + return + } + if f == nil { + continue + } + f.Report = "" + v["finding"] = s.caseFindingDTO(f) + } + items = append(items, v) + } + stats, err := s.m.pg.DistinctFindingStats(q.Get("task_id")) + if err != nil { + caseHTTPError(w, err) + return + } + writeJSON(w, 200, map[string]any{"items": items, "total": total, "page": page, "page_size": size, "stats": stats}) +} +func (s *Server) findingCaseAccess(w http.ResponseWriter, r *http.Request, mutation bool) (*db.FindingCase, bool) { + id := int64(atoiDefault(r.PathValue("id"), 0)) + c, err := s.m.pg.GetFindingCase(id) + if err != nil { + caseHTTPError(w, err) + return nil, false + } + if c == nil { + writeErr(w, 404, "漏洞组不存在") + return nil, false + } + if ctx := r.URL.Query().Get("context_task"); ctx != "" { + t := s.m.ResolveTask(ctx) + _, inherited, allowed := findingProvenanceInTask(t, c.TaskID) + if !allowed { + writeErr(w, 404, "任务中没有该漏洞组") + return nil, false + } + if mutation && inherited { + writeErr(w, 403, "继承漏洞组只读") + return nil, false + } + } + return c, true +} +func (s *Server) getFindingCase(w http.ResponseWriter, r *http.Request) { + c, ok := s.findingCaseAccess(w, r, false) + if !ok { + return + } + events, err := s.m.pg.FindingCaseEvents(c.ID) + if err != nil { + caseHTTPError(w, err) + return + } + writeJSON(w, 200, map[string]any{"case": c, "events": events}) +} +func (s *Server) getFindingCaseMembers(w http.ResponseWriter, r *http.Request) { + c, ok := s.findingCaseAccess(w, r, false) + if !ok { + return + } + page := findingPaginationParam(r.URL.Query().Get("page"), 1, 0) + size := findingPaginationParam(r.URL.Query().Get("limit"), 20, 100) + fs, total, err := s.m.pg.FindingCaseMembers(c.ID, page, size) + if err != nil { + caseHTTPError(w, err) + return + } + assets := s.resolveFindingAssets(fs) + items := []FindingDTO{} + for _, f := range fs { + dto := findingFromDB(f, assets) + if ctx := r.URL.Query().Get("context_task"); ctx != "" { + dto.SourceTaskID, dto.Inherited, _ = findingProvenanceInTask(s.m.ResolveTask(ctx), f.TaskID) + } + items = append(items, dto) + } + writeJSON(w, 200, map[string]any{"items": items, "total": total, "page": page, "page_size": size}) +} +func (s *Server) removeFindingCaseMember(w http.ResponseWriter, r *http.Request) { + c, ok := s.findingCaseAccess(w, r, true) + if !ok { + return + } + fid := int64(atoiDefault(r.PathValue("fid"), 0)) + var body struct { + Reason string `json:"reason"` + } + if !decodeCaseRequest(w, r, &body) { + return + } + if strings.TrimSpace(body.Reason) == "" { + body.Reason = "人工移出归并组" + } + if err := s.m.pg.RemoveFindingCaseMember(r.Context(), c.ID, fid, body.Reason); err != nil { + caseHTTPError(w, err) + return + } + writeJSON(w, 200, map[string]bool{"ok": true}) +} +func (s *Server) caseSuggestions(w http.ResponseWriter, r *http.Request) { + items, err := s.m.pg.FindingCaseSuggestions(int64(atoiDefault(r.URL.Query().Get("task_id"), 0))) + if err != nil { + caseHTTPError(w, err) + return + } + writeJSON(w, 200, items) +} +func (s *Server) resolveCaseSuggestion(w http.ResponseWriter, r *http.Request) { + var a struct { + Accept bool `json:"accept"` + } + if !decodeCaseRequest(w, r, &a) { + return + } + id, err := s.m.pg.ResolveFindingCaseSuggestion(r.Context(), int64(atoiDefault(r.PathValue("id"), 0)), a.Accept) + if err != nil { + caseHTTPError(w, err) + return + } + writeJSON(w, 200, map[string]string{"case_id": i64s(id)}) +} +func (s *Server) mergeFindingCase(w http.ResponseWriter, r *http.Request) { + var a findingCaseRequest + if !decodeCaseRequest(w, r, &a) { + return + } + id, err := s.m.pg.MergeFindingCase(r.Context(), parseProfileID(a.TaskID), a.ids(), a.Title, a.Reason, "human") + if err != nil { + caseHTTPError(w, err) + return + } + writeJSON(w, 200, map[string]string{"case_id": i64s(id)}) +} +func (s *Server) updateCaseReport(w http.ResponseWriter, r *http.Request) { + c, ok := s.findingCaseAccess(w, r, true) + if !ok { + return + } + var a findingCaseRequest + if !decodeCaseRequest(w, r, &a) { + 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 + } + writeJSON(w, 200, map[string]bool{"ok": true}) +} + +func (s *Server) findingCaseTools() []actool.CoreTool { + props := map[string]any{"finding_id": idParam("独立 findings 记录 ID,不是探索节点 ID"), "finding_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, "description": "独立漏洞记录 ID 列表"}, "task_id": idParam("同一来源任务 ID"), "case_id": idParam("归并组 ID"), "version": idParam("get_finding_case 实际读取的 version"), "page": idParam("分页,从1开始,每页20条"), "title": strParam("同一实际缺陷的名称"), "reason": strParam("具体证据与归并理由,不能仅类型相同"), "report": strParam("全部成员证据的完整统一 Markdown 报告"), "severity": strParam("critical|high|medium|low"), "severity_reason": strParam("基于已验证影响的评级依据,不直接取最高等级")} + specs := []struct { + name, desc string + required []string + }{ + {"search_finding_duplicates", "只读检索同任务候选,返回摘要与资产;等级不同也可能重复。支持分页,按需读取详情。", []string{"finding_id"}}, + {"get_finding_record", "读取独立漏洞完整原始证据和报告,返回探索节点编号以便追溯执行记录。", []string{"finding_id"}}, + {"merge_finding_records", "证据明确证明同一缺陷时归并;保留所有原始记录。不跨任务、不改变原评级,不可绕过人工否决。", []string{"task_id", "finding_ids", "title", "reason"}}, + {"suggest_finding_merge", "证据不足时提交疑似重复建议,待人工确认;不是自动合并。只允许两条同任务记录。", []string{"task_id", "finding_ids", "title", "reason"}}, + {"get_finding_case", "读取归并组和成员分页。先记录version,再读取成员的原报告、证据及流量;版本变化必须重新读取。", []string{"case_id"}}, + {"update_finding_case_report", "保存统一报告及评级理由,version必须来自实际读取。保留各条原报告,不新建漏洞。", []string{"case_id", "version", "title", "report", "severity", "severity_reason"}}, + } + out := []actool.CoreTool{} + for _, spec := range specs { + spec := spec + handler := func(ctx context.Context, in json.RawMessage) (actool.Result, error) { + var a findingCaseRequest + if err := json.Unmarshal(in, &a); err != nil { + return actool.Errorf(err.Error()), nil + } + v, err := s.performFindingCaseTool(ctx, spec.name, a) + if err != nil { + return actool.Errorf(err.Error()), nil + } + raw, _ := json.Marshal(v) + return actool.Text(string(raw)), nil + } + schemaProps := map[string]any{} + for _, key := range spec.required { + schemaProps[key] = props[key] + } + if spec.name == "search_finding_duplicates" || spec.name == "get_finding_case" { + schemaProps["page"] = props["page"] + } + schema := objSchema(schemaProps, spec.required...) + if strings.HasPrefix(spec.name, "get_") || strings.HasPrefix(spec.name, "search_") { + out = append(out, roTool(spec.name, spec.desc, schema, handler)) + } else { + out = append(out, wrTool(spec.name, spec.desc, schema, handler)) + } + } + return out +} +func (s *Server) performFindingCaseTool(ctx context.Context, name string, a findingCaseRequest) (any, error) { + if err := s.checkCaseReviewScope(ctx, name, a); err != nil { + return nil, err + } + fid := parseProfileID(a.FindingID) + cid := parseProfileID(a.CaseID) + page := a.Page + if page < 1 { + page = 1 + } + switch name { + case "search_finding_duplicates": + items, total, err := s.m.pg.FindingCaseCandidates(fid, page, 20) + return map[string]any{"items": items, "total": total, "page": page, "page_size": 20}, err + case "get_finding_record": + f, err := s.m.pg.GetFinding(fid) + if err != nil { + return nil, err + } + if f == nil { + return nil, errors.New("漏洞不存在") + } + dto := s.caseFindingDTO(f) + cid, err := s.m.pg.FindingCaseID(f.ID) + if err != nil { + return nil, err + } + if cid > 0 { + dto.CaseID = i64s(cid) + } + 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") + return map[string]string{"case_id": i64s(id)}, err + case "suggest_finding_merge": + ids := a.ids() + if len(ids) != 2 { + return nil, errors.New("疑似重复建议需要两条记录") + } + id, err := s.m.pg.SuggestFindingCase(ctx, parseProfileID(a.TaskID), ids[0], ids[1], a.Title, a.Reason) + return map[string]string{"suggestion_id": i64s(id)}, err + case "get_finding_case": + c, err := s.m.pg.GetFindingCase(cid) + if err != nil { + return nil, err + } + if c == nil { + return nil, errors.New("漏洞组不存在") + } + fs, total, err := s.m.pg.FindingCaseMembers(cid, page, 20) + members := []FindingDTO{} + for _, f := range fs { + members = append(members, s.caseFindingDTO(f)) + } + return map[string]any{"case": c, "members": members, "total": total, "page": page, "page_size": 20}, err + case "update_finding_case_report": + 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 + } + return nil, errors.New("未知归并工具") +} +func idParam(desc string) map[string]any { + return map[string]any{"type": "integer", "description": desc} +} +func (s *Server) seedFindingCaseTools() { + _, _ = s.m.pg.Exec(`UPDATE finding_case_review_runs SET state='failed',error='服务重启中断,请重新提交整理' WHERE state IN ('queued','running')`) + a, err := s.m.pg.GetAgentByKey("reporter") + if err != nil || a == nil { + return + } + const flag = "finding_case_tools_v1" + if v, _, _ := s.m.pg.GetSetting(flag); v == "true" { + return + } + names := []string{} + 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 err := s.m.pg.AddAgentToToolBinding("reporter", names); err != nil { + return + } + _ = s.m.pg.SetSetting(flag, "true") +} +func (s *Server) reviewFindingCases(w http.ResponseWriter, r *http.Request) { + var a findingCaseRequest + if !decodeCaseRequest(w, r, &a) { + return + } + ids := a.ids() + if cid := parseProfileID(a.CaseID); cid > 0 { + c, err := s.m.pg.GetFindingCase(cid) + if err != nil || c == nil || !c.Active { + writeErr(w, 400, "漏洞组不可用") + return + } + if ctx := r.URL.Query().Get("context_task"); ctx != "" && (c.TaskID == nil || ctx != i64s(*c.TaskID)) { + writeErr(w, 403, "继承漏洞组只读") + return + } + fs, _, err := s.m.pg.FindingCaseMembers(cid, 1, 100000) + if err != nil { + caseHTTPError(w, err) + return + } + ids = nil + for _, f := range fs { + ids = append(ids, f.ID) + } + } + if len(ids) == 0 || (len(a.CaseID) == 0 && len(ids) > 200) { + writeErr(w, 400, "请选择1至200条原始上报") + return + } + reporter, err := s.m.pg.GetAgentByKey("reporter") + if err != nil || reporter == nil || !reporter.Enabled || s.chatAgentRef() == nil { + writeErr(w, 409, "报告 Agent 未启用或模型不可用") + return + } + batches := map[int64][]int64{} + for _, fid := range ids { + f, err := s.m.pg.FindingCaseSummary(fid) + if err != nil || f == nil || f.TaskID == nil { + writeErr(w, 400, "所选漏洞来源任务不可用") + return + } + batches[*f.TaskID] = append(batches[*f.TaskID], fid) + } + // Validate every source before starting any batch. + 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)) + if !ok { + writeErr(w, 409, "任务不可用") + return + } + 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} + 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)}) + } + writeJSON(w, 202, map[string]any{"runs": runs}) +} +func (s *Server) caseReviewRuns(w http.ResponseWriter, r *http.Request) { + rows, err := s.m.pg.Query(`SELECT conversation_id,task_id,state,error FROM finding_case_review_runs ORDER BY conversation_id DESC LIMIT 50`) + if err != nil { + caseHTTPError(w, err) + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var conv int64 + var task sql.NullInt64 + var state, msg string + if err := rows.Scan(&conv, &task, &state, &msg); err != nil { + caseHTTPError(w, err) + return + } + items = append(items, map[string]any{"conversation_id": conv, "task_id": i64s(task.Int64), "state": state, "error": msg}) + } + if err := rows.Err(); err != nil { + caseHTTPError(w, err) + return + } + writeJSON(w, 200, items) +} + +// Manual reviews cannot mutate a finding outside the explicit selection. +func (s *Server) checkCaseReviewScope(ctx context.Context, name string, a findingCaseRequest) error { + conv := intercept.ConvIDFromContext(ctx) + 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) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil + } + return err + } + var ids []int64 + if err := json.Unmarshal(raw, &ids); err != nil { + return err + } + 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)} + } + for _, id := range checkIDs { + if !allowed[id] { + return errors.New("人工整理只能归并所选记录") + } + } + case "update_finding_case_report": + fs, _, err := s.m.pg.FindingCaseMembers(parseProfileID(a.CaseID), 1, 100000) + if err != nil { + return err + } + found := false + for _, f := range fs { + if allowed[f.ID] { + found = true + } + } + if !found { + return errors.New("该漏洞组不属于人工所选范围") + } + } + return nil +} diff --git a/server/finding_cases_export.go b/server/finding_cases_export.go new file mode 100644 index 0000000..9a54e35 --- /dev/null +++ b/server/finding_cases_export.go @@ -0,0 +1,124 @@ +package server + +import ( + "fmt" + "strings" + + "github.com/Autumn-27/artex/db" +) + +type findingCaseExportPlan struct { + cases map[int64]*db.FindingCase + membership map[int64]int64 + selected map[int64]bool +} + +// Expand selection to complete cases before the evidence store freezes snapshots. +func (s *Server) prepareFindingCaseExport(fs []*db.DBFinding) ([]*db.DBFinding, *findingCaseExportPlan, error) { + plan := &findingCaseExportPlan{cases: map[int64]*db.FindingCase{}, membership: map[int64]int64{}, selected: map[int64]bool{}} + expanded := append([]*db.DBFinding{}, fs...) + seen := map[int64]bool{} + for _, f := range fs { + seen[f.ID] = true + plan.selected[f.ID] = true + } + for _, f := range fs { + cid, err := s.m.pg.FindingCaseID(f.ID) + if err != nil { + return nil, nil, err + } + if cid == 0 { + continue + } + if _, ok := plan.cases[cid]; ok { + continue + } + c, err := s.m.pg.GetFindingCase(cid) + if err != nil { + return nil, nil, err + } + if c == nil || !c.Active { + continue + } + plan.cases[cid] = c + members, _, err := s.m.pg.FindingCaseMembers(cid, 1, 1000000) + if err != nil { + return nil, nil, err + } + for _, m := range members { + plan.membership[m.ID] = cid + if !seen[m.ID] { + seen[m.ID] = true + expanded = append(expanded, m) + } + } + } + return expanded, plan, nil +} + +// Composites are built AFTER staging real records, so their traffic attachments +// remain private frozen export copies, not aliases to live or deleted evidence. +func (s *Server) consolidateFindingCaseExport(fs []*db.DBFinding, plan *findingCaseExportPlan, includeOriginals bool) ([]*db.DBFinding, error) { + out := []*db.DBFinding{} + emitted := map[int64]bool{} + for _, f := range fs { + cid := plan.membership[f.ID] + if cid == 0 { + if plan.selected[f.ID] { + out = append(out, f) + } + continue + } + if !emitted[cid] { + emitted[cid] = true + c := plan.cases[cid] + latest, err := s.m.pg.GetFindingCase(cid) + if err != nil { + return nil, err + } + stale := latest == nil || latest.Version != c.Version || c.ReportVersion != c.Version + title := c.Title + sev := c.Severity + if sev == "" { + sev = "unassessed" + } + composite := &db.DBFinding{ID: f.ID, ExportCaseID: c.ID, TaskID: c.TaskID, TaskDescription: f.TaskDescription, Name: title, VulnClass: "归并漏洞", Severity: sev, Status: f.Status, CreatedAt: c.CreatedAt, Report: c.Report, EvidenceVersion: c.Version, ReportEvidenceVersion: c.ReportVersion} + assets := map[int64]bool{} + var refs []string + var evidence strings.Builder + for _, member := range fs { + if plan.membership[member.ID] != cid { + continue + } + composite.ExportMemberIDs = append(composite.ExportMemberIDs, member.ID) + for _, aid := range member.AssetIDs { + if !assets[aid] { + assets[aid] = true + composite.AssetIDs = append(composite.AssetIDs, aid) + } + } + refs = append(refs, fmt.Sprintf("#%d [%s] %s", member.ID, member.Severity, member.Name)) + fmt.Fprintf(&evidence, "原始上报 #%d:%s\n%s\n\n", member.ID, member.Summary, member.Evidence) + if member.Report != "" { + fmt.Fprintf(&evidence, "原始报告 #%d:\n%s\n\n", member.ID, member.Report) + } + for _, b := range member.TrafficBindings { + b.Note = fmt.Sprintf("来自上报 #%d;%s", member.ID, b.Note) + composite.TrafficBindings = append(composite.TrafficBindings, b) + } + } + composite.Summary = fmt.Sprintf("漏洞文件夹 #%d;原始记录:%s。统一评级依据:%s", cid, strings.Join(refs, ";"), c.SeverityReason) + composite.TrafficCount = len(composite.TrafficBindings) + if stale || c.Report == "" { + composite.Report = "**统一报告尚未生成或已过期,以下提供全部原始证据,不能视为完整复现已验证。**\n\n" + c.Report + composite.Evidence = evidence.String() + composite.ReportEvidenceVersion = -1 + } + out = append(out, composite) + } + if includeOriginals { + out = append(out, f) + } + } + return out, nil +} diff --git a/server/finding_cases_test.go b/server/finding_cases_test.go new file mode 100644 index 0000000..3754c0f --- /dev/null +++ b/server/finding_cases_test.go @@ -0,0 +1,323 @@ +package server + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/Autumn-27/artex/db" + "github.com/Autumn-27/artex/intercept" +) + +func testCaseServer(t *testing.T) (*Server, int64, []int64) { + t.Helper() + dsn, _, err := db.DSN() + if err != nil { + t.Fatal(err) + } + pg, err := db.Open(dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { pg.Close() }) + task, err := pg.CreateTask("case server", "goal", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + ids := []int64{} + for _, sev := range []string{"high", "medium", "low"} { + id, err := pg.AddFinding(task.ID, 0, "IDOR", "orders", sev, "same endpoint", "proof "+sev, "worker", nil) + if err != nil { + t.Fatal(err) + } + ids = append(ids, 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 +} +func TestFindingCaseAPIPaginationAndTools(t *testing.T) { + s, task, ids := testCaseServer(t) + ctx := context.Background() + a := findingCaseRequest{TaskID: json.RawMessage(fmt.Sprint(task)), Title: "订单越权", Reason: "same check"} + for _, id := range ids { + a.IDs = append(a.IDs, json.RawMessage(fmt.Sprint(id))) + } + result, err := s.performFindingCaseTool(ctx, "merge_finding_records", a) + if err != nil { + t.Fatal(err) + } + cid := result.(map[string]string)["case_id"] + r := httptest.NewRequest("GET", fmt.Sprintf("/api/exploration/finding-cases?task_id=%d&severity=low&page=1&limit=1", task), nil) + w := httptest.NewRecorder() + s.listFindingCases(w, r) + if w.Code != 200 { + t.Fatal(w.Code, w.Body) + } + var page struct { + Total int `json:"total"` + Items []struct { + Case db.FindingCase `json:"case"` + Matched []int64 `json:"matched_ids"` + } `json:"items"` + } + if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil { + t.Fatal(err) + } + if page.Total != 1 || page.Items[0].Case.Count != 3 || page.Items[0].Case.Report != "" || len(page.Items[0].Matched) != 1 { + t.Fatalf("page %+v", page) + } + r = httptest.NewRequest("GET", "/", nil) + r.SetPathValue("id", cid) + w = httptest.NewRecorder() + s.getFindingCaseMembers(w, r) + if w.Code != 200 || !strings.Contains(w.Body.String(), "proof high") { + t.Fatal(w.Code, w.Body) + } + a.CaseID = json.RawMessage(cid) + result, err = s.performFindingCaseTool(ctx, "get_finding_case", a) + if err != nil { + t.Fatal(err) + } + c := result.(map[string]any)["case"].(*db.FindingCase) + a.Version = c.Version + a.Report = "complete reproducible report" + a.Severity = "high" + a.SeverityReason = "verified data disclosure" + if _, err := s.performFindingCaseTool(ctx, "update_finding_case_report", a); err != nil { + t.Fatal(err) + } + if _, err := s.m.pg.Exec(`UPDATE findings SET evidence='new data' WHERE id=$1`, ids[1]); err != nil { + t.Fatal(err) + } + if _, err := s.performFindingCaseTool(ctx, "update_finding_case_report", a); err != db.ErrFindingCaseConflict { + t.Fatalf("stale report accepted %v", err) + } +} +func TestFindingCaseConsolidatedExportPreservesScope(t *testing.T) { + s, task, ids := testCaseServer(t) + ctx := context.Background() + cid, err := s.m.pg.MergeFindingCase(ctx, task, ids, "订单越权", "same", "reporter") + if err != nil { + t.Fatal(err) + } + c, _ := s.m.pg.GetFindingCase(cid) + if err := s.m.pg.UpdateFindingCaseReport(ctx, cid, c.Version, "订单越权", "complete report", "high", "verified"); err != nil { + t.Fatal(err) + } + fs, err := s.m.pg.ListFindingsForExport(db.FindingFilter{}, ids[:1]) + if err != nil { + t.Fatal(err) + } + expanded, plan, err := s.prepareFindingCaseExport(fs) + if err != nil { + t.Fatal(err) + } + if len(expanded) != 3 { + t.Fatal("selected one child did not include complete group") + } + out, err := s.consolidateFindingCaseExport(expanded, plan, false) + if err != nil || len(out) != 1 || out[0].Report != "complete report" || !strings.Contains(out[0].Summary, fmt.Sprint(ids[2])) { + t.Fatalf("unified %+v %v", out, err) + } + out, err = s.consolidateFindingCaseExport(expanded, plan, true) + if err != nil || len(out) != 4 { + t.Fatalf("originals lost %d %v", len(out), err) + } + if _, err := s.m.pg.Exec(`UPDATE findings SET severity='critical' WHERE id=$1`, ids[0]); err != nil { + t.Fatal(err) + } + out, err = s.consolidateFindingCaseExport(expanded, plan, false) + if err != nil || !strings.Contains(out[0].Report, "已过期") || !strings.Contains(out[0].Evidence, "proof low") { + t.Fatalf("pending export omitted evidence %+v %v", out, err) + } +} +func TestFindingCaseManualSelectionScope(t *testing.T) { + s, task, ids := testCaseServer(t) + conv, err := s.m.pg.CreateConversation("reporter", "manual", nil) + if err != nil { + t.Fatal(err) + } + defer s.m.pg.Exec(`DELETE FROM conversations WHERE id=$1`, conv.ID) + raw, _ := json.Marshal(ids[:2]) + if _, err := s.m.pg.Exec(`INSERT INTO finding_case_review_runs(conversation_id,task_id,finding_ids) VALUES($1,$2,$3)`, conv.ID, task, string(raw)); err != nil { + t.Fatal(err) + } + ctx := intercept.WithConvID(context.Background(), conv.ID) + a := findingCaseRequest{IDs: []json.RawMessage{json.RawMessage(fmt.Sprint(ids[0])), json.RawMessage(fmt.Sprint(ids[2]))}} + if err := s.checkCaseReviewScope(ctx, "merge_finding_records", a); err == nil { + t.Fatal("unselected mutation allowed") + } + a.IDs[1] = json.RawMessage(fmt.Sprint(ids[1])) + if err := s.checkCaseReviewScope(ctx, "merge_finding_records", a); err != nil { + t.Fatal(err) + } +} + +func TestFindingCaseReviewStatusSurvivesTaskDeletion(t *testing.T) { + s, task, ids := testCaseServer(t) + conv, err := s.m.pg.CreateConversation("reporter", "review", nil) + if err != nil { + t.Fatal(err) + } + defer s.m.pg.Exec(`DELETE FROM conversations WHERE id=$1`, conv.ID) + if _, err := s.m.pg.Exec(`INSERT INTO finding_case_review_runs(conversation_id,task_id,finding_ids) VALUES($1,$2,$3)`, conv.ID, task, fmt.Sprintf("[%d]", ids[0])); err != nil { + t.Fatal(err) + } + if err := s.m.pg.DeleteTask(task); err != nil { + t.Fatal(err) + } + defer func() { + for _, id := range ids { + _, _ = s.m.pg.DeleteFinding(id) + } + }() + w := httptest.NewRecorder() + s.caseReviewRuns(w, httptest.NewRequest("GET", "/", nil)) + if w.Code != 200 || !strings.Contains(w.Body.String(), `"task_id":"0"`) { + t.Fatal(w.Code, w.Body) + } +} + +func TestFindingCaseSelectedReviewCannotRewriteOtherOriginal(t *testing.T) { + s, task, ids := testCaseServer(t) + conv, err := s.m.pg.CreateConversation("reporter", "review", nil) + if err != nil { + t.Fatal(err) + } + defer s.m.pg.Exec(`DELETE FROM conversations WHERE id=$1`, conv.ID) + raw, _ := json.Marshal(ids[:1]) + if _, err := s.m.pg.Exec(`INSERT INTO finding_case_review_runs(conversation_id,task_id,finding_ids) VALUES($1,$2,$3)`, conv.ID, task, string(raw)); err != nil { + t.Fatal(err) + } + ctx := intercept.WithConvID(context.Background(), conv.ID) + 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) + } +} + +func TestFindingCaseGroupedTrafficExportAndInheritedAccess(t *testing.T) { + s, recorded, req := trafficEvidenceServer(t) + ctx := t.Context() + f, err := s.m.pg.GetFinding(recorded.FindingID) + if err != nil { + t.Fatal(err) + } + task, err := s.m.pg.GetTask(*f.TaskID) + if err != nil { + t.Fatal(err) + } + body := []byte("verified original response") + seedServerEvidenceFlow(t, s, "case-proof", body) + if w := req("POST", fmt.Sprintf("/api/exploration/findings/%d/traffic", f.ID), `{"traffic_refs":[{"traffic_id":"case-proof","role":"proof"}]}`); w.Code != 200 { + t.Fatal(w.Code, w.Body) + } + other, err := s.evidenceStore().Record(ctx, db.RecordFindingInput{TaskID: task.ID, ExplorationID: task.ExplorationID, Summary: "same permission boundary", Severity: "low"}, nil) + if err != nil { + t.Fatal(err) + } + for _, id := range []int64{f.ID, other.FindingID} { + if _, err := s.m.pg.Exec(`UPDATE findings SET report=$2 WHERE id=$1`, id, fmt.Sprintf("original script #%d", id)); err != nil { + t.Fatal(err) + } + } + cid, err := s.m.pg.MergeFindingCase(ctx, task.ID, []int64{f.ID, other.FindingID}, "orders", "same permission check", "reporter") + if err != nil { + t.Fatal(err) + } + c, _ := s.m.pg.GetFindingCase(cid) + if err := s.m.pg.UpdateFindingCaseReport(ctx, cid, c.Version, "orders", "unified complete script", "high", "verified impact"); err != nil { + t.Fatal(err) + } + // A binding note is evidence too: changing it makes the aggregate report stale. + list, err := s.m.pg.GetFindingTraffic(ctx, f.ID) + if err != nil { + t.Fatal(err) + } + w := req("PATCH", fmt.Sprintf("/api/exploration/findings/%d/traffic/%d", f.ID, list.Bindings[0].ID), fmt.Sprintf(`{"version":%d,"note":"additional verified condition"}`, list.Version)) + if w.Code != 200 { + t.Fatal(w.Code, w.Body) + } + c, _ = s.m.pg.GetFindingCase(cid) + if c.Version == c.ReportVersion { + t.Fatal("binding evidence did not invalidate unified report") + } + url := fmt.Sprintf("/api/exploration/findings/export?mode=consolidated&include_originals=true&scope=selected&ids=%d&format=md-zip", f.ID) + w = req("GET", url, "") + if w.Code != 200 { + t.Fatal(w.Code, w.Body) + } + z, err := zip.NewReader(bytes.NewReader(w.Body.Bytes()), int64(w.Body.Len())) + if err != nil { + t.Fatal(err) + } + mdCount, caseCount := 0, 0 + seen := map[string]bool{} + recovered := false + for _, file := range z.File { + if seen[file.Name] { + t.Fatalf("duplicate ZIP entry %s", file.Name) + } + seen[file.Name] = true + reader, e := file.Open() + if e != nil { + t.Fatal(e) + } + data, e := io.ReadAll(reader) + reader.Close() + if e != nil { + t.Fatal(e) + } + if strings.HasSuffix(file.Name, ".md") { + mdCount++ + if strings.HasPrefix(file.Name, "case_") { + caseCount++ + if !strings.Contains(string(data), fmt.Sprintf("original script #%d", other.FindingID)) { + t.Fatal("stale export lost an original report") + } + } + } + if strings.HasSuffix(file.Name, "response.bin") && bytes.Equal(data, body) { + recovered = true + } + } + if mdCount != 3 || caseCount != 1 || !recovered { + t.Fatalf("ZIP reports=%d folders=%d evidence=%v", mdCount, caseCount, recovered) + } + w = req("GET", fmt.Sprintf("/api/exploration/findings/export?mode=consolidated&scope=selected&ids=%d&format=json", f.ID), "") + if w.Code != 200 || !strings.Contains(w.Body.String(), `"id": "case:`) || !strings.Contains(w.Body.String(), `"member_ids"`) { + t.Fatal(w.Code, w.Body) + } + child, err := s.m.CreateTaskWithOptions("inherited folders", "fixture", db.TaskCreateOptions{SourceTaskIDs: []int64{task.ID}}) + if err != nil { + t.Fatal(err) + } + childID, _ := strconv.ParseInt(child.ID, 10, 64) + defer s.m.pg.DeleteTask(childID) + base := fmt.Sprintf("/api/exploration/finding-cases/%d", cid) + suffix := "?context_task=" + child.ID + if w = req("GET", base+suffix, ""); w.Code != 200 { + t.Fatal(w.Code, w.Body) + } + if w = req("GET", base+"/members"+suffix, ""); w.Code != 200 || !strings.Contains(w.Body.String(), `"inherited":true`) { + t.Fatal(w.Code, w.Body) + } + if w = req("DELETE", fmt.Sprintf("%s/members/%d%s", base, f.ID, suffix), `{}`); w.Code != 403 { + t.Fatal(w.Code, w.Body) + } + if w = req("PUT", base+"/report"+suffix, `{}`); w.Code != 403 { + t.Fatal(w.Code, w.Body) + } + if w = req("POST", "/api/exploration/finding-case-review"+suffix, fmt.Sprintf(`{"case_id":%d}`, cid)); w.Code != 403 { + t.Fatal(w.Code, w.Body) + } +} diff --git a/server/finding_traffic_export.go b/server/finding_traffic_export.go index 99dc599..740e41b 100644 --- a/server/finding_traffic_export.go +++ b/server/finding_traffic_export.go @@ -27,6 +27,7 @@ func writeFindingsEvidenceZip(out io.Writer, findings []*db.DBFinding, stage str func writeFindingsZipEntries(zw *zip.Writer, findings []*db.DBFinding, stage string, now time.Time) error { store := evidence.New(nil, nil, stage) // private export copy; not subject to GC used := map[string]int{} + evidencePaths := map[string]bool{} for _, f := range findings { base := report.FindingFilename(f) name := base @@ -43,6 +44,10 @@ func writeFindingsZipEntries(zw *zip.Writer, findings []*db.DBFinding, stage str } for _, b := range f.TrafficBindings { prefix := fmt.Sprintf("evidence/%d/%d/", f.ID, b.ID) + if evidencePaths[prefix] { + continue + } + evidencePaths[prefix] = true w, err := zw.Create(prefix + "manifest.json") if err != nil { return err diff --git a/server/orchestration.go b/server/orchestration.go index c5baa9b..3dd95c9 100644 --- a/server/orchestration.go +++ b/server/orchestration.go @@ -63,7 +63,7 @@ func (s *Server) hostTools() ([]actool.CoreTool, map[string][]string) { // orchestrationTools returns the cross-task tool set. Bound per-agent via the // tools table (default: no binding — opt-in for orchestration agents). func (s *Server) orchestrationTools() []actool.CoreTool { - return []actool.CoreTool{ + return append([]actool.CoreTool{ s.toolListTasks(), s.toolListLLMProfiles(), s.toolSpawnTask(), @@ -78,7 +78,7 @@ func (s *Server) orchestrationTools() []actool.CoreTool { s.toolUpdateFindingReport(), s.toolGetFindingTraffic(), s.toolBindFindingTraffic(), - } + }, s.findingCaseTools()...) } // --- schema helpers --- @@ -442,6 +442,15 @@ func (s *Server) toolUpdateFindingReport() actool.CoreTool { if nodeID <= 0 { return actool.Errorf("finding_id 无效"), nil } + fid, err := s.m.pg.FindingIDByNodeID(nodeID) + if err != nil { + return actool.Errorf(err.Error()), nil + } + if fid > 0 { + if err := s.checkCaseReviewScope(ctx, "update_finding_report", findingCaseRequest{FindingID: json.RawMessage(i64s(fid))}); err != nil { + return actool.Errorf(err.Error()), nil + } + } n, err := s.m.pg.SetFindingReportVersionByNodeID(ctx, nodeID, a.Report, a.EvidenceVersion) if err != nil { return actool.Errorf(err.Error()), nil @@ -477,7 +486,7 @@ func (s *Server) seedOrchestrationTools() { for _, t := range s.orchestrationTools() { schema, _ := json.Marshal(t.InputSchema()) bindings := autoAgents - if t.Name() == "bind_finding_traffic" { + if t.Name() == "bind_finding_traffic" || strings.Contains(t.Name(), "finding_case") || t.Name() == "search_finding_duplicates" || t.Name() == "get_finding_record" || t.Name() == "merge_finding_records" || t.Name() == "suggest_finding_merge" { bindings = json.RawMessage(`["reporter"]`) } _ = s.m.PG().SeedTool(t.Name(), t.Description(), schema, bindings) @@ -503,6 +512,7 @@ func (s *Server) seedOrchestrationTools() { s.upgradeReporterTriggerMessage() // 老库补迁移:让 reporter 回传 evidence_version(一次性) s.seedFindingTrafficTools() // 增加可选证据参数及只读证据工具,保留用户配置 s.seedFindingWorkflowTools() + s.seedFindingCaseTools() // 注:pentest 的默认工具绑定无需迁移——BuiltinToolSeeds 在全新初始化时就把 // list_assets/insert_assets/report_finding/list_findings/list_companies 连同 // pentest 一起 seed 好了(项目尚无旧库,不做迁移)。 diff --git a/server/server.go b/server/server.go index f79605d..53b5494 100644 --- a/server/server.go +++ b/server/server.go @@ -131,6 +131,8 @@ type provEntry struct { // taskID + mergeable let the drainer coalesce several event triggers (finding/goal) // from the SAME task into one conversation before it starts (interval fires don't merge). type triggeredRun struct { + conversationID int64 // pre-created manual finding review; never coalesced + agentKey string title string message string // 事件正文(触发语 + 工具/入参/返回等);不含任务描述/目标头 @@ -755,6 +757,16 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/exploration/frontier", s.frontier) mux.HandleFunc("GET /api/exploration/findings", s.findings) mux.HandleFunc("GET /api/exploration/findings/groups", s.findingGroups) + mux.HandleFunc("GET /api/exploration/finding-cases", s.listFindingCases) + mux.HandleFunc("POST /api/exploration/finding-cases", s.mergeFindingCase) + mux.HandleFunc("GET /api/exploration/finding-cases/{id}", s.getFindingCase) + mux.HandleFunc("GET /api/exploration/finding-cases/{id}/members", s.getFindingCaseMembers) + mux.HandleFunc("DELETE /api/exploration/finding-cases/{id}/members/{fid}", s.removeFindingCaseMember) + mux.HandleFunc("PUT /api/exploration/finding-cases/{id}/report", s.updateCaseReport) + mux.HandleFunc("GET /api/exploration/finding-case-suggestions", s.caseSuggestions) + mux.HandleFunc("POST /api/exploration/finding-case-suggestions/{id}", s.resolveCaseSuggestion) + mux.HandleFunc("POST /api/exploration/finding-case-review", s.reviewFindingCases) + mux.HandleFunc("GET /api/exploration/finding-case-review", s.caseReviewRuns) mux.HandleFunc("GET /api/exploration/findings/asset-tree", s.findingAssetTree) mux.HandleFunc("GET /api/exploration/findings/stats", s.findingStats) mux.HandleFunc("GET /api/exploration/findings/export", s.findingsExport) @@ -1040,6 +1052,7 @@ func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) { } list := s.m.List() metrics, _ := s.m.PG().TaskListMetricsAll() + distinct, _ := s.m.pg.DistinctFindingStatsByTask() archiveBlockers, _ := s.m.PG().TaskArchiveBlockers() dtos := make([]TaskDTO, 0, len(list)) for _, t := range list { @@ -1056,6 +1069,7 @@ func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) { dto.GoalsTotal = metric.Goals.Total dto.GoalsMet = metric.Goals.Met dto.InFlight = metric.RunningIntents + dto.DistinctFindings = distinct[t.ID] dto.Findings = FindingSeverityDTO{ Critical: metric.Findings.Critical, High: metric.Findings.High, @@ -2052,6 +2066,11 @@ func (s *Server) findingStats(w http.ResponseWriter, r *http.Request) { writeErr(w, 500, err.Error()) return } + st.Distinct, err = s.m.pg.DistinctFindingStats("") + if err != nil { + writeErr(w, 500, err.Error()) + return + } writeJSON(w, 200, st) } @@ -2074,6 +2093,14 @@ func (s *Server) getFinding(w http.ResponseWriter, r *http.Request) { } assets := s.resolveAssetIDs(f.AssetIDs) dto := findingFromDB(f, assets) + cid, err := s.m.pg.FindingCaseID(f.ID) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if cid > 0 { + dto.CaseID = i64s(cid) + } if contextTaskID := strings.TrimSpace(r.URL.Query().Get("context_task")); contextTaskID != "" { contextTask := s.m.ResolveTask(contextTaskID) if contextTask == nil { @@ -2138,6 +2165,14 @@ func (s *Server) findingsExport(w http.ResponseWriter, r *http.Request) { return } + var casePlan *findingCaseExportPlan + if q.Get("mode") == "consolidated" { + fs, casePlan, err = s.prepareFindingCaseExport(fs) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + } stage, err := os.MkdirTemp("", "artex-finding-export-") if err != nil { writeErr(w, 500, err.Error()) @@ -2148,6 +2183,13 @@ func (s *Server) findingsExport(w http.ResponseWriter, r *http.Request) { evidenceError(w, err) return } + if casePlan != nil { + fs, err = s.consolidateFindingCaseExport(fs, casePlan, q.Get("include_originals") == "true") + if err != nil { + writeErr(w, 500, err.Error()) + return + } + } now := time.Now() stamp := now.Format("20060102-150405") setDownload := func(contentType, filename string) { diff --git a/web/src/app/(main)/function/findings/case/page.tsx b/web/src/app/(main)/function/findings/case/page.tsx new file mode 100644 index 0000000..3fa7c77 --- /dev/null +++ b/web/src/app/(main)/function/findings/case/page.tsx @@ -0,0 +1,242 @@ +"use client"; +import * as React from "react"; + +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; + +import { toast } from "sonner"; + +import { FindingCaseMembers, FindingCaseReviewPanel, FindingSeverityCounts } from "@/components/finding-case-list"; +import { Markdown } from "@/components/markdown"; +import { StatusBadge } from "@/components/status-badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Spinner } from "@/components/ui/spinner"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { api } from "@/lib/api"; +import type { FindingCaseDetail } from "@/lib/types"; + +function CaseDetail() { + const params = useSearchParams(); + const id = params.get("id") ?? ""; + const contextTask = params.get("context_task") ?? undefined; + const [data, setData] = React.useState(null); + const [error, setError] = React.useState(""); + const [busy, setBusy] = React.useState(false); + const [selected, setSelected] = React.useState>(() => new Set()); + const [refresh, setRefresh] = React.useState(0); + // biome-ignore lint/correctness/useExhaustiveDependencies: refresh explicitly reloads group after a detach. + React.useEffect(() => { + let active = true; + let timer: ReturnType; + async function load() { + try { + const v = await api.getFindingCase(id, contextTask); + if (active) { + setData(v); + setError(""); + } + } catch (e) { + if (active) setError((e as Error).message); + } finally { + if (active) timer = setTimeout(load, 5000); + } + } + if (id) void load(); + return () => { + active = false; + clearTimeout(timer); + }; + }, [id, contextTask, refresh]); + const readOnly = !!contextTask && contextTask !== data?.case.task_id; + const select = (fid: string, on: boolean) => + setSelected((v) => { + const n = new Set(v); + if (on) n.add(fid); + else n.delete(fid); + return n; + }); + async function regenerate() { + setBusy(true); + try { + await api.regenerateFindingCase(id, contextTask); + toast.success("已提交重新整理,可查看执行状态"); + } catch (e) { + toast.error((e as Error).message); + } finally { + setBusy(false); + } + } + async function detach() { + setBusy(true); + try { + for (const fid of selected) await api.removeFindingCaseMember(id, fid, "人工撤销归并"); + setSelected(new Set()); + setRefresh((v) => v + 1); + toast.success("已移出,原始记录保留"); + } catch (e) { + toast.error((e as Error).message); + } finally { + setBusy(false); + } + } + if (!data) + return error ? ( + + {error} + + ) : ( + + ); + const c = data.case; + return ( +
+
+ +

{c.title}

+ + {c.severity ? : 统一评级待评估} +
+ {error ? ( + + {error} + + ) : null} + {!c.active ? ( + + 此文件夹已解散或并入其他组,原始记录仍可从漏洞列表查看。 + + ) : null} + {readOnly ? ( + + 继承任务的漏洞组,只读。 + + ) : ( + + )} + + + 统一报告 + 原始上报({c.count}) + 归并历史 + + + + + 完整复现与修复报告 + {c.severity_reason || "统一评级尚未完成"} + + + {c.report_version !== c.version ? ( + + 成员或证据已变化,统一报告待更新。 + + ) : null} + {!readOnly && c.active ? ( + + ) : null} + {c.report ? ( + + ) : ( +

统一报告尚未生成,原始报告可在“原始上报”中查看。

+ )} +
+
+
+ + + + 原始报告和证据完整保留 + 成员保留原评级;统一评级不覆盖原始记录。 + + + {!readOnly && selected.size > 0 ? ( + + + + + + + 移出归并组? + + 原报告和证据保留,Agent 不会自动把这些关系再次归并。 + + + + 取消 + 移出 + + + + ) : null} + + + + + + + + 判断依据与操作记录 + {c.reason} + + + {data.events.map((e) => ( +
+

+ {({ reporter: "报告 Agent", human: "人工", system: "系统" } as Record)[e.actor] ?? + e.actor}{" "} + ·{" "} + {( + { + merge: "归并", + detach: "移出成员", + member_deleted: "成员移出或删除", + report_updated: "更新统一报告", + } as Record + )[e.action] ?? e.action}{" "} + · {new Date(e.created_at).toLocaleString("zh-CN")} +

+

+ {e.reason} + {e.finding_ids.length > 0 ? ` · #${e.finding_ids.join(" / #")}` : ""} +

+
+ ))} +
+
+
+
+
+ ); +} +export default function Page() { + return ( + }> + + + ); +} diff --git a/web/src/app/(main)/function/findings/detail/page.tsx b/web/src/app/(main)/function/findings/detail/page.tsx index d40f962..f771776 100644 --- a/web/src/app/(main)/function/findings/detail/page.tsx +++ b/web/src/app/(main)/function/findings/detail/page.tsx @@ -140,6 +140,15 @@ function FindingDetailInner() { + {finding.case_id ? ( + + ) : null}

{title}

@@ -172,7 +181,7 @@ function FindingDetailInner() {

{finding.summary || "(无摘要)"}

- + 证据 / PoC @@ -188,7 +197,7 @@ function FindingDetailInner() { ("flat"); + const [view, setView] = React.useState("cases"); const [severity, setSeverity] = React.useState<"all" | Severity>("all"); const [status, setStatus] = React.useState<"all" | FindingStatus>("all"); const [vulnclass, setVulnclass] = React.useState("all"); @@ -252,6 +254,8 @@ export default function FindingsPage() { const [exportOpen, setExportOpen] = React.useState(false); const [exportScope, setExportScope] = React.useState<"filtered" | "all" | "selected">("filtered"); const [exportFormat, setExportFormat] = React.useState<"md-single" | "md-zip" | "csv" | "json">("md-single"); + const [includeOriginals, setIncludeOriginals] = React.useState(false); + const [reviewing, setReviewing] = React.useState(false); const [exporting, setExporting] = React.useState(false); const toggleSelected = React.useCallback((id: string, checked: boolean) => { @@ -288,6 +292,7 @@ export default function FindingsPage() { scope: exportScope, filters: { severity, status, vulnclass, task, query, sort }, ids: [...selectedIds], + includeOriginals, }); setExportOpen(false); toast.success("已开始下载导出文件"); @@ -752,17 +757,18 @@ export default function FindingsPage() { } const statCards = [ - { label: "发现总数", value: stats.total, icon: BugIcon }, - { label: "待处理", value: stats.pending, tone: "text-amber-500", icon: ClockIcon }, - { label: "严重", value: stats.critical, tone: "text-rose-600", icon: ShieldAlertIcon }, - { label: "高危", value: stats.high, tone: "text-red-500", icon: TriangleAlertIcon }, - { label: "中危", value: stats.medium, tone: "text-amber-500", icon: TriangleAlertIcon }, - { label: "低危", value: stats.low, tone: "text-slate-500", icon: InfoIcon }, + { label: "独立漏洞", value: stats.distinct?.total ?? stats.total, icon: BugIcon }, + { label: "上报记录", value: stats.total, tone: "text-amber-500", icon: ClockIcon }, + { label: "严重", value: stats.distinct?.critical ?? stats.critical, tone: "text-rose-600", icon: ShieldAlertIcon }, + { label: "高危", value: stats.distinct?.high ?? stats.high, tone: "text-red-500", icon: TriangleAlertIcon }, + { label: "中危", value: stats.distinct?.medium ?? stats.medium, tone: "text-amber-500", icon: TriangleAlertIcon }, + { label: "低危", value: stats.distinct?.low ?? stats.low, tone: "text-slate-500", icon: InfoIcon }, ]; // 导出弹窗里「当前筛选」的条数:两个视图的筛选一致,只是统计口径来源不同。 // 平铺与资产视图共用 flat 列表状态,分组视图的口径来自组接口的 finding_total。 - const filteredTotal = view === "grouped" ? total : flat.total; + const [caseTotal, setCaseTotal] = React.useState(0); + const filteredTotal = view === "cases" ? caseTotal : view === "grouped" ? total : flat.total; const assetPath = React.useMemo( () => (view === "asset" ? assetPathOf(assetTree.nodes, assetScope) : []), [assetScope, assetTree.nodes, view], @@ -823,7 +829,8 @@ export default function FindingsPage() { setView(v as FindingView)}> - 全部发现 + 按漏洞 + 原始上报 按任务分组 按资产 @@ -953,6 +960,24 @@ export default function FindingsPage() { {selectedIds.size > 0 && ( 已选 {selectedIds.size} 条 )} + @@ -1015,6 +1040,14 @@ export default function FindingsPage() { )} + {view === "cases" && ( + + )} {view === "grouped" && (
{groups.map((group) => { @@ -1231,6 +1264,16 @@ export default function FindingsPage() {
导出格式 +
+ setIncludeOriginals(v === true)} + /> + +
setExportFormat(v as typeof exportFormat)}>