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.
This commit is contained in:
MuCheng committed 2026-10-27 17:17:34 +08:00
1 parent a94b67ffd6
commit 93dc83165c
19 files changed
+493 -71

No files matched your search

+29
View File
@@ -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 }
+1 -1
View File
@@ -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 的自检已通过——没独立复现过的战果不算达成依据。
+39 -2
View File
@@ -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)
+2 -2
View File
@@ -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...)
}
+71
View File
@@ -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)
}
}
+33 -8
View File
@@ -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
}
// 在**同一事务**里登记一条推送事件:提交即保证「漏洞落库」与「推送任务存在」
+10 -1
View File
@@ -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)`)
+34 -8
View File
@@ -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
}
+4
View File
@@ -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);
-- =====================================================================
+25 -21
View File
@@ -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)
+21 -6
View File
@@ -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
+10 -1
View File
@@ -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
+12
View File
@@ -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) {
@@ -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 (
<Tabs value={tab} onValueChange={setTab} className="flex flex-1 flex-col gap-0">
@@ -183,25 +185,36 @@ function FindingDetailInner() {
</CardContent>
</Card>
<FindingRetestPanel key={`retest:${id}`} findingId={id} readOnly={finding.inherited} onCompleted={load} />
<Card>
<CardHeader>
<CardTitle className="text-sm">证据 / PoC</CardTitle>
</CardHeader>
<CardContent>
{finding.evidence ? (
<pre className="max-h-[46vh] overflow-auto rounded-md bg-muted px-3 py-2 font-mono text-xs whitespace-pre-wrap">
{finding.evidence}
</pre>
) : (
<p className="text-sm text-muted-foreground">(无证据)</p>
)}
</CardContent>
</Card>
{structuredReproduction && finding.reproduction ? (
<FindingReproductionSection
findingId={id}
reproduction={finding.reproduction}
contextTask={contextTaskId || undefined}
readOnly={finding.inherited}
onChanged={load}
/>
) : null}
<details open={!structuredReproduction} className="group">
<summary className="cursor-pointer list-none">
<Card>
<CardHeader><CardTitle className="text-sm">证据 / PoC</CardTitle></CardHeader>
</Card>
</summary>
<Card className="mt-2">
<CardContent className="pt-4">
{finding.evidence ? (
<pre className="max-h-[46vh] overflow-auto rounded-md bg-muted px-3 py-2 font-mono text-xs whitespace-pre-wrap">{finding.evidence}</pre>
) : <p className="text-sm text-muted-foreground">(无证据)</p>}
</CardContent>
</Card>
</details>
<FindingTrafficPanel
key={`traffic:${id}`}
findingId={id}
contextTask={contextTaskId || undefined}
readOnly={finding.inherited}
stepIndex={structuredReproduction ? 0 : undefined}
title={structuredReproduction ? "未归入步骤的流量" : undefined}
onChanged={load}
/>
{/* 证据下方:详细报告(Markdown 渲染) */}
@@ -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<FindingTraffic | null>(null);
const [pickerStep, setPickerStep] = React.useState<number | null>(null);
const [preview, setPreview] = React.useState<string | null>(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 (
<Card>
<CardHeader>
<CardTitle className="text-sm">测试过程 / 复现</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{reproduction.entry_point ? (
<div className="rounded-md border bg-muted/30 px-3 py-2 text-sm">
<span className="text-muted-foreground">入口:</span>
<code className="break-all font-mono">{reproduction.entry_point}</code>
</div>
) : null}
{(reproduction.prerequisites?.length ?? 0) > 0 ? (
<div className="rounded-md border bg-muted/30 px-3 py-2 text-sm">
<div className="text-muted-foreground">前置条件</div>
<ul className="ml-5 list-disc">
{reproduction.prerequisites?.map((item, index) => <li key={`${index}-${item}`}>{item}</li>)}
</ul>
</div>
) : null}
{steps.map((step, index) => {
const stepIndex = index + 1;
const bound = allBindings.filter((item) => (item.step_index ?? 0) === stepIndex);
return (
<div key={stepIndex} className="space-y-2 rounded-md border p-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">步骤 {stepIndex}</Badge>
{step.expected ? <span className="text-sm">预期:{step.expected}</span> : null}
{step.proves ? <span className="text-sm text-muted-foreground">证明:{step.proves}</span> : null}
<div className="ml-auto flex gap-1">
{bound.map((item) => (
<Button key={item.id} variant="ghost" size="sm" className="h-7 text-xs" onClick={() => setPreview(item.id)}>
<ExternalLinkIcon className="size-3" /> 查看流量
</Button>
))}
{!readOnly ? (
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setPickerStep(stepIndex)}>
<PlusIcon className="size-3" /> 绑定流量
</Button>
) : null}
</div>
</div>
{step.request?.trim() ? <HttpCodeBlock raw={step.request} /> : null}
{step.response?.trim() ? (
<div>
<div className="mb-1 text-xs text-muted-foreground">响应</div>
<HttpCodeBlock raw={step.response} />
</div>
) : null}
{bound.length > 0 ? <div className="text-xs text-muted-foreground">已绑定 {bound.length} 条流量</div> : null}
</div>
);
})}
{reproduction.provenance ? <p className="text-xs text-muted-foreground">来源:{reproduction.provenance}</p> : null}
{pickerStep !== null ? (
<TrafficPickerDialog
findingId={findingId}
contextTask={contextTask}
bound={allBindings}
stepIndex={pickerStep}
onClose={() => setPickerStep(null)}
onBound={() => {
setPickerStep(null);
setReload((value) => value + 1);
onChanged?.();
}}
/>
) : null}
<TrafficEvidenceViewer findingId={findingId} bindingId={preview} contextTask={contextTask} onClose={() => setPreview(null)} />
</CardContent>
</Card>
);
}
+35 -4
View File
@@ -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<FindingTraffic | null>(null);
const [error, setError] = React.useState("");
@@ -58,6 +63,7 @@ export function FindingTrafficPanel({
const [editing, setEditing] = React.useState<FindingTrafficBinding | null>(null);
const [role, setRole] = React.useState<TrafficEvidenceRole>("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<FindingTraffic>) {
setBusy(true);
@@ -131,7 +143,7 @@ export function FindingTrafficPanel({
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle>关联流量 {data ? `(${data.bindings.length})` : ""}</CardTitle>
<CardTitle>{title ?? "关联流量"} {data ? `(${data.bindings.length})` : ""}</CardTitle>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
@@ -216,6 +228,7 @@ export function FindingTrafficPanel({
setEditing(b);
setRole(b.role);
setNote(b.note);
setSelectedStepIndex(String(b.step_index ?? 0));
}}
>
编辑说明
@@ -262,6 +275,7 @@ export function FindingTrafficPanel({
findingId={findingId}
contextTask={contextTask}
bound={data.bindings}
stepIndex={Math.max(0, Number.parseInt(selectedStepIndex, 10) || 0)}
onClose={() => setAdding(false)}
onBound={() => {
setReload((n) => n + 1);
@@ -308,6 +322,17 @@ export function FindingTrafficPanel({
<FieldLabel htmlFor="evidence-note">证据说明</FieldLabel>
<Textarea id="evidence-note" value={note} onChange={(e) => setNote(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="evidence-step">复现步骤序号</FieldLabel>
<Input
id="evidence-step"
type="number"
min={0}
step={1}
value={selectedStepIndex}
onChange={(e) => setSelectedStepIndex(e.target.value)}
/>
</Field>
</FieldGroup>
<DialogFooter>
<Button variant="outline" disabled={busy} onClick={() => setEditing(null)}>
@@ -318,7 +343,13 @@ export function FindingTrafficPanel({
onClick={() => {
if (editing && data)
void mutate(() =>
api.editFindingTraffic(findingId, editing.id, data.version, { role, note }, contextTask),
api.editFindingTraffic(
findingId,
editing.id,
data.version,
{ role, note, step_index: Math.max(0, Number.parseInt(selectedStepIndex, 10) || 0) },
contextTask,
),
);
}}
>
@@ -30,12 +30,14 @@ export function TrafficPickerDialog({
findingId,
contextTask,
bound,
stepIndex,
onClose,
onBound,
}: {
findingId: string;
contextTask?: string;
bound: FindingTrafficBinding[];
stepIndex?: number;
onClose: () => void;
onBound: () => void;
}) {
@@ -91,6 +93,7 @@ export function TrafficPickerDialog({
findingId,
[...selected].map((traffic_id) => ({ traffic_id })),
contextTask,
stepIndex,
);
toast.success(`已绑定 ${selected.size} 条流量`);
onBound();
+3 -3
View File
@@ -714,16 +714,16 @@ export const api = {
get<FindingTraffic>(
`/exploration/findings/${id}/traffic${contextTask ? `?context_task=${encodeURIComponent(contextTask)}` : ""}`,
),
bindFindingTraffic: (id: string, traffic_refs: TrafficEvidenceRef[], contextTask?: string) =>
bindFindingTraffic: (id: string, traffic_refs: TrafficEvidenceRef[], contextTask?: string, step_index?: number) =>
post<FindingTraffic>(
`/exploration/findings/${id}/traffic${contextTask ? `?context_task=${encodeURIComponent(contextTask)}` : ""}`,
{ traffic_refs },
{ traffic_refs, ...(step_index !== undefined ? { step_index } : {}) },
),
editFindingTraffic: (
id: string,
bindingId: string,
version: number,
fields: { role: TrafficEvidenceRole; note: string },
fields: { role: TrafficEvidenceRole; note: string; step_index?: number },
contextTask?: string,
) =>
patch<FindingTraffic>(
+18
View File
@@ -512,6 +512,21 @@ export interface FindingAsset {
label: string;
}
export interface ReproStep {
request: string;
response?: string;
expected?: string;
proves?: string;
traffic_ids?: string[];
}
export interface Reproduction {
entry_point?: string;
prerequisites?: string[];
steps?: ReproStep[];
provenance?: string;
}
export interface Finding {
case_id?: string;
traffic_count?: number;
@@ -527,6 +542,7 @@ export interface Finding {
summary: string;
evidence: string;
report?: string; // 详细报告(Markdown);仅详情接口返回,列表为空
reproduction?: Reproduction | null;
intent_id?: string;
param_id?: string;
task_id?: string;
@@ -1604,6 +1620,7 @@ export interface TrafficEvidenceRef {
traffic_id: string;
role?: TrafficEvidenceRole;
note?: string;
step_index?: number;
}
export interface TrafficEvidenceSnapshot {
id: string;
@@ -1627,6 +1644,7 @@ export interface FindingTrafficBinding {
role: TrafficEvidenceRole;
note: string;
position: number;
step_index?: number;
created_at: string;
snapshot: TrafficEvidenceSnapshot;
}