mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 01:03:50 +08:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22a3cf8eb4 | ||
|
|
8d3412582a | ||
|
|
bd4eef3bde | ||
|
|
05b72683aa | ||
|
|
69eab0c033 | ||
|
|
8f9d981bdc | ||
|
|
4273ef5d97 | ||
|
|
8ad507e31f | ||
|
|
fb0146f4b6 | ||
|
|
867e3fd1c5 | ||
|
|
93dc83165c |
No files matched your search
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -18,6 +19,15 @@ func (failingFindingRecorder) Record(context.Context, db.RecordFindingInput, []d
|
||||
return nil, errors.New("fixture persistence failed")
|
||||
}
|
||||
|
||||
type capturingFindingRecorder struct {
|
||||
refs []db.TrafficRef
|
||||
}
|
||||
|
||||
func (r *capturingFindingRecorder) Record(_ context.Context, _ db.RecordFindingInput, refs []db.TrafficRef) (*db.RecordedFinding, error) {
|
||||
r.refs = append([]db.TrafficRef(nil), refs...)
|
||||
return &db.RecordedFinding{Traffic: &db.FindingTraffic{}}, nil
|
||||
}
|
||||
|
||||
func TestReportFindingOptionalTrafficWithoutCapture(t *testing.T) {
|
||||
d := testDB(t)
|
||||
defer d.Close()
|
||||
@@ -50,6 +60,84 @@ 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)
|
||||
}
|
||||
var wantReproduction, gotReproduction map[string]any
|
||||
if err := json.Unmarshal([]byte(reproduction), &wantReproduction); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if finding == nil {
|
||||
t.Fatalf("reproduction was not persisted: %#v", finding)
|
||||
}
|
||||
if err := json.Unmarshal(finding.Reproduction, &gotReproduction); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(gotReproduction, wantReproduction) {
|
||||
t.Fatalf("reproduction was not persisted: %#v", finding)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportFindingStepTrafficPreservesExplicitReferenceMetadata(t *testing.T) {
|
||||
old := FindingTrafficBindingEnabled
|
||||
FindingTrafficBindingEnabled = func() bool { return true }
|
||||
t.Cleanup(func() { FindingTrafficBindingEnabled = old })
|
||||
d := testDB(t)
|
||||
defer d.Close()
|
||||
task, err := d.CreateTask("step traffic merge", "fixture", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.DeleteTask(task.ID)
|
||||
recorder := &capturingFindingRecorder{}
|
||||
ts := NewToolSet(d.Exploration(task.ExplorationID), "fixture")
|
||||
ts.taskID = task.ID
|
||||
ts.SetFindingRecorder(recorder)
|
||||
reproduction := `{"steps":[{"traffic_ids":["shared"]},{"traffic_ids":["new-step"]}]}`
|
||||
input := `{"vulnclass":"TEST","severity":"low","summary":"step merge","traffic_refs":[{"traffic_id":"shared","role":"proof","note":"preserve this note"},{"traffic_id":"overview","role":"baseline","note":"overview note"}],"reproduction":` + reproduction + `}`
|
||||
result, err := ts.addFinding().Call(t.Context(), json.RawMessage(input), nil)
|
||||
if err != nil || result.IsError {
|
||||
t.Fatalf("report failed: %v %s", err, result.Flatten())
|
||||
}
|
||||
if len(recorder.refs) != 3 {
|
||||
t.Fatalf("unexpected refs: %#v", recorder.refs)
|
||||
}
|
||||
byID := make(map[string]db.TrafficRef, len(recorder.refs))
|
||||
for _, ref := range recorder.refs {
|
||||
byID[ref.TrafficID] = ref
|
||||
}
|
||||
if got := byID["shared"]; got.Role != "proof" || got.Note != "preserve this note" || got.StepIndex != 1 {
|
||||
t.Fatalf("overlapping ref metadata changed: %#v", got)
|
||||
}
|
||||
if got := byID["overview"]; got.Role != "baseline" || got.Note != "overview note" || got.StepIndex != 0 {
|
||||
t.Fatalf("overview ref changed: %#v", got)
|
||||
}
|
||||
if got := byID["new-step"]; got.Role != "supporting" || got.Note != "" || got.StepIndex != 2 {
|
||||
t.Fatalf("new step ref not defaulted: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportFindingAtomicContract(t *testing.T) {
|
||||
old := FindingTrafficBindingEnabled
|
||||
FindingTrafficBindingEnabled = func() bool { return true }
|
||||
|
||||
@@ -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 的自检已通过——没独立复现过的战果不算达成依据。
|
||||
|
||||
+51
-2
@@ -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,44 @@ 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. Keep metadata supplied by
|
||||
// traffic_refs when an id is mentioned by a reproduction step; only ids
|
||||
// that are absent from traffic_refs get the supporting default.
|
||||
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
|
||||
}
|
||||
refIndex := make(map[string]int, len(refs))
|
||||
for i := range refs {
|
||||
id := strings.TrimSpace(refs[i].TrafficID)
|
||||
if id != "" {
|
||||
refs[i].TrafficID = id
|
||||
if _, exists := refIndex[id]; !exists {
|
||||
refIndex[id] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, step := range recipe.Steps {
|
||||
for _, id := range step.TrafficIDs {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if ref, exists := refIndex[id]; exists {
|
||||
refs[ref].StepIndex = i + 1
|
||||
continue
|
||||
}
|
||||
refIndex[id] = len(refs)
|
||||
refs = append(refs, db.TrafficRef{TrafficID: id, Role: "supporting", StepIndex: i + 1})
|
||||
}
|
||||
}
|
||||
}
|
||||
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
@@ -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...)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"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 {
|
||||
t.Fatalf("reproduction round trip: %#v, %v", f, err)
|
||||
}
|
||||
var wantRepro, gotRepro map[string]any
|
||||
if err := json.Unmarshal(repro, &wantRepro); err != nil {
|
||||
t.Fatalf("unmarshal input reproduction: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(f.Reproduction, &gotRepro); err != nil {
|
||||
t.Fatalf("unmarshal round-trip reproduction: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(gotRepro, wantRepro) {
|
||||
t.Fatalf("reproduction round trip: got %#v, want %#v", gotRepro, wantRepro)
|
||||
}
|
||||
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
@@ -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
|
||||
}
|
||||
// 在**同一事务**里登记一条推送事件:提交即保证「漏洞落库」与「推送任务存在」
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
)
|
||||
|
||||
func (s *Server) seedFindingWorkflowTools() {
|
||||
s.migrateFindingWorkflowSchemas()
|
||||
const hostSearchDescriptionFlag = "finding_workflow_tools_v3_host_search_description"
|
||||
if value, _, _ := s.m.pg.GetSetting(hostSearchDescriptionFlag); value != "true" {
|
||||
// Only replace the original built-in text. A user-edited description is
|
||||
@@ -106,6 +107,51 @@ func (s *Server) seedFindingWorkflowTools() {
|
||||
_ = s.m.pg.SetSetting(flag, "true")
|
||||
}
|
||||
|
||||
// migrateFindingWorkflowSchemas adds schema fields introduced after the
|
||||
// original finding workflow seed. It patches only missing properties so a
|
||||
// user's description, agent bindings, enabled state, and other schema edits
|
||||
// remain untouched.
|
||||
func (s *Server) migrateFindingWorkflowSchemas() {
|
||||
const flag = "finding_workflow_tools_v3_reproduction_step_index"
|
||||
if value, _, _ := s.m.pg.GetSetting(flag); value == "true" {
|
||||
return
|
||||
}
|
||||
properties := map[string]json.RawMessage{}
|
||||
for _, seed := range agent.BuiltinToolSeeds() {
|
||||
if seed.Key != "report_finding" {
|
||||
continue
|
||||
}
|
||||
if raw, err := json.Marshal(objectProperty(seed.Schema, "properties")["reproduction"]); err == nil && string(raw) != "null" {
|
||||
properties["report_finding"] = raw
|
||||
}
|
||||
break
|
||||
}
|
||||
for _, tool := range s.orchestrationTools() {
|
||||
if tool.Name() != "bind_finding_traffic" {
|
||||
continue
|
||||
}
|
||||
schema := tool.InputSchema()
|
||||
if raw, err := json.Marshal(objectProperty(schema, "properties")["step_index"]); err == nil && string(raw) != "null" {
|
||||
properties["bind_finding_traffic"] = raw
|
||||
}
|
||||
break
|
||||
}
|
||||
for key, property := range map[string]string{"report_finding": "reproduction", "bind_finding_traffic": "step_index"} {
|
||||
raw := properties[key]
|
||||
if len(raw) == 0 {
|
||||
log.Printf("[evidence] missing code schema property %s.%s", key, property)
|
||||
continue
|
||||
}
|
||||
if _, err := s.m.pg.Exec(`UPDATE tools
|
||||
SET schema=jsonb_set(COALESCE(schema,'{}'::jsonb),ARRAY['properties',$2::text],$3::jsonb,true),updated_at=now()
|
||||
WHERE key=$1 AND system AND NOT(COALESCE(schema->'properties','{}'::jsonb) ? $2)`, key, property, string(raw)); err != nil {
|
||||
log.Printf("[evidence] upgrade %s.%s: %v", key, property, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = s.m.pg.SetSetting(flag, "true")
|
||||
}
|
||||
|
||||
func objectProperty(parent map[string]any, key string) map[string]any {
|
||||
value, ok := parent[key].(map[string]any)
|
||||
if !ok {
|
||||
@@ -149,7 +195,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 +203,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 +215,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
|
||||
|
||||
@@ -227,6 +227,9 @@ func TestFindingWorkflowMigrationPreservesUserConfiguration(t *testing.T) {
|
||||
if err := pg.SetSetting("finding_workflow_tools_v2_reporter", "false"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pg.SetSetting("finding_workflow_tools_v3_reproduction_step_index", "false"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.seedFindingWorkflowTools()
|
||||
for _, key := range []string{"report_finding", "add_hint", "add_task_hint"} {
|
||||
row, _ := pg.GetTool(key)
|
||||
@@ -243,6 +246,13 @@ func TestFindingWorkflowMigrationPreservesUserConfiguration(t *testing.T) {
|
||||
if !strings.Contains(string(row.Schema), field) {
|
||||
t.Fatal("missing optional field", key)
|
||||
}
|
||||
if key == "report_finding" && !strings.Contains(string(row.Schema), "reproduction") {
|
||||
t.Fatal("missing reproduction schema migration")
|
||||
}
|
||||
}
|
||||
bindSchema, _ := pg.GetTool("bind_finding_traffic")
|
||||
if !strings.Contains(string(bindSchema.Schema), "step_index") {
|
||||
t.Fatal("missing bind_finding_traffic step_index migration")
|
||||
}
|
||||
search, _ := pg.GetTool("traffic_search")
|
||||
get, _ := pg.GetTool("traffic_get")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -9,8 +9,10 @@ import { ArrowLeftIcon, ArrowUpRightIcon, ShieldAlertIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { FindingAgentChatDrawer } from "@/components/finding-agent-chat-drawer";
|
||||
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";
|
||||
@@ -62,6 +64,7 @@ function FindingDetailInner() {
|
||||
const [finding, setFinding] = React.useState<Finding | null>(null);
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const [tab, setTab] = React.useState("overview");
|
||||
const [trafficReload, setTrafficReload] = React.useState(0);
|
||||
|
||||
const load = React.useCallback(() => {
|
||||
if (!id) {
|
||||
@@ -78,6 +81,11 @@ function FindingDetailInner() {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const handleTrafficChanged = React.useCallback(() => {
|
||||
setTrafficReload((value) => value + 1);
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const changeSeverity = React.useCallback(
|
||||
async (next: Severity) => {
|
||||
if (!finding || finding.inherited || next === finding.severity) return;
|
||||
@@ -128,6 +136,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">
|
||||
@@ -160,6 +169,7 @@ function FindingDetailInner() {
|
||||
{finding.inherited && finding.source_task_id && (
|
||||
<Badge variant="outline">来源任务 #{finding.source_task_id} · 只读</Badge>
|
||||
)}
|
||||
<FindingAgentChatDrawer finding={finding} />
|
||||
</div>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">概览</TabsTrigger>
|
||||
@@ -183,26 +193,39 @@ 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={handleTrafficChanged}
|
||||
trafficReload={trafficReload}
|
||||
/>
|
||||
) : 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}
|
||||
onChanged={load}
|
||||
stepIndex={structuredReproduction ? 0 : undefined}
|
||||
title={structuredReproduction ? "未归入步骤的流量" : undefined}
|
||||
onChanged={handleTrafficChanged}
|
||||
trafficReload={trafficReload}
|
||||
/>
|
||||
{/* 证据下方:详细报告(Markdown 渲染) */}
|
||||
<Card>
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { ArrowUpIcon, Loader2Icon, MessageCircleQuestionIcon, SquareIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Transcript } from "@/components/transcript";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api, openSSE } from "@/lib/api";
|
||||
import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode";
|
||||
import { MOCK } from "@/lib/mock/enabled";
|
||||
import type { Activity, Finding } from "@/lib/types";
|
||||
|
||||
import {
|
||||
buildFindingAgentContextMessage,
|
||||
findingAgentUnavailableReason,
|
||||
findingDisplayTitle,
|
||||
} from "./finding-agent-chat";
|
||||
|
||||
const MAIN_HISTORY_LIMIT = 200;
|
||||
|
||||
type FindingAgentChatDrawerProps = {
|
||||
finding: Finding;
|
||||
};
|
||||
|
||||
function mainActivity(activity: Activity, segment: number): boolean {
|
||||
if (activity.worker !== "mainagent") return false;
|
||||
return activity.main_seg == null || activity.main_seg === segment;
|
||||
}
|
||||
|
||||
function mergeActivities(current: Activity[], incoming: Activity[], segment: number): Activity[] {
|
||||
const bySeq = new Map<number, Activity>();
|
||||
for (const item of current) bySeq.set(item.seq, item);
|
||||
for (const item of incoming) {
|
||||
if (mainActivity(item, segment)) bySeq.set(item.seq, item);
|
||||
}
|
||||
return [...bySeq.values()].sort((a, b) => a.seq - b.seq);
|
||||
}
|
||||
|
||||
function LoadingActivity() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
正在加载主 Agent 对话…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyActivity({ available }: { available: boolean }) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{available ? "还没有对话。发送问题后,主 Agent 的回复会显示在这里。" : "暂无可显示的活动。"}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function FindingAgentChatDrawer({ finding }: FindingAgentChatDrawerProps) {
|
||||
const taskId = finding.task_id ?? "";
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [activities, setActivities] = React.useState<Activity[]>([]);
|
||||
const [input, setInput] = React.useState("");
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [sending, setSending] = React.useState(false);
|
||||
const [running, setRunning] = React.useState(false);
|
||||
const [stopping, setStopping] = React.useState(false);
|
||||
const sendMode = useChatSendMode();
|
||||
const [taskAvailable, setTaskAvailable] = React.useState<boolean | null>(null);
|
||||
const [unavailableReason, setUnavailableReason] = React.useState("");
|
||||
const eventSourceRef = React.useRef<EventSource | null>(null);
|
||||
const snapshotRef = React.useRef(0);
|
||||
const aliveRef = React.useRef(false);
|
||||
const requestGenerationRef = React.useRef(0);
|
||||
const sentContextRef = React.useRef(false);
|
||||
const currentSegmentRef = React.useRef(0);
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const atBottomRef = React.useRef(true);
|
||||
const findingIdRef = React.useRef(finding.id);
|
||||
findingIdRef.current = finding.id;
|
||||
|
||||
const findingId = finding.id;
|
||||
const inherited = Boolean(finding.inherited);
|
||||
const canAsk = Boolean(taskId && !inherited);
|
||||
|
||||
const closeStream = React.useCallback(() => {
|
||||
eventSourceRef.current?.close();
|
||||
eventSourceRef.current = null;
|
||||
}, []);
|
||||
|
||||
const refreshStatus = React.useCallback(
|
||||
(generation = requestGenerationRef.current) => {
|
||||
if (!taskId || !canAsk) return Promise.resolve();
|
||||
return api
|
||||
.chatStatus(taskId)
|
||||
.then(({ running: nextRunning }) => {
|
||||
if (aliveRef.current && requestGenerationRef.current === generation) setRunning(nextRunning);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
},
|
||||
[canAsk, taskId],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const generation = ++requestGenerationRef.current;
|
||||
const isCurrent = () =>
|
||||
aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId;
|
||||
|
||||
closeStream();
|
||||
aliveRef.current = open;
|
||||
if (!open) return;
|
||||
|
||||
sentContextRef.current = false;
|
||||
setInput("");
|
||||
setActivities([]);
|
||||
setTaskAvailable(null);
|
||||
setUnavailableReason(findingAgentUnavailableReason({ task_id: taskId, inherited }));
|
||||
setRunning(false);
|
||||
setLoading(false);
|
||||
setSending(false);
|
||||
setStopping(false);
|
||||
snapshotRef.current = 0;
|
||||
currentSegmentRef.current = 0;
|
||||
atBottomRef.current = true;
|
||||
|
||||
if (!canAsk) {
|
||||
return () => {
|
||||
aliveRef.current = false;
|
||||
requestGenerationRef.current += 1;
|
||||
closeStream();
|
||||
};
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
if (MOCK) {
|
||||
api
|
||||
.activity(taskId)
|
||||
.then(({ items, cursor }) => {
|
||||
if (!isCurrent()) return;
|
||||
setTaskAvailable(true);
|
||||
setActivities(mergeActivities([], items, currentSegmentRef.current));
|
||||
snapshotRef.current = cursor;
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isCurrent()) return;
|
||||
setTaskAvailable(false);
|
||||
setUnavailableReason("任务不存在或已删除,无法连接到主 Agent。");
|
||||
})
|
||||
.finally(() => {
|
||||
if (isCurrent()) setLoading(false);
|
||||
});
|
||||
} else {
|
||||
api
|
||||
.task(taskId)
|
||||
.then(() => api.mainSessions(taskId))
|
||||
.then((sessions) => {
|
||||
if (!isCurrent()) return null;
|
||||
const currentSegment = sessions.current ?? 0;
|
||||
currentSegmentRef.current = currentSegment;
|
||||
return api.activityHistory(taskId, `main:${currentSegment}`, 0, MAIN_HISTORY_LIMIT).then((history) => ({
|
||||
currentSegment,
|
||||
history,
|
||||
}));
|
||||
})
|
||||
.then((result) => {
|
||||
if (!result || !isCurrent()) return;
|
||||
const { currentSegment, history } = result;
|
||||
setTaskAvailable(true);
|
||||
setActivities((current) => mergeActivities(current, history.items, currentSegment));
|
||||
snapshotRef.current = history.snapshotCursor;
|
||||
|
||||
closeStream();
|
||||
const stream = openSSE(
|
||||
`/api/exploration/activity/stream?task=${encodeURIComponent(taskId)}&since=${snapshotRef.current}`,
|
||||
);
|
||||
if (!isCurrent()) {
|
||||
stream.close();
|
||||
return;
|
||||
}
|
||||
eventSourceRef.current = stream;
|
||||
stream.onmessage = (event) => {
|
||||
if (!isCurrent()) return;
|
||||
try {
|
||||
const activity = JSON.parse(event.data) as Activity;
|
||||
setActivities((current) => mergeActivities(current, [activity], currentSegment));
|
||||
} catch {
|
||||
// Ignore malformed frames; the next history refresh still repairs the view.
|
||||
}
|
||||
};
|
||||
stream.onerror = () => undefined; // EventSource reconnects automatically.
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isCurrent()) return;
|
||||
setTaskAvailable(false);
|
||||
setUnavailableReason("任务不存在或已删除,无法连接到主 Agent。");
|
||||
})
|
||||
.finally(() => {
|
||||
if (isCurrent()) setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
void refreshStatus(generation);
|
||||
const statusTimer = window.setInterval(() => void refreshStatus(generation), 2000);
|
||||
return () => {
|
||||
aliveRef.current = false;
|
||||
requestGenerationRef.current += 1;
|
||||
window.clearInterval(statusTimer);
|
||||
closeStream();
|
||||
};
|
||||
}, [canAsk, closeStream, findingId, inherited, open, refreshStatus, taskId]);
|
||||
|
||||
const viewport = React.useCallback(
|
||||
() => (contentRef.current?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null) ?? null,
|
||||
[],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const vp = viewport();
|
||||
if (!vp) return;
|
||||
const onScroll = () => {
|
||||
atBottomRef.current = vp.scrollTop + vp.clientHeight >= vp.scrollHeight - 60;
|
||||
};
|
||||
vp.addEventListener("scroll", onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => vp.removeEventListener("scroll", onScroll);
|
||||
}, [open, viewport]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const vp = viewport();
|
||||
if (vp) {
|
||||
vp.scrollTop = vp.scrollHeight;
|
||||
atBottomRef.current = true;
|
||||
}
|
||||
}, [open, viewport]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: activity growth intentionally drives live-edge scrolling.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open || !atBottomRef.current) return;
|
||||
const vp = viewport();
|
||||
if (vp) vp.scrollTop = vp.scrollHeight;
|
||||
}, [activities, open, running, viewport]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || typeof ResizeObserver === "undefined") return;
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (!atBottomRef.current) return;
|
||||
const vp = viewport();
|
||||
if (vp) vp.scrollTop = vp.scrollHeight;
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [open, viewport]);
|
||||
|
||||
const send = React.useCallback(async () => {
|
||||
const message = input.trim();
|
||||
if (!message || sending || running || !taskId || taskAvailable !== true || !canAsk) return;
|
||||
const generation = requestGenerationRef.current;
|
||||
setSending(true);
|
||||
const payload = sentContextRef.current
|
||||
? message
|
||||
: `${buildFindingAgentContextMessage(finding)}\n\n用户问题:${message}`;
|
||||
try {
|
||||
const result = await api.chat(payload, taskId);
|
||||
if (!aliveRef.current || requestGenerationRef.current !== generation || findingIdRef.current !== findingId)
|
||||
return;
|
||||
if (MOCK) {
|
||||
const ts = new Date().toISOString();
|
||||
const reply = result.reply?.trim() || "(demo)主 Agent 暂无回复。";
|
||||
setActivities((current) =>
|
||||
mergeActivities(
|
||||
current,
|
||||
[
|
||||
{
|
||||
seq: current.reduce((max, item) => Math.max(max, item.seq), 0) + 1,
|
||||
worker: "mainagent",
|
||||
ts,
|
||||
kind: "user",
|
||||
summary: message,
|
||||
main_seg: currentSegmentRef.current,
|
||||
},
|
||||
{
|
||||
seq: current.reduce((max, item) => Math.max(max, item.seq), 0) + 2,
|
||||
worker: "mainagent",
|
||||
ts,
|
||||
kind: "text",
|
||||
summary: reply,
|
||||
main_seg: currentSegmentRef.current,
|
||||
},
|
||||
],
|
||||
currentSegmentRef.current,
|
||||
),
|
||||
);
|
||||
}
|
||||
sentContextRef.current = true;
|
||||
setInput("");
|
||||
if (!MOCK) void refreshStatus(generation);
|
||||
} catch (error) {
|
||||
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
|
||||
toast.error(`发送失败:${(error as Error).message || "请稍后重试"}`);
|
||||
}
|
||||
} finally {
|
||||
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
}, [canAsk, finding, findingId, input, refreshStatus, running, sending, taskAvailable, taskId]);
|
||||
|
||||
const stop = React.useCallback(async () => {
|
||||
if (!taskId || stopping) return;
|
||||
const generation = requestGenerationRef.current;
|
||||
setStopping(true);
|
||||
try {
|
||||
await api.stopChat(taskId);
|
||||
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
|
||||
await refreshStatus(generation);
|
||||
}
|
||||
} catch (error) {
|
||||
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
|
||||
toast.error(`停止失败:${(error as Error).message || "请稍后重试"}`);
|
||||
}
|
||||
} finally {
|
||||
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
|
||||
setStopping(false);
|
||||
}
|
||||
}
|
||||
}, [findingId, refreshStatus, stopping, taskId]);
|
||||
|
||||
const title = findingDisplayTitle(finding);
|
||||
const canSend = canAsk && taskAvailable === true && !running && !sending && Boolean(input.trim());
|
||||
let footerStatus = "Enter 发送,Shift+Enter 换行。";
|
||||
if (sending) footerStatus = "正在发送…";
|
||||
else if (running) footerStatus = "主 Agent 运行中,可停止当前执行。";
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)} aria-label="问 Agent">
|
||||
<MessageCircleQuestionIcon />问 Agent
|
||||
</Button>
|
||||
<SheetContent side="right" className="w-full gap-0 p-0 sm:max-w-xl">
|
||||
<SheetHeader className="border-b pr-12">
|
||||
<SheetTitle className="truncate">问 Agent · {title}</SheetTitle>
|
||||
<SheetDescription className="flex min-w-0 items-center gap-1">
|
||||
所属任务:
|
||||
{taskId ? (
|
||||
<Link className="truncate text-primary hover:underline" href={`/function/tasks/detail?id=${taskId}`}>
|
||||
{finding.task_description ?? `#${taskId}`}
|
||||
</Link>
|
||||
) : (
|
||||
<span>无</span>
|
||||
)}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{unavailableReason ? (
|
||||
<Alert variant="destructive" className="m-4 mb-0">
|
||||
<AlertDescription>{unavailableReason}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div ref={contentRef} className="min-h-full p-4">
|
||||
{loading ? <LoadingActivity /> : null}
|
||||
{!loading && activities.length ? (
|
||||
<Transcript
|
||||
activity={activities}
|
||||
live={running}
|
||||
taskId={taskId}
|
||||
chat
|
||||
fetchDetail={(seq) => api.activityDetail(seq, taskId).then((r) => r.detail)}
|
||||
/>
|
||||
) : null}
|
||||
{!loading && !activities.length ? <EmptyActivity available={taskAvailable === true} /> : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<SheetFooter className="border-t bg-background p-3">
|
||||
<div className="flex w-full items-end gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (!shouldSubmitOnKey(event, sendMode)) return;
|
||||
event.preventDefault();
|
||||
void send();
|
||||
}}
|
||||
disabled={!canAsk || taskAvailable !== true || sending || running}
|
||||
placeholder={running ? "主 Agent 正在运行…" : "描述复现现象或继续提问…"}
|
||||
aria-label="向漏洞所属任务的主 Agent 提问"
|
||||
className="max-h-32 min-h-10 resize-none"
|
||||
/>
|
||||
{running ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
onClick={() => void stop()}
|
||||
disabled={stopping}
|
||||
title="停止当前执行"
|
||||
aria-label="停止当前执行"
|
||||
>
|
||||
{stopping ? <Loader2Icon className="animate-spin" /> : <SquareIcon />}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
onClick={() => void send()}
|
||||
disabled={!canSend}
|
||||
title="发送消息"
|
||||
aria-label="发送消息"
|
||||
>
|
||||
{sending ? <Loader2Icon className="animate-spin" /> : <ArrowUpIcon />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="w-full text-[11px] text-muted-foreground">{footerStatus}</p>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { mentionToken } from "../lib/chat-mentions";
|
||||
import type { Finding } from "../lib/types";
|
||||
|
||||
type FindingContextFields = Finding & {
|
||||
url?: string;
|
||||
endpoint?: string;
|
||||
entry_point?: string;
|
||||
};
|
||||
|
||||
export function canAskFindingAgent(finding: Pick<Finding, "task_id" | "inherited"> | null | undefined): boolean {
|
||||
return Boolean(finding?.task_id && !finding.inherited);
|
||||
}
|
||||
|
||||
export function findingDisplayTitle(finding: Pick<Finding, "id" | "name" | "vulnclass">): string {
|
||||
if (finding.name?.trim()) return finding.name;
|
||||
if (finding.vulnclass?.trim()) return finding.vulnclass;
|
||||
return `漏洞 #${finding.id}`;
|
||||
}
|
||||
|
||||
export function findingAgentUnavailableReason(
|
||||
finding: Pick<Finding, "task_id" | "inherited"> | null | undefined,
|
||||
): string {
|
||||
if (!finding?.task_id) return "无所属任务,无法连接到主 Agent。";
|
||||
if (finding.inherited) return "继承漏洞只读,请到源任务提问。";
|
||||
return "";
|
||||
}
|
||||
|
||||
function truncate(value: string, max: number): string {
|
||||
const compact = value.replace(/\s+/g, " ").trim();
|
||||
return compact.length > max ? `${compact.slice(0, max)}…` : compact;
|
||||
}
|
||||
|
||||
/**
|
||||
* The finding mention is included once, in the first turn, so later user turns
|
||||
* stay short while the task's main Agent still has an unambiguous finding anchor.
|
||||
*/
|
||||
export function buildFindingAgentContextMessage(finding: Finding): string {
|
||||
const extended = finding as FindingContextFields;
|
||||
const numericId = Number(finding.id);
|
||||
const mention = mentionToken({
|
||||
kind: "finding",
|
||||
id: Number.isFinite(numericId) ? numericId : 0,
|
||||
label: truncate(findingDisplayTitle(finding), 100),
|
||||
description: truncate(finding.summary || "", 200),
|
||||
});
|
||||
const lines = [
|
||||
"用户正在漏洞详情页复现并提问。",
|
||||
mention,
|
||||
`严重等级:${finding.severity};漏洞类型:${finding.vulnclass || "未分类"}。`,
|
||||
];
|
||||
const summary = truncate(finding.summary || "", 300);
|
||||
if (summary) lines.push(`摘要:${summary}`);
|
||||
const entryPoint = extended.url || extended.entry_point || extended.endpoint;
|
||||
if (entryPoint) lines.push(`入口点:${truncate(entryPoint, 240)}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { ExternalLinkIcon, PlusIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
import type { FindingTraffic, FindingTrafficBinding, Reproduction, TrafficEvidenceRole } from "@/lib/types";
|
||||
|
||||
const ROLES: Record<TrafficEvidenceRole, string> = {
|
||||
baseline: "正常对照",
|
||||
proof: "漏洞证明",
|
||||
verification: "补充验证",
|
||||
supporting: "辅助证据",
|
||||
};
|
||||
|
||||
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,
|
||||
trafficReload = 0,
|
||||
}: {
|
||||
findingId: string;
|
||||
reproduction: Reproduction;
|
||||
contextTask?: string;
|
||||
readOnly?: boolean;
|
||||
onChanged?: () => void;
|
||||
trafficReload?: number;
|
||||
}) {
|
||||
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 [editing, setEditing] = React.useState<FindingTrafficBinding | null>(null);
|
||||
const [role, setRole] = React.useState<TrafficEvidenceRole>("supporting");
|
||||
const [note, setNote] = React.useState("");
|
||||
const [editingStepIndex, setEditingStepIndex] = React.useState("0");
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [reload, setReload] = React.useState(0);
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
api
|
||||
.findingTraffic(findingId, contextTask)
|
||||
.then((value) => {
|
||||
if (active) setTraffic(value);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setTraffic(null);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [findingId, contextTask, reload, trafficReload]);
|
||||
|
||||
const steps = reproduction.steps ?? [];
|
||||
const allBindings = traffic?.bindings ?? [];
|
||||
async function mutate(action: () => Promise<FindingTraffic>) {
|
||||
setBusy(true);
|
||||
try {
|
||||
setTraffic(await action());
|
||||
setEditing(null);
|
||||
onChanged?.();
|
||||
toast.success("流量证据已更新");
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message);
|
||||
setReload((value) => value + 1);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
function editBinding(item: FindingTrafficBinding) {
|
||||
setEditing(item);
|
||||
setRole(item.role);
|
||||
setNote(item.note);
|
||||
setEditingStepIndex(String(item.step_index ?? 0));
|
||||
}
|
||||
function bindingActions(item: FindingTrafficBinding) {
|
||||
return (
|
||||
<div key={item.id} className="flex flex-wrap items-center gap-1 rounded border px-2 py-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{ROLES[item.role]}
|
||||
</Badge>
|
||||
{item.note ? <span className="max-w-64 truncate text-muted-foreground text-xs">{item.note}</span> : null}
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => setPreview(item.id)}>
|
||||
<ExternalLinkIcon className="size-3" /> 查看流量
|
||||
</Button>
|
||||
{!readOnly ? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" disabled={busy} onClick={() => editBinding(item)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={busy || !traffic}
|
||||
onClick={() => {
|
||||
if (traffic)
|
||||
void mutate(() => api.removeFindingTraffic(findingId, item.id, traffic.version, contextTask));
|
||||
}}
|
||||
>
|
||||
解除绑定
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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-muted-foreground text-sm">证明:{step.proves}</span> : null}
|
||||
<div className="ml-auto flex gap-1">
|
||||
{bound.map(bindingActions)}
|
||||
{!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-muted-foreground text-xs">响应</div>
|
||||
<HttpCodeBlock raw={step.response} />
|
||||
</div>
|
||||
) : null}
|
||||
{bound.length > 0 ? (
|
||||
<div className="text-muted-foreground text-xs">已绑定 {bound.length} 条流量</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{allBindings.some((item) => (item.step_index ?? 0) > steps.length) ? (
|
||||
<div className="space-y-2 rounded-md border border-dashed p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">超出步骤范围</Badge>
|
||||
<span className="text-muted-foreground text-sm">这些绑定仍可编辑、解绑或改回总览/有效步骤。</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allBindings.filter((item) => (item.step_index ?? 0) > steps.length).map(bindingActions)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{reproduction.provenance ? (
|
||||
<p className="text-muted-foreground text-xs">来源:{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)}
|
||||
/>
|
||||
<Dialog
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !busy) setEditing(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑流量证据</DialogTitle>
|
||||
<DialogDescription>修改用途、说明或所属复现步骤。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="reproduction-evidence-role">用途</FieldLabel>
|
||||
<Select value={role} onValueChange={(value) => setRole(value as TrafficEvidenceRole)}>
|
||||
<SelectTrigger id="reproduction-evidence-role">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{Object.entries(ROLES).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="reproduction-evidence-note">证据说明</FieldLabel>
|
||||
<Textarea
|
||||
id="reproduction-evidence-note"
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="reproduction-evidence-step">复现步骤序号</FieldLabel>
|
||||
<Input
|
||||
id="reproduction-evidence-step"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={editingStepIndex}
|
||||
onChange={(event) => setEditingStepIndex(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={busy} onClick={() => setEditing(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy || !editing || !traffic}
|
||||
onClick={() => {
|
||||
if (editing && traffic) {
|
||||
void mutate(() =>
|
||||
api.editFindingTraffic(
|
||||
findingId,
|
||||
editing.id,
|
||||
traffic.version,
|
||||
{ role, note, step_index: Math.max(0, Number.parseInt(editingStepIndex, 10) || 0) },
|
||||
contextTask,
|
||||
),
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -43,11 +44,17 @@ export function FindingTrafficPanel({
|
||||
contextTask,
|
||||
readOnly,
|
||||
onChanged,
|
||||
stepIndex: filterStepIndex,
|
||||
title,
|
||||
trafficReload = 0,
|
||||
}: {
|
||||
findingId: string;
|
||||
contextTask?: string;
|
||||
readOnly?: boolean;
|
||||
onChanged: () => void;
|
||||
stepIndex?: number;
|
||||
title?: string;
|
||||
trafficReload?: number;
|
||||
}) {
|
||||
const [data, setData] = React.useState<FindingTraffic | null>(null);
|
||||
const [error, setError] = React.useState("");
|
||||
@@ -58,6 +65,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 [editingStepIndex, setEditingStepIndex] = 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 +76,9 @@ export function FindingTrafficPanel({
|
||||
api
|
||||
.findingTraffic(findingId, contextTask)
|
||||
.then((value) => {
|
||||
if (active) setData(value);
|
||||
if (active) {
|
||||
setData(value);
|
||||
}
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (active) setError(e.message);
|
||||
@@ -76,7 +86,7 @@ export function FindingTrafficPanel({
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [findingId, contextTask, reload]);
|
||||
}, [findingId, contextTask, reload, filterStepIndex, trafficReload]);
|
||||
|
||||
async function mutate(action: () => Promise<FindingTraffic>) {
|
||||
setBusy(true);
|
||||
@@ -95,11 +105,116 @@ export function FindingTrafficPanel({
|
||||
|
||||
function move(index: number, delta: number) {
|
||||
if (!data) return;
|
||||
const visibleBindings = data.bindings.filter(
|
||||
(b) => filterStepIndex === undefined || (b.step_index ?? 0) === filterStepIndex,
|
||||
);
|
||||
const target = visibleBindings[index + delta];
|
||||
if (!target) return;
|
||||
const ids = data.bindings.map((b) => b.id);
|
||||
[ids[index], ids[index + delta]] = [ids[index + delta], ids[index]];
|
||||
const from = ids.indexOf(visibleBindings[index].id);
|
||||
const to = ids.indexOf(target.id);
|
||||
if (from < 0 || to < 0) return;
|
||||
[ids[from], ids[to]] = [ids[to], ids[from]];
|
||||
void mutate(() => api.orderFindingTraffic(findingId, ids, data.version, contextTask));
|
||||
}
|
||||
|
||||
const visibleBindings =
|
||||
data?.bindings.filter((b) => filterStepIndex === undefined || (b.step_index ?? 0) === filterStepIndex) ?? [];
|
||||
const addStepIndex = filterStepIndex ?? 0;
|
||||
|
||||
function renderBindings() {
|
||||
if (!data) return <Skeleton className="h-28 w-full" />;
|
||||
if (visibleBindings.length === 0) {
|
||||
return (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>暂无关联流量</EmptyTitle>
|
||||
<EmptyDescription>可绑定正常对照、漏洞证明和补充验证请求。</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
return visibleBindings.map((b, index) => (
|
||||
<div key={b.id} className="flex min-w-0 flex-col gap-2 rounded-md border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{index + 1}. {ROLES[b.role]}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{b.snapshot.method} · {b.snapshot.status}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">证据 #{b.id}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto justify-start whitespace-normal p-0 text-left"
|
||||
onClick={() => setPreview(b.id)}
|
||||
>
|
||||
<span className="break-all font-mono text-xs">{b.snapshot.url}</span>
|
||||
</Button>
|
||||
{b.note ? <p className="whitespace-pre-wrap text-sm">{b.note}</p> : null}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{new Date(b.snapshot.captured_at * 1000).toLocaleString("zh-CN")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Button variant="outline" size="sm" onClick={() => setPreview(b.id)}>
|
||||
查看报文
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={repeaterBusy} onClick={() => void seedRepeater(b.id)}>
|
||||
送入请求重发
|
||||
</Button>
|
||||
{!readOnly ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setEditing(b);
|
||||
setRole(b.role);
|
||||
setNote(b.note);
|
||||
setEditingStepIndex(String(b.step_index ?? 0));
|
||||
}}
|
||||
>
|
||||
编辑说明
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`上移证据 ${b.id}`}
|
||||
disabled={busy || index === 0}
|
||||
onClick={() => move(index, -1)}
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`下移证据 ${b.id}`}
|
||||
disabled={busy || index === visibleBindings.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void mutate(() => api.removeFindingTraffic(findingId, b.id, data.version, contextTask))
|
||||
}
|
||||
>
|
||||
解除绑定
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
async function seedRepeater(bindingId: string) {
|
||||
setRepeaterBusy(true);
|
||||
try {
|
||||
@@ -131,7 +246,9 @@ 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 ? `(${visibleBindings.length})` : ""}
|
||||
</CardTitle>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -166,95 +283,7 @@ export function FindingTrafficPanel({
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{!data ? (
|
||||
<Skeleton className="h-28 w-full" />
|
||||
) : data.bindings.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>暂无关联流量</EmptyTitle>
|
||||
<EmptyDescription>可绑定正常对照、漏洞证明和补充验证请求。</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : (
|
||||
data.bindings.map((b, index) => (
|
||||
<div key={b.id} className="flex min-w-0 flex-col gap-2 rounded-md border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{index + 1}. {ROLES[b.role]}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{b.snapshot.method} · {b.snapshot.status}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">证据 #{b.id}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto justify-start whitespace-normal p-0 text-left"
|
||||
onClick={() => setPreview(b.id)}
|
||||
>
|
||||
<span className="break-all font-mono text-xs">{b.snapshot.url}</span>
|
||||
</Button>
|
||||
{b.note ? <p className="whitespace-pre-wrap text-sm">{b.note}</p> : null}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{new Date(b.snapshot.captured_at * 1000).toLocaleString("zh-CN")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Button variant="outline" size="sm" onClick={() => setPreview(b.id)}>
|
||||
查看报文
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={repeaterBusy} onClick={() => void seedRepeater(b.id)}>
|
||||
送入请求重发
|
||||
</Button>
|
||||
{!readOnly ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setEditing(b);
|
||||
setRole(b.role);
|
||||
setNote(b.note);
|
||||
}}
|
||||
>
|
||||
编辑说明
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`上移证据 ${b.id}`}
|
||||
disabled={busy || index === 0}
|
||||
onClick={() => move(index, -1)}
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`下移证据 ${b.id}`}
|
||||
disabled={busy || index === data.bindings.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void mutate(() => api.removeFindingTraffic(findingId, b.id, data.version, contextTask))
|
||||
}
|
||||
>
|
||||
解除绑定
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{renderBindings()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{adding && data ? (
|
||||
@@ -262,6 +291,7 @@ export function FindingTrafficPanel({
|
||||
findingId={findingId}
|
||||
contextTask={contextTask}
|
||||
bound={data.bindings}
|
||||
stepIndex={addStepIndex}
|
||||
onClose={() => setAdding(false)}
|
||||
onBound={() => {
|
||||
setReload((n) => n + 1);
|
||||
@@ -308,6 +338,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={editingStepIndex}
|
||||
onChange={(e) => setEditingStepIndex(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={busy} onClick={() => setEditing(null)}>
|
||||
@@ -318,7 +359,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(editingStepIndex, 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
@@ -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>(
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { createRequire } = require("node:module");
|
||||
const test = require("node:test");
|
||||
|
||||
// Keep the tests runnable on Node 20, which does not load TypeScript natively.
|
||||
const requireFromTest = createRequire(__filename);
|
||||
requireFromTest("ts-node").register({
|
||||
transpileOnly: true,
|
||||
compilerOptions: { module: "CommonJS", moduleResolution: "Node10" },
|
||||
});
|
||||
|
||||
const {
|
||||
buildFindingAgentContextMessage,
|
||||
canAskFindingAgent,
|
||||
findingAgentUnavailableReason,
|
||||
} = requireFromTest("../components/finding-agent-chat.ts");
|
||||
|
||||
const finding = (overrides = {}) => ({
|
||||
id: "42",
|
||||
vulnclass: "SQL 注入",
|
||||
name: "搜索参数注入",
|
||||
severity: "high",
|
||||
status: "confirmed",
|
||||
summary: "搜索参数可控制数据库查询。",
|
||||
evidence: "large evidence omitted",
|
||||
ts: "2026-10-27T00:00:00Z",
|
||||
task_id: "task-7",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("finding without task cannot ask its Agent", () => {
|
||||
assert.equal(canAskFindingAgent(finding({ task_id: undefined })), false);
|
||||
assert.match(findingAgentUnavailableReason(finding({ task_id: undefined })), /无所属任务/);
|
||||
});
|
||||
|
||||
test("task-owned finding can ask and context contains a finding mention", () => {
|
||||
assert.equal(canAskFindingAgent(finding()), true);
|
||||
const context = buildFindingAgentContextMessage(finding());
|
||||
assert.match(context, /用户正在漏洞详情页复现并提问/);
|
||||
assert.match(context, /@\[漏洞#42 搜索参数注入\]/);
|
||||
assert.match(context, /SQL 注入/);
|
||||
});
|
||||
|
||||
test("inherited finding is read-only", () => {
|
||||
assert.equal(canAskFindingAgent(finding({ inherited: true, source_task_id: "source-1" })), false);
|
||||
assert.match(findingAgentUnavailableReason(finding({ inherited: true })), /继承漏洞只读/);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user