From 93dc83165cf135658b4114c6fc7f23d06a168df2 Mon Sep 17 00:00:00 2001 From: MuCheng Date: Tue, 27 Oct 2026 17:17:34 +0800 Subject: [PATCH] feat(findings): structured reproduction steps with per-step traffic bind Align finding detail reproduction with AntiFox: store reproduction JSONB (entry_point/prerequisites/steps/provenance), bind traffic via step_index, syntax-highlight HTTP in steps/traffic panel, fall back to legacy evidence when reproduction is absent. Old findings are not backfilled. --- agent/finding_recorder_test.go | 29 +++++ agent/promptcatalog.go | 2 +- agent/tools.go | 41 ++++++- db/exploration.go | 4 +- db/finding_reproduction_test.go | 71 +++++++++++ db/finding_traffic.go | 41 +++++-- db/finding_traffic_archive.go | 11 +- db/findings.go | 42 +++++-- db/schema.sql | 4 + server/dto.go | 46 +++---- server/finding_traffic.go | 27 +++- server/finding_workflow.go | 11 +- server/inheritance_dto_test.go | 12 ++ .../(main)/function/findings/detail/page.tsx | 41 ++++--- .../finding-reproduction-section.tsx | 116 ++++++++++++++++++ web/src/components/finding-traffic-panel.tsx | 39 +++++- web/src/components/traffic-picker-dialog.tsx | 3 + web/src/lib/api.ts | 6 +- web/src/lib/types.ts | 18 +++ 19 files changed, 493 insertions(+), 71 deletions(-) create mode 100644 db/finding_reproduction_test.go create mode 100644 web/src/components/finding-reproduction-section.tsx diff --git a/agent/finding_recorder_test.go b/agent/finding_recorder_test.go index db7d90a..ae2be29 100644 --- a/agent/finding_recorder_test.go +++ b/agent/finding_recorder_test.go @@ -50,6 +50,35 @@ func TestReportFindingOptionalTrafficWithoutCapture(t *testing.T) { } } +func TestReportFindingPersistsStructuredReproduction(t *testing.T) { + d := testDB(t) + defer d.Close() + task, err := d.CreateTask("structured reproduction", "fixture", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + defer d.DeleteTask(task.ID) + ts := NewToolSet(d.Exploration(task.ExplorationID), "fixture") + ts.taskID = task.ID + ts.SetFindingRecorder(evidence.New(d, nil, t.TempDir())) + reproduction := `{"entry_point":"POST /upload.php","prerequisites":["anonymous access"],"steps":[{"request":"POST /upload.php HTTP/1.1","response":"HTTP/1.1 201 Created","expected":"201 Created","proves":"upload accepted","traffic_ids":[]}]}` + result, err := ts.addFinding().Call(t.Context(), json.RawMessage(`{"vulnclass":"upload","severity":"high","summary":"structured fixture","evidence":"proof","reproduction":`+reproduction+`}`), nil) + if err != nil { + t.Fatal(err) + } + var recorded db.RecordedFinding + if err := json.Unmarshal([]byte(strings.SplitN(result.Flatten(), "\n", 2)[1]), &recorded); err != nil { + t.Fatal(err) + } + finding, err := d.GetFinding(recorded.FindingID) + if err != nil { + t.Fatal(err) + } + if finding == nil || string(finding.Reproduction) != reproduction { + t.Fatalf("reproduction was not persisted: %#v", finding) + } +} + func TestReportFindingAtomicContract(t *testing.T) { old := FindingTrafficBindingEnabled FindingTrafficBindingEnabled = func() bool { return true } diff --git a/agent/promptcatalog.go b/agent/promptcatalog.go index 67f1fa7..3c27095 100644 --- a/agent/promptcatalog.go +++ b/agent/promptcatalog.go @@ -54,7 +54,7 @@ const pentestDefaultTmpl = `你是一个授权渗透测试系统的"独立渗透 - **发现新资产/入口** → insert_assets(资产本身:endpoint/parameter/tech 指纹/service/凭据/子域等,结构化属性写在资产 props 上)。回看已登记资产用 list_assets,避免重复登记。 - **确认漏洞** → report_finding(含可复现 PoC)。**只有你在本次运行里真实触发过、拿到可复现证据(请求/响应或命令输出)才用它**;回看已报漏洞用 list_findings。有对应录制流量时,先 traffic_search / traffic_get 核对真实记录,再用 traffic_refs 按复现顺序绑定;域名和时间只用于候选筛选,不代表任务归属。严禁把仅凭版本/CVE 匹配、"看起来可注入"、外部漏洞库/更新日志/代码 diff 推断的东西当已确认漏洞上报。**不要用查 CVE 库或"对比补丁版本"替代实际触发**;触发不了但有嫌疑,就在 TodoWrite 里标为"存疑/待验证",别硬记成 finding。 -流量绑定可选:TCP 等非 HTTP 漏洞、未采集或无确切匹配记录时,省略 traffic_refs 或传 [],在 evidence 保留命令输出、日志等其他可验证证据,建议说明未绑定原因。不要猜测 ID,也不要仅为补包重复探测。 +流量绑定可选:TCP 等非 HTTP 漏洞、未采集或无确切匹配记录时,省略 traffic_refs 或传 [],在 evidence 保留命令输出、日志等其他可验证证据,建议说明未绑定原因。不要猜测 ID,也不要仅为补包重复探测。report_finding 还应填写 reproduction:entry_point、prerequisites、每步真实 request/response、expected、proves;有来源时填写 provenance 和步骤 traffic_ids,保留请求原文,不臆造或改写。 ━━ 判定与收尾 ━━ - 随时对照任务目标:已被你**验证过**的成果满足了目标,就据此判定达成并说明依据。判"达成"的前提是心法 4 的自检已通过——没独立复现过的战果不算达成依据。 diff --git a/agent/tools.go b/agent/tools.go index 52774b2..6dea34a 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -1222,13 +1222,24 @@ func (t *ToolSet) goalMet() actool.CoreTool { // --- worker write tools --- func (t *ToolSet) addFinding() actool.CoreTool { - return writeTool("report_finding", "记录确认的漏洞,用 evidence 提供命令输出、日志等可验证证据。任务上下文传当前 intent_id。返回的 finding_id 是独立漏洞记录 ID,finding_node_id 是探索节点 ID(第一行保留该节点编号)。", obj(map[string]any{ + return writeTool("report_finding", "记录确认的漏洞,用 evidence 提供命令输出、日志等可验证证据。请务必填写结构化 reproduction:entry_point、prerequisites,以及每一步真实发出的 HTTP request/可选 response、expected、proves;不要改写请求原文。任务上下文传当前 intent_id。返回的 finding_id 是独立漏洞记录 ID,finding_node_id 是探索节点 ID(第一行保留该节点编号)。", obj(map[string]any{ "vulnclass": str("漏洞类别"), "name": str("漏洞名称"), "severity": str("critical|high|medium|low"), "summary": str("发现摘要"), "intent_id": idp("当前任务的意图 id"), "asset_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, "description": "受影响资产 id"}, "evidence": str("证据/PoC 文本"), "evidence_hint_id": idp("可选:本任务中对应此漏洞的提示节点 ID,自动携带其结构化 traffic_refs;不能引用继承提示或其他漏洞的提示"), "traffic_refs": map[string]any{"type": "array", "description": "可选;HTTP/HTTPS 漏洞先检索并逐条核实请求/响应确实支持漏洞结论,再按复现顺序填写真实 ID。TCP 等非 HTTP 漏洞、未采集或找不到确切记录时省略或传 [],不阻止上报;可在 evidence 说明原因并提供其他可验证证据。不要猜测 ID、按域名/时间推定关联或仅为补包重复探测。用途 baseline 正常对照 / proof 漏洞证明 / verification 补充验证 / supporting 辅助证据。", "items": obj(map[string]any{"traffic_id": str("traffic_search 返回的真实流量 ID"), "role": map[string]any{"type": "string", "enum": []string{"baseline", "proof", "verification", "supporting"}}, "note": str("该流量如何支持漏洞结论")}, "traffic_id")}, + "reproduction": map[string]any{"type": "object", "description": "结构化、可从零复现的真实过程;有 HTTP 时每一步都填原始请求,响应填关键证明片段。", + "properties": map[string]any{ + "entry_point": str("入口接口,例如 POST /upload.php"), + "prerequisites": map[string]any{"type": "array", "items": str("前置条件,例如匿名访问或测试账号注册方式")}, + "steps": map[string]any{"type": "array", "items": obj(map[string]any{ + "request": str("真实发出的标准 HTTP 请求原文,逐字保留"), "response": str("可选:真实 HTTP 响应原文或关键证明片段"), "expected": str("该步预期结果"), "proves": str("该步证明了什么"), + "traffic_ids": map[string]any{"type": "array", "items": str("可选:traffic_search 返回且已核实的真实流量 ID")}, + }, "request", "expected", "proves")}, + "provenance": str("攻击链来源或发现该漏洞的意图"), + }, + }, }, "vulnclass", "severity", "summary"), func(ctx context.Context, in json.RawMessage) (actool.Result, error) { var a struct { VulnClass, Name, Severity, Summary, Evidence string @@ -1236,6 +1247,7 @@ func (t *ToolSet) addFinding() actool.CoreTool { AssetIDs []json.RawMessage `json:"asset_ids"` TrafficRefs []db.TrafficRef `json:"traffic_refs"` EvidenceHintID json.RawMessage `json:"evidence_hint_id"` + Reproduction json.RawMessage `json:"reproduction"` } if err := json.Unmarshal(in, &a); err != nil { return actool.Errorf(err.Error()), nil @@ -1258,7 +1270,32 @@ func (t *ToolSet) addFinding() actool.CoreTool { if err != nil { return actool.Errorf(err.Error()), nil } - input := db.RecordFindingInput{TaskID: t.taskID, ExplorationID: t.ts.ID(), IntentID: pid(a.IntentID), VulnClass: a.VulnClass, Name: a.Name, Severity: a.Severity, Summary: a.Summary, Evidence: a.Evidence, Worker: t.worker, AssetIDs: pidList(a.AssetIDs)} + // Step traffic_ids are resolved at report time. Explicit traffic_refs that + // are not listed by a step remain overview bindings (step_index=0). + if findingTrafficBindingEnabled() && len(a.Reproduction) > 0 && string(a.Reproduction) != "null" { + var recipe struct { + Steps []struct { + TrafficIDs []string `json:"traffic_ids"` + } `json:"steps"` + } + if err := json.Unmarshal(a.Reproduction, &recipe); err != nil { + return actool.Errorf(fmt.Sprintf("reproduction JSON 无效: %v", err)), nil + } + seen := map[string]bool{} + stepRefs := make([]db.TrafficRef, 0) + for i, step := range recipe.Steps { + for _, id := range step.TrafficIDs { + id = strings.TrimSpace(id) + if id == "" || seen[id] { + continue + } + seen[id] = true + stepRefs = append(stepRefs, db.TrafficRef{TrafficID: id, Role: "supporting", StepIndex: i + 1}) + } + } + refs = append(stepRefs, refs...) + } + input := db.RecordFindingInput{TaskID: t.taskID, ExplorationID: t.ts.ID(), IntentID: pid(a.IntentID), VulnClass: a.VulnClass, Name: a.Name, Severity: a.Severity, Summary: a.Summary, Evidence: a.Evidence, Worker: t.worker, AssetIDs: pidList(a.AssetIDs), Reproduction: a.Reproduction} var recorded *db.RecordedFinding if t.findingRecorder != nil { recorded, err = t.findingRecorder.Record(ctx, input, refs) diff --git a/db/exploration.go b/db/exploration.go index f41ef99..a6adac6 100644 --- a/db/exploration.go +++ b/db/exploration.go @@ -2016,6 +2016,6 @@ func (s *ExplorationStore) ActivityByIDsForTerminalIntents(ids []int64) ([]Activ // AddStandaloneFinding writes a finding to the standalone findings table, which // persists across task deletion (task_id / node_id become NULL when the task or // exploration node is deleted). taskID and nodeID may be 0 (stored as NULL). -func (s *ExplorationStore) AddStandaloneFinding(taskID, nodeID int64, vulnclass, name, severity, summary, evidence, worker string, assetIDs []int64) (int64, error) { - return s.db.AddFinding(taskID, nodeID, vulnclass, name, severity, summary, evidence, worker, assetIDs) +func (s *ExplorationStore) AddStandaloneFinding(taskID, nodeID int64, vulnclass, name, severity, summary, evidence, worker string, assetIDs []int64, reproduction ...json.RawMessage) (int64, error) { + return s.db.AddFinding(taskID, nodeID, vulnclass, name, severity, summary, evidence, worker, assetIDs, reproduction...) } diff --git a/db/finding_reproduction_test.go b/db/finding_reproduction_test.go new file mode 100644 index 0000000..3fbc9e0 --- /dev/null +++ b/db/finding_reproduction_test.go @@ -0,0 +1,71 @@ +package db + +import ( + "database/sql" + "encoding/json" + "testing" +) + +func TestFindingReproductionAndTrafficStepIndex(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer d.Close() + repro := json.RawMessage(`{"entry_point":"POST /upload.php","steps":[{"request":"POST /upload.php HTTP/1.1","expected":"201","proves":"upload accepted"}]}`) + fid, err := d.AddFinding(0, 0, "upload", "upload", SeverityHigh, "summary", "poc", "test", nil, repro) + if err != nil { + t.Fatal(err) + } + defer d.DeleteFinding(fid) + f, err := d.GetFinding(fid) + if err != nil || string(f.Reproduction) != string(repro) { + t.Fatalf("reproduction round trip: %#v, %v", f, err) + } + snapshot := TrafficEvidenceSnapshot{SourceTrafficID: "source-1", CapturedAt: 1, URL: "https://example.test/upload.php", Method: "POST", Status: 201, ReqHash: "req", RespHash: "resp"} + snapshot.ID = TrafficSnapshotID(snapshot) + prepared := []PreparedTrafficEvidence{{Ref: TrafficRef{TrafficID: "source-1", StepIndex: 2, Role: "proof"}, Snapshot: snapshot}} + if err := d.WithEvidenceTx(t.Context(), func(tx *sql.Tx) error { + if err := InsertEvidenceSnapshotTx(tx, snapshot); err != nil { + return err + } + return AddFindingTrafficTx(tx, fid, prepared) + }); err != nil { + t.Fatal(err) + } + traffic, err := d.GetFindingTraffic(t.Context(), fid) + if err != nil || len(traffic.Bindings) != 1 || traffic.Bindings[0].StepIndex != 2 { + t.Fatalf("step binding round trip: %#v, %v", traffic, err) + } + zero := 0 + if err := d.EditFindingTrafficStep(t.Context(), fid, traffic.Bindings[0].ID, traffic.Version, nil, nil, &zero, false, nil); err != nil { + t.Fatal(err) + } + traffic, err = d.GetFindingTraffic(t.Context(), fid) + if err != nil || traffic.Bindings[0].StepIndex != 0 { + t.Fatalf("step edit: %#v, %v", traffic, err) + } + if err := d.EditFindingTraffic(t.Context(), fid, traffic.Bindings[0].ID, traffic.Version, nil, nil, true, nil); err != nil { + t.Fatal(err) + } +} + +func TestFindingWithoutReproductionLoadsNull(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer d.Close() + fid, err := d.AddFinding(0, 0, "legacy", "legacy", SeverityLow, "summary", "evidence", "test", nil) + if err != nil { + t.Fatal(err) + } + defer d.DeleteFinding(fid) + f, err := d.GetFinding(fid) + if err != nil { + t.Fatal(err) + } + if f.Reproduction != nil { + t.Fatalf("legacy reproduction = %s, want nil", f.Reproduction) + } +} diff --git a/db/finding_traffic.go b/db/finding_traffic.go index bfa485d..f9b6278 100644 --- a/db/finding_traffic.go +++ b/db/finding_traffic.go @@ -43,6 +43,7 @@ type TrafficRef struct { TrafficID string `json:"traffic_id"` Role string `json:"role"` Note string `json:"note"` + StepIndex int `json:"step_index,omitempty"` } func NormalizeTrafficRefs(refs []TrafficRef) ([]TrafficRef, error) { @@ -56,6 +57,9 @@ func NormalizeTrafficRefs(refs []TrafficRef) ([]TrafficRef, error) { if ref.Role == "" { ref.Role = "supporting" } + if ref.StepIndex < 0 { + return nil, errors.New("step_index 不能为负数") + } if !ValidTrafficRole(ref.Role) { return nil, fmt.Errorf("无效的流量用途 %q", ref.Role) } @@ -95,6 +99,7 @@ type FindingTrafficBinding struct { Role string `json:"role"` Note string `json:"note"` Position int `json:"position"` + StepIndex int `json:"step_index"` CreatedAt time.Time `json:"created_at"` Snapshot TrafficEvidenceSnapshot `json:"snapshot"` } @@ -215,8 +220,8 @@ func AddFindingTrafficTx(tx *sql.Tx, findingID int64, prepared []PreparedTraffic if _, err := tx.Exec(`UPDATE traffic_evidence_snapshots SET unreferenced_at=NULL WHERE id=$1`, item.Snapshot.ID); err != nil { return err } - res, err := tx.Exec(`INSERT INTO finding_traffic_bindings(finding_id,snapshot_id,role,note,position) -VALUES($1,$2,$3,$4,$5) ON CONFLICT(finding_id,snapshot_id) DO NOTHING`, findingID, item.Snapshot.ID, item.Ref.Role, item.Ref.Note, position) + res, err := tx.Exec(`INSERT INTO finding_traffic_bindings(finding_id,snapshot_id,role,note,position,step_index) +VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(finding_id,snapshot_id) DO NOTHING`, findingID, item.Snapshot.ID, item.Ref.Role, item.Ref.Note, position, item.Ref.StepIndex) if err != nil { return err } @@ -248,7 +253,7 @@ func FindingTrafficTx(tx *sql.Tx, findingID int64) (*FindingTraffic, error) { } return nil, err } - rows, err := tx.Query(`SELECT b.id,b.finding_id,b.snapshot_id,b.role,b.note,b.position,b.created_at,to_jsonb(s) + rows, err := tx.Query(`SELECT b.id,b.finding_id,b.snapshot_id,b.role,b.note,b.position,b.step_index,b.created_at,to_jsonb(s) FROM finding_traffic_bindings b JOIN traffic_evidence_snapshots s ON s.id=b.snapshot_id WHERE b.finding_id=$1 ORDER BY b.position,b.id`, findingID) if err != nil { return nil, err @@ -257,7 +262,7 @@ FROM finding_traffic_bindings b JOIN traffic_evidence_snapshots s ON s.id=b.snap for rows.Next() { var b FindingTrafficBinding var raw []byte - if err := rows.Scan(&b.ID, &b.FindingID, &b.SnapshotID, &b.Role, &b.Note, &b.Position, &b.CreatedAt, &raw); err != nil { + if err := rows.Scan(&b.ID, &b.FindingID, &b.SnapshotID, &b.Role, &b.Note, &b.Position, &b.StepIndex, &b.CreatedAt, &raw); err != nil { return nil, err } if err := json.Unmarshal(raw, &b.Snapshot); err != nil { @@ -274,9 +279,16 @@ func (d *DB) GetFindingTraffic(ctx context.Context, findingID int64) (out *Findi } func (d *DB) EditFindingTraffic(ctx context.Context, findingID, bindingID, version int64, role, note *string, remove bool, order []int64) error { + return d.EditFindingTrafficStep(ctx, findingID, bindingID, version, role, note, nil, remove, order) +} + +func (d *DB) EditFindingTrafficStep(ctx context.Context, findingID, bindingID, version int64, role, note *string, stepIndex *int, remove bool, order []int64) error { if role != nil && !ValidTrafficRole(*role) { return errors.New("无效的流量用途") } + if stepIndex != nil && *stepIndex < 0 { + return errors.New("step_index 不能为负数") + } return d.WithEvidenceTx(ctx, func(tx *sql.Tx) error { if err := LockFindingEvidenceTx(tx, findingID, &version); err != nil { return err @@ -308,7 +320,7 @@ func (d *DB) EditFindingTraffic(ctx context.Context, findingID, bindingID, versi if remove { res, err = tx.Exec(`DELETE FROM finding_traffic_bindings WHERE finding_id=$1 AND id=$2`, findingID, bindingID) } else { - res, err = tx.Exec(`UPDATE finding_traffic_bindings SET role=COALESCE($3,role),note=COALESCE($4,note) WHERE finding_id=$1 AND id=$2`, findingID, bindingID, role, note) + res, err = tx.Exec(`UPDATE finding_traffic_bindings SET role=COALESCE($3,role),note=COALESCE($4,note),step_index=COALESCE($5,step_index) WHERE finding_id=$1 AND id=$2`, findingID, bindingID, role, note, stepIndex) } if err != nil { return err @@ -329,6 +341,7 @@ type RecordFindingInput struct { TaskID, ExplorationID, IntentID int64 VulnClass, Name, Severity, Summary, Evidence, Worker string AssetIDs []int64 + Reproduction json.RawMessage } type RecordedFinding struct { @@ -361,7 +374,15 @@ func RecordFindingTx(ctx context.Context, tx *sql.Tx, in RecordFindingInput, pre return nil, errors.New("intent_id 必须是本任务的意图(关联任务意图只读)") } } - payload, _ := json.Marshal(map[string]any{"vulnclass": in.VulnClass, "name": in.Name, "severity": in.Severity, "summary": in.Summary, "evidence": map[string]any{"by": in.Worker, "poc": in.Evidence}}) + payloadMap := map[string]any{"vulnclass": in.VulnClass, "name": in.Name, "severity": in.Severity, "summary": in.Summary, "evidence": map[string]any{"by": in.Worker, "poc": in.Evidence}} + if len(in.Reproduction) > 0 && string(in.Reproduction) != "null" { + var reproduction any + if err := json.Unmarshal(in.Reproduction, &reproduction); err != nil { + return nil, fmt.Errorf("reproduction JSON 无效: %w", err) + } + payloadMap["reproduction"] = reproduction + } + payload, _ := json.Marshal(payloadMap) out := &RecordedFinding{} if err := tx.QueryRow(`INSERT INTO exploration_nodes(exploration_id,kind,payload,priority,state,origin) VALUES($1,'finding',$2,9,'confirmed',$3) RETURNING id`, in.ExplorationID, string(payload), in.Worker).Scan(&out.NodeID); err != nil { @@ -382,8 +403,12 @@ VALUES($1,'finding',$2,9,'confirmed',$3) RETURNING id`, in.ExplorationID, string assets = []int64{} } raw, _ := json.Marshal(assets) - if err := tx.QueryRow(`INSERT INTO findings(task_id,node_id,vulnclass,name,severity,summary,evidence,worker,asset_ids) -VALUES(NULLIF($1,0),$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`, in.TaskID, out.NodeID, in.VulnClass, in.Name, in.Severity, in.Summary, in.Evidence, in.Worker, string(raw)).Scan(&out.FindingID); err != nil { + var repro any + if len(in.Reproduction) > 0 && string(in.Reproduction) != "null" { + repro = string(in.Reproduction) + } + if err := tx.QueryRow(`INSERT INTO findings(task_id,node_id,vulnclass,name,severity,summary,evidence,worker,asset_ids,reproduction) +VALUES(NULLIF($1,0),$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id`, in.TaskID, out.NodeID, in.VulnClass, in.Name, in.Severity, in.Summary, in.Evidence, in.Worker, string(raw), repro).Scan(&out.FindingID); err != nil { return nil, err } // 在**同一事务**里登记一条推送事件:提交即保证「漏洞落库」与「推送任务存在」 diff --git a/db/finding_traffic_archive.go b/db/finding_traffic_archive.go index 8e496af..7cd3703 100644 --- a/db/finding_traffic_archive.go +++ b/db/finding_traffic_archive.go @@ -51,6 +51,12 @@ func restoreFindingTrafficTx(tx *sql.Tx, snapshot *TaskArchiveSnapshot) error { if !allowed[sid] || !ValidTrafficRole(role) { return errors.New("invalid archived evidence binding") } + if rawStep, exists := row["step_index"]; exists && rawStep != nil { + step, ok := jsonInt64(rawStep) + if !ok || step < 0 { + return errors.New("invalid archived evidence step index") + } + } var owned bool if err = tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM findings WHERE id=$1 AND task_id=$2)`, fid, snapshot.TaskID).Scan(&owned); err != nil { return err @@ -61,7 +67,10 @@ func restoreFindingTrafficTx(tx *sql.Tx, snapshot *TaskArchiveSnapshot) error { } if len(rows) > 0 { raw, _ := json.Marshal(rows) - if _, err = tx.Exec(`INSERT INTO finding_traffic_bindings SELECT * FROM json_populate_recordset(NULL::finding_traffic_bindings,$1::json)`, string(raw)); err != nil { + if _, err = tx.Exec(`INSERT INTO finding_traffic_bindings + (id,finding_id,snapshot_id,role,note,position,created_at,step_index) + SELECT id,finding_id,snapshot_id,role,note,position,created_at,COALESCE(step_index,0) + FROM json_to_recordset($1::json) AS x(id bigint,finding_id bigint,snapshot_id text,role text,note text,position integer,created_at timestamptz,step_index integer)`, string(raw)); err != nil { return err } _, err = tx.Exec(`UPDATE traffic_evidence_snapshots s SET unreferenced_at=NULL WHERE EXISTS(SELECT 1 FROM finding_traffic_bindings b WHERE b.snapshot_id=s.id)`) diff --git a/db/findings.go b/db/findings.go index 7ec541e..cd66b6c 100644 --- a/db/findings.go +++ b/db/findings.go @@ -34,11 +34,30 @@ type DBFinding struct { Worker string AssetIDs []int64 Status string - Report string // 详细报告(Markdown);仅 GetFinding 填充,列表查询不带 + Report string // 详细报告(Markdown);仅 GetFinding 填充,列表查询不带 + Reproduction json.RawMessage // 结构化复现过程; NULL 表示未提供 CreatedAt time.Time TaskDescription string // populated via LEFT JOIN on tasks } +// Reproduction is the structured reproduction recipe captured by report_finding. +// It is kept as JSON in the findings table so older rows remain nullable while +// callers that need the shape can decode it without inventing steps from prose. +type Reproduction struct { + EntryPoint string `json:"entry_point,omitempty"` + Prerequisites []string `json:"prerequisites,omitempty"` + Steps []ReproStep `json:"steps,omitempty"` + Provenance string `json:"provenance,omitempty"` +} + +type ReproStep struct { + Request string `json:"request"` + Response string `json:"response,omitempty"` + Expected string `json:"expected,omitempty"` + Proves string `json:"proves,omitempty"` + TrafficIDs []string `json:"traffic_ids,omitempty"` +} + // Finding triage states (findings.status). const ( FindingPending = "pending" // 待处理 @@ -82,7 +101,7 @@ func ValidSeverity(s string) bool { // AddFinding inserts a finding into the standalone findings table. taskID and // nodeID may be 0 (stored as NULL). name may be "" (frontend falls back to // vulnclass). Returns the new finding id. -func (d *DB) AddFinding(taskID, nodeID int64, vulnclass, name, severity, summary, evidence, worker string, assetIDs []int64) (int64, error) { +func (d *DB) AddFinding(taskID, nodeID int64, vulnclass, name, severity, summary, evidence, worker string, assetIDs []int64, reproduction ...json.RawMessage) (int64, error) { aidsJSON, _ := json.Marshal(assetIDs) if assetIDs == nil { aidsJSON = []byte("[]") @@ -94,11 +113,15 @@ func (d *DB) AddFinding(taskID, nodeID int64, vulnclass, name, severity, summary if nodeID > 0 { nid = &nodeID } + var repro any + if len(reproduction) > 0 && len(reproduction[0]) > 0 && string(reproduction[0]) != "null" { + repro = string(reproduction[0]) + } var id int64 err := d.QueryRow( - `INSERT INTO findings (task_id, node_id, vulnclass, name, severity, summary, evidence, worker, asset_ids) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`, - tid, nid, vulnclass, name, severity, summary, evidence, worker, string(aidsJSON), + `INSERT INTO findings (task_id, node_id, vulnclass, name, severity, summary, evidence, worker, asset_ids, reproduction) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`, + tid, nid, vulnclass, name, severity, summary, evidence, worker, string(aidsJSON), repro, ).Scan(&id) return id, err } @@ -646,14 +669,14 @@ func (d *DB) FindingStats() (*FindingStats, error) { // it also selects `report` — that column is only needed on the detail page. func (d *DB) GetFinding(id int64) (*DBFinding, error) { f := &DBFinding{} - var aidsJSON string - err := d.QueryRow(`SELECT `+findingSelectCols+`, COALESCE(f.report, '') + var aidsJSON, reproduction string + err := d.QueryRow(`SELECT `+findingSelectCols+`, COALESCE(f.report, ''), COALESCE(f.reproduction::text, '') FROM findings f LEFT JOIN tasks t ON f.task_id = t.id WHERE f.id = $1`, id).Scan( &f.ID, &f.TaskID, &f.NodeID, &f.VulnClass, &f.Name, &f.Severity, &f.Summary, &f.Evidence, &f.Worker, &aidsJSON, &f.Status, &f.CreatedAt, - &f.TaskDescription, &f.EvidenceVersion, &f.ReportEvidenceVersion, &f.TrafficCount, &f.Report) + &f.TaskDescription, &f.EvidenceVersion, &f.ReportEvidenceVersion, &f.TrafficCount, &f.Report, &reproduction) if err == sql.ErrNoRows { return nil, nil } @@ -661,6 +684,9 @@ func (d *DB) GetFinding(id int64) (*DBFinding, error) { return nil, err } _ = json.Unmarshal([]byte(aidsJSON), &f.AssetIDs) + if reproduction != "" { + f.Reproduction = json.RawMessage(reproduction) + } return f, nil } diff --git a/db/schema.sql b/db/schema.sql index 7599e7c..363a05e 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1167,6 +1167,7 @@ CREATE TABLE IF NOT EXISTS findings ( ALTER TABLE findings ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'pending'; ALTER TABLE findings ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT ''; ALTER TABLE findings ADD COLUMN IF NOT EXISTS report TEXT NOT NULL DEFAULT ''; +ALTER TABLE findings ADD COLUMN IF NOT EXISTS reproduction JSONB; CREATE INDEX IF NOT EXISTS idx_findings_task ON findings(task_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_findings_time ON findings(created_at DESC); CREATE INDEX IF NOT EXISTS idx_findings_status ON findings(status, created_at DESC); @@ -1231,11 +1232,14 @@ CREATE TABLE IF NOT EXISTS finding_traffic_bindings ( snapshot_id TEXT NOT NULL REFERENCES traffic_evidence_snapshots(id), role TEXT NOT NULL DEFAULT 'supporting', note TEXT NOT NULL DEFAULT '', + step_index INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE(finding_id, snapshot_id) ); +ALTER TABLE finding_traffic_bindings ADD COLUMN IF NOT EXISTS step_index INTEGER NOT NULL DEFAULT 0; CREATE INDEX IF NOT EXISTS idx_finding_traffic_order ON finding_traffic_bindings(finding_id, position, id); +CREATE INDEX IF NOT EXISTS idx_finding_traffic_step ON finding_traffic_bindings(finding_id, step_index, position, id); CREATE INDEX IF NOT EXISTS idx_finding_traffic_snapshot ON finding_traffic_bindings(snapshot_id); -- ===================================================================== diff --git a/server/dto.go b/server/dto.go index fabfd65..25e4922 100644 --- a/server/dto.go +++ b/server/dto.go @@ -324,6 +324,7 @@ type FindingDTO struct { ReportEvidenceVersion int64 `json:"report_evidence_version"` ReportStale bool `json:"report_stale"` TrafficBindings []db.FindingTrafficBinding `json:"traffic_bindings,omitempty"` + Reproduction json.RawMessage `json:"reproduction"` ID string `json:"id"` FindingID string `json:"finding_id,omitempty"` // standalone findings-table id — the handle for status updates @@ -386,25 +387,27 @@ func assetLabel(a *db.Asset) string { // findingPayload mirrors the JSON written by the worker's report_finding tool // (agent/tools.go addFinding): {vulnclass, severity, summary, evidence:{by,poc}}. type findingPayload struct { - VulnClass string `json:"vulnclass"` - Name string `json:"name"` - Severity string `json:"severity"` - Summary string `json:"summary"` - Evidence json.RawMessage `json:"evidence"` + VulnClass string `json:"vulnclass"` + Name string `json:"name"` + Severity string `json:"severity"` + Summary string `json:"summary"` + Evidence json.RawMessage `json:"evidence"` + Reproduction json.RawMessage `json:"reproduction"` } func findingDTO(n *db.Node) FindingDTO { var p findingPayload _ = json.Unmarshal(n.Payload, &p) d := FindingDTO{ - ID: i64s(n.ID), - VulnClass: p.VulnClass, - Name: p.Name, - Severity: p.Severity, - Status: db.FindingPending, - Summary: p.Summary, - Evidence: rawString(p.Evidence), - TS: rfc3339(n.CreatedAt), + ID: i64s(n.ID), + VulnClass: p.VulnClass, + Name: p.Name, + Severity: p.Severity, + Status: db.FindingPending, + Summary: p.Summary, + Evidence: rawString(p.Evidence), + Reproduction: p.Reproduction, + TS: rfc3339(n.CreatedAt), } if n.SourceTaskID > 0 { d.SourceTaskID = i64s(n.SourceTaskID) @@ -464,14 +467,15 @@ func findingFromDB(f *db.DBFinding, assets map[int64]*db.Asset) FindingDTO { FindingID: i64s(f.ID), TrafficCount: f.TrafficCount, EvidenceVersion: f.EvidenceVersion, ReportEvidenceVersion: f.ReportEvidenceVersion, ReportStale: f.Report != "" && f.EvidenceVersion != f.ReportEvidenceVersion, TrafficBindings: f.TrafficBindings, - VulnClass: f.VulnClass, - Name: f.Name, - Severity: f.Severity, - Status: status, - Summary: f.Summary, - Evidence: f.Evidence, - Report: f.Report, - TS: rfc3339(f.CreatedAt), + Reproduction: f.Reproduction, + VulnClass: f.VulnClass, + Name: f.Name, + Severity: f.Severity, + Status: status, + Summary: f.Summary, + Evidence: f.Evidence, + Report: f.Report, + TS: rfc3339(f.CreatedAt), } if f.ExportCaseID > 0 { d.ID = "case:" + i64s(f.ExportCaseID) diff --git a/server/finding_traffic.go b/server/finding_traffic.go index 103159e..b36ba34 100644 --- a/server/finding_traffic.go +++ b/server/finding_traffic.go @@ -164,7 +164,8 @@ func (s *Server) bindFindingTraffic(w http.ResponseWriter, r *http.Request) { return } var body struct { - Refs []db.TrafficRef `json:"traffic_refs"` + Refs []db.TrafficRef `json:"traffic_refs"` + StepIndex *int `json:"step_index"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil { writeErr(w, 400, "invalid body") @@ -174,6 +175,15 @@ func (s *Server) bindFindingTraffic(w http.ResponseWriter, r *http.Request) { writeErr(w, 400, "请选择流量") return } + if body.StepIndex != nil { + if *body.StepIndex < 0 { + writeErr(w, 400, "step_index 不能为负数") + return + } + for i := range body.Refs { + body.Refs[i].StepIndex = *body.StepIndex + } + } out, err := s.evidenceStore().Bind(r.Context(), id, body.Refs) if err != nil { evidenceError(w, err) @@ -188,15 +198,20 @@ func (s *Server) editFindingTraffic(w http.ResponseWriter, r *http.Request) { return } var body struct { - Version *int64 `json:"version"` - Role *string `json:"role"` - Note *string `json:"note"` - Order []string `json:"binding_ids"` + Version *int64 `json:"version"` + Role *string `json:"role"` + Note *string `json:"note"` + Order []string `json:"binding_ids"` + StepIndex *int `json:"step_index"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil || body.Version == nil { writeErr(w, 400, "version 和有效请求体必填") return } + if body.StepIndex != nil && *body.StepIndex < 0 { + writeErr(w, 400, "step_index 不能为负数") + return + } var order []int64 bindingID := int64(0) if r.Method == http.MethodPut { @@ -221,7 +236,7 @@ func (s *Server) editFindingTraffic(w http.ResponseWriter, r *http.Request) { return } } - err := s.m.pg.EditFindingTraffic(r.Context(), id, bindingID, *body.Version, body.Role, body.Note, r.Method == http.MethodDelete, order) + err := s.m.pg.EditFindingTrafficStep(r.Context(), id, bindingID, *body.Version, body.Role, body.Note, body.StepIndex, r.Method == http.MethodDelete, order) if err != nil { evidenceError(w, err) return diff --git a/server/finding_workflow.go b/server/finding_workflow.go index 277ff0a..a5e1eb8 100644 --- a/server/finding_workflow.go +++ b/server/finding_workflow.go @@ -149,7 +149,7 @@ func (s *Server) agentFindingTrafficAccess(ctx context.Context, id int64, write func (s *Server) toolBindFindingTraffic() actool.CoreTool { return wrTool("bind_finding_traffic", "为已登记漏洞补绑经核实的真实 HTTP 流量。finding_id 使用独立漏洞记录 ID;不要传探索节点 ID。同批引用全部成功或全部失败,重复引用不覆盖已有说明。补绑会使已有报告标记待更新;不要为补包重新探测或重复创建漏洞。", - objSchema(map[string]any{"finding_id": strParam("独立漏洞记录 ID,从 list_task_findings / get_task_node_detail 的 finding_id 字段读取"), "traffic_refs": agent.HintTrafficSchema()}, "finding_id", "traffic_refs"), + objSchema(map[string]any{"finding_id": strParam("独立漏洞记录 ID,从 list_task_findings / get_task_node_detail 的 finding_id 字段读取"), "traffic_refs": agent.HintTrafficSchema(), "step_index": map[string]any{"type": "integer", "minimum": 0, "description": "可选:绑定到复现步骤序号;0 表示概览"}}, "finding_id", "traffic_refs"), func(ctx context.Context, raw json.RawMessage) (actool.Result, error) { if !s.m.pg.GetBool(settingAgentTrafficBinding, false) { return actool.Errorf("Agent 自动绑定流量已关闭;请在系统设置开启,或使用页面人工绑定。"), nil @@ -157,6 +157,7 @@ func (s *Server) toolBindFindingTraffic() actool.CoreTool { var args struct { FindingID json.RawMessage `json:"finding_id"` Refs []db.TrafficRef `json:"traffic_refs"` + StepIndex *int `json:"step_index"` } if err := json.Unmarshal(raw, &args); err != nil { return actool.Errorf(err.Error()), nil @@ -168,6 +169,14 @@ func (s *Server) toolBindFindingTraffic() actool.CoreTool { if len(args.Refs) == 0 { return actool.Errorf("补绑需要至少一条已核实的 traffic_refs;无流量无需调用此工具"), nil } + if args.StepIndex != nil { + if *args.StepIndex < 0 { + return actool.Errorf("step_index 不能为负数"), nil + } + for i := range args.Refs { + args.Refs[i].StepIndex = *args.StepIndex + } + } list, err := s.evidenceStore().Bind(ctx, id, args.Refs) if err != nil { return actool.Errorf(err.Error()), nil diff --git a/server/inheritance_dto_test.go b/server/inheritance_dto_test.go index 078d233..7a5eca1 100644 --- a/server/inheritance_dto_test.go +++ b/server/inheritance_dto_test.go @@ -1,6 +1,7 @@ package server import ( + "bytes" "encoding/json" "testing" "time" @@ -48,6 +49,17 @@ func TestInheritedDTOProvenance(t *testing.T) { } } +func TestFindingDTONilReproductionIsSafe(t *testing.T) { + dto := findingFromDB(&db.DBFinding{ID: 9, VulnClass: "legacy", Severity: db.SeverityLow, Status: db.FindingPending}, nil) + raw, err := json.Marshal(dto) + if err != nil { + t.Fatal(err) + } + if string(dto.Reproduction) != "" || !bytes.Contains(raw, []byte(`"reproduction":null`)) { + t.Fatalf("legacy reproduction should remain null: dto=%+v json=%s", dto, raw) + } +} + func TestInheritedIntentResultOnlyAllowsHistory(t *testing.T) { for _, state := range []string{"done", "blocked", "exhausted", "stopped"} { if !inheritedIntentResult(state) { diff --git a/web/src/app/(main)/function/findings/detail/page.tsx b/web/src/app/(main)/function/findings/detail/page.tsx index d4a640a..5bd2d4f 100644 --- a/web/src/app/(main)/function/findings/detail/page.tsx +++ b/web/src/app/(main)/function/findings/detail/page.tsx @@ -11,6 +11,7 @@ import { toast } from "sonner"; import { CopyButton } from "@/components/copy-button"; import { FindingRetestPanel } from "@/components/finding-retest-panel"; import { FindingTrafficPanel } from "@/components/finding-traffic-panel"; +import { FindingReproductionSection, hasStructuredReproduction } from "@/components/finding-reproduction-section"; import { Markdown } from "@/components/markdown"; import { StatusBadge } from "@/components/status-badge"; import { Alert, AlertDescription } from "@/components/ui/alert"; @@ -128,6 +129,7 @@ function FindingDetailInner() { } const title = finding.name || finding.vulnclass || "未分类"; + const structuredReproduction = hasStructuredReproduction(finding.reproduction); return ( @@ -183,25 +185,36 @@ function FindingDetailInner() { - - - 证据 / PoC - - - {finding.evidence ? ( -
-                      {finding.evidence}
-                    
- ) : ( -

(无证据)

- )} -
-
+ {structuredReproduction && finding.reproduction ? ( + + ) : null} +
+ + + 证据 / PoC + + + + + {finding.evidence ? ( +
{finding.evidence}
+ ) :

(无证据)

} +
+
+
{/* 证据下方:详细报告(Markdown 渲染) */} diff --git a/web/src/components/finding-reproduction-section.tsx b/web/src/components/finding-reproduction-section.tsx new file mode 100644 index 0000000..74708db --- /dev/null +++ b/web/src/components/finding-reproduction-section.tsx @@ -0,0 +1,116 @@ +"use client"; + +import * as React from "react"; + +import { ExternalLinkIcon, PlusIcon } from "lucide-react"; + +import { HttpCodeBlock } from "@/components/http-code-block"; +import { TrafficEvidenceViewer } from "@/components/traffic-evidence-viewer"; +import { TrafficPickerDialog } from "@/components/traffic-picker-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { api } from "@/lib/api"; +import type { FindingTraffic, Reproduction } from "@/lib/types"; + +export function hasStructuredReproduction(reproduction?: Reproduction | null) { + return Boolean(reproduction && ((reproduction.steps?.length ?? 0) > 0 || reproduction.entry_point)); +} + +export function FindingReproductionSection({ + findingId, + reproduction, + contextTask, + readOnly, + onChanged, +}: { + findingId: string; + reproduction: Reproduction; + contextTask?: string; + readOnly?: boolean; + onChanged?: () => void; +}) { + const [traffic, setTraffic] = React.useState(null); + const [pickerStep, setPickerStep] = React.useState(null); + const [preview, setPreview] = React.useState(null); + const [reload, setReload] = React.useState(0); + const load = React.useCallback(() => { + api.findingTraffic(findingId, contextTask).then(setTraffic).catch(() => setTraffic(null)); + }, [findingId, contextTask]); + React.useEffect(() => load(), [load, reload]); + + const steps = reproduction.steps ?? []; + const allBindings = traffic?.bindings ?? []; + return ( + + + 测试过程 / 复现 + + + {reproduction.entry_point ? ( +
+ 入口: + {reproduction.entry_point} +
+ ) : null} + {(reproduction.prerequisites?.length ?? 0) > 0 ? ( +
+
前置条件
+
    + {reproduction.prerequisites?.map((item, index) =>
  • {item}
  • )} +
+
+ ) : null} + {steps.map((step, index) => { + const stepIndex = index + 1; + const bound = allBindings.filter((item) => (item.step_index ?? 0) === stepIndex); + return ( +
+
+ 步骤 {stepIndex} + {step.expected ? 预期:{step.expected} : null} + {step.proves ? 证明:{step.proves} : null} +
+ {bound.map((item) => ( + + ))} + {!readOnly ? ( + + ) : null} +
+
+ {step.request?.trim() ? : null} + {step.response?.trim() ? ( +
+
响应
+ +
+ ) : null} + {bound.length > 0 ?
已绑定 {bound.length} 条流量
: null} +
+ ); + })} + {reproduction.provenance ?

来源:{reproduction.provenance}

: null} + {pickerStep !== null ? ( + setPickerStep(null)} + onBound={() => { + setPickerStep(null); + setReload((value) => value + 1); + onChanged?.(); + }} + /> + ) : null} + setPreview(null)} /> +
+
+ ); +} diff --git a/web/src/components/finding-traffic-panel.tsx b/web/src/components/finding-traffic-panel.tsx index c326f9c..3b62bad 100644 --- a/web/src/components/finding-traffic-panel.tsx +++ b/web/src/components/finding-traffic-panel.tsx @@ -28,6 +28,7 @@ import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { Textarea } from "@/components/ui/textarea"; +import { Input } from "@/components/ui/input"; import { api } from "@/lib/api"; import type { FindingTraffic, FindingTrafficBinding, TrafficEvidenceRole } from "@/lib/types"; @@ -43,11 +44,15 @@ export function FindingTrafficPanel({ contextTask, readOnly, onChanged, + stepIndex: filterStepIndex, + title, }: { findingId: string; contextTask?: string; readOnly?: boolean; onChanged: () => void; + stepIndex?: number; + title?: string; }) { const [data, setData] = React.useState(null); const [error, setError] = React.useState(""); @@ -58,6 +63,7 @@ export function FindingTrafficPanel({ const [editing, setEditing] = React.useState(null); const [role, setRole] = React.useState("supporting"); const [note, setNote] = React.useState(""); + const [selectedStepIndex, setSelectedStepIndex] = React.useState("0"); const [busy, setBusy] = React.useState(false); const [reload, setReload] = React.useState(0); // biome-ignore lint/correctness/useExhaustiveDependencies: retry token intentionally refreshes this request. @@ -68,7 +74,13 @@ export function FindingTrafficPanel({ api .findingTraffic(findingId, contextTask) .then((value) => { - if (active) setData(value); + if (active) { + setData( + filterStepIndex === undefined + ? value + : { ...value, bindings: value.bindings.filter((b) => (b.step_index ?? 0) === filterStepIndex) }, + ); + } }) .catch((e: Error) => { if (active) setError(e.message); @@ -76,7 +88,7 @@ export function FindingTrafficPanel({ return () => { active = false; }; - }, [findingId, contextTask, reload]); + }, [findingId, contextTask, reload, filterStepIndex]); async function mutate(action: () => Promise) { setBusy(true); @@ -131,7 +143,7 @@ export function FindingTrafficPanel({
- 关联流量 {data ? `(${data.bindings.length})` : ""} + {title ?? "关联流量"} {data ? `(${data.bindings.length})` : ""}