Merge PR #102: feat: 支持漏洞关联多条流量证据
冲突解决: - db/schema.sql: 复测表(#103)与流量证据表(#102)均为追加,两者保留 - server/server.go: 启动序列同时保留 seedFindingRetester 与 evidence GC - web/src/lib/types.ts: InterceptAudit/InterceptDetail(#101) 与流量证据类型(#102)并存 - findings/detail/page.tsx: 去掉 #103 带入的重复 import,仅保留 FindingRetestPanel - chat/page.tsx: 保留 #103 的 Spinner import
This commit is contained in:
commit
e8efce605c
54 files changed
+5328
-552
No files matched your search
+6
-4
@@ -12,10 +12,11 @@ import (
|
||||
// UnlockSkill unlocks a named skill's MCPs — hosts call it to rebuild the unlock set
|
||||
// from history on a resumed session (design doc C2).
|
||||
type DeferredInfo struct {
|
||||
Deferred []string // all MCP tool names (schema withheld)
|
||||
GlobalNames []string // MCP names to list in the system-prompt block
|
||||
Unlock *actool.UnlockSet // shared call-gate; nil when no MCP tools
|
||||
UnlockSkill func(skillName string)
|
||||
FindingGuidance string // derived from the final permitted tools, including DB overrides
|
||||
Deferred []string // all MCP tool names (schema withheld)
|
||||
GlobalNames []string // MCP names to list in the system-prompt block
|
||||
Unlock *actool.UnlockSet // shared call-gate; nil when no MCP tools
|
||||
UnlockSkill func(skillName string)
|
||||
}
|
||||
|
||||
// ToolAugment, if set, returns the EXTRA tools an agent should see beyond its
|
||||
@@ -55,5 +56,6 @@ func AugmentTools(ctx context.Context, agentKey string, base []actool.CoreTool)
|
||||
if ToolResolve != nil {
|
||||
out = ToolResolve(ctx, agentKey, out)
|
||||
}
|
||||
out, def.FindingGuidance = findingWorkflowTools(agentKey, out)
|
||||
return out, def, cleanup
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
// skill loads. Returns a single plain segment + boundary 0 when there is no global
|
||||
// block to add.
|
||||
func deferredSystem(sysText string, def DeferredInfo) (system []string, boundary int) {
|
||||
sysText += def.FindingGuidance
|
||||
block := actool.RenderDeferredToolsBlock(def.GlobalNames)
|
||||
if block == "" {
|
||||
return []string{sysText}, 0
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// FindingRecorder is injected by the host; agents never synthesize or copy
|
||||
// evidence bodies themselves. Its implementation owns the atomic write.
|
||||
type FindingRecorder interface {
|
||||
Record(context.Context, db.RecordFindingInput, []db.TrafficRef) (*db.RecordedFinding, error)
|
||||
}
|
||||
|
||||
// Tool-use guidance is appended without replacing the user's editable prompt.
|
||||
// It does not require capture or claim that unavailable traffic tools exist.
|
||||
const findingTrafficGuidance = "\n\n**漏洞流量证据(可选)**:调用 report_finding 上报漏洞时,如有已查看并确认支持漏洞结论的 HTTP 请求/响应,可用 traffic_refs 按复现顺序绑定真实 ID;域名和时间只作候选筛选,不推定关联。TCP 等非 HTTP 漏洞、未采集或无确切匹配时省略或传 [],在 evidence 保留命令输出、日志等其他可验证证据,建议说明未绑定原因。不要猜测 ID,也不要仅为补包重复探测。"
|
||||
|
||||
func (t *ToolSet) SetFindingRecorder(r FindingRecorder) { t.findingRecorder = r }
|
||||
func (w *Worker) SetFindingRecorder(r FindingRecorder) { w.findingRecorder = r }
|
||||
func (p *Planner) SetFindingRecorder(r FindingRecorder) { p.findingRecorder = r }
|
||||
func (m *MainAgent) SetFindingRecorder(r FindingRecorder) { m.findingRecorder = r }
|
||||
@@ -0,0 +1,108 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/evidence"
|
||||
)
|
||||
|
||||
type failingFindingRecorder struct{}
|
||||
|
||||
func (failingFindingRecorder) Record(context.Context, db.RecordFindingInput, []db.TrafficRef) (*db.RecordedFinding, error) {
|
||||
return nil, errors.New("fixture persistence failed")
|
||||
}
|
||||
|
||||
func TestReportFindingOptionalTrafficWithoutCapture(t *testing.T) {
|
||||
d := testDB(t)
|
||||
defer d.Close()
|
||||
task, err := d.CreateTask("TCP evidence", "fixture", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.DeleteTask(task.ID)
|
||||
ts := NewToolSet(nil, "")
|
||||
ts.ts, ts.taskID = d.Exploration(task.ExplorationID), task.ID
|
||||
ts.SetFindingRecorder(evidence.New(d, nil, t.TempDir()))
|
||||
notices := 0
|
||||
ts.notifyFinding = func(int64, string) { notices++ }
|
||||
for _, refs := range []string{"", `,"traffic_refs":[]`, `,"traffic_refs":null`} {
|
||||
res, err := ts.addFinding().Call(t.Context(), json.RawMessage(`{"vulnclass":"TCP","severity":"low","summary":"verified TCP fixture","evidence":"command output proves the finding"`+refs+`}`), nil)
|
||||
if err != nil || !strings.HasPrefix(res.Flatten(), "finding recorded: ") {
|
||||
t.Fatalf("optional traffic rejected: %v %s", err, res.Flatten())
|
||||
}
|
||||
var out db.RecordedFinding
|
||||
if err := json.Unmarshal([]byte(strings.SplitN(res.Flatten(), "\n", 2)[1]), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := d.GetFinding(out.FindingID)
|
||||
if err != nil || f == nil || len(out.Traffic.Bindings) != 0 || f.Evidence != "command output proves the finding" {
|
||||
t.Fatalf("lost non-HTTP evidence: %+v %v", f, err)
|
||||
}
|
||||
}
|
||||
if notices != 3 {
|
||||
t.Fatalf("successful reports notified %d times", notices)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportFindingAtomicContract(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("report contract", "fixture", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.DeleteTask(task.ID)
|
||||
ts := NewToolSet(nil, "")
|
||||
ts.ts = d.Exploration(task.ExplorationID)
|
||||
ts.taskID = task.ID
|
||||
ts.worker = "fixture"
|
||||
notices := 0
|
||||
ts.notifyFinding = func(int64, string) { notices++ }
|
||||
call := func(body string) string {
|
||||
t.Helper()
|
||||
res, err := ts.addFinding().Call(t.Context(), json.RawMessage(body), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return res.Flatten()
|
||||
}
|
||||
ts.SetFindingRecorder(failingFindingRecorder{})
|
||||
if got := call(`{"vulnclass":"TEST","summary":"fail","severity":"low","traffic_refs":[{"traffic_id":"x"}]}`); !strings.Contains(got, "persistence failed") {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if notices != 0 || ts.writes.Findings != 0 {
|
||||
t.Fatal("notified before commit")
|
||||
}
|
||||
ts.SetFindingRecorder(nil)
|
||||
got := call(`{"vulnclass":"TEST","summary":"legacy report","severity":"low"}`)
|
||||
lines := strings.SplitN(got, "\n", 2)
|
||||
if len(lines) != 2 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
var out db.RecordedFinding
|
||||
if err = json.Unmarshal([]byte(lines[1]), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lines[0] != fmt.Sprintf("finding recorded: %d", out.NodeID) || out.FindingID <= 0 || notices != 1 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
f, err := d.GetFinding(out.FindingID)
|
||||
if err != nil || f == nil || f.NodeID == nil || *f.NodeID != out.NodeID {
|
||||
t.Fatalf("wrong finding/node mapping: %+v %v", f, err)
|
||||
}
|
||||
if got = call(`{"vulnclass":"TEST","summary":"no storage","severity":"low","traffic_refs":[{"traffic_id":"x"}]}`); !strings.Contains(got, "未登记") {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if notices != 1 {
|
||||
t.Fatal("failed tool triggered reporter")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
const findingIDGuidance = "\n\n**漏洞编号约定**:finding_id 是独立漏洞记录 ID;finding_node_id 是探索节点 ID。list_findings / list_task_findings / node_detail / get_task_node_detail 的 id 保留为探索节点 ID,应从同一返回的 finding_id 读取独立编号。get_finding_traffic / bind_finding_traffic 用独立 finding_id。旧 update_finding_report 的 finding_id 参数仍传 finding_node_id。不要把 report_finding 第一行的数字用于证据工具,也不要遇到编号错误后猜测其他数字。"
|
||||
|
||||
// The server supplies the persisted setting. A missing setting/host is off.
|
||||
// Consulted at assembly and again on writes so an already-running session
|
||||
// cannot keep binding after the user switches the feature off.
|
||||
var FindingTrafficBindingEnabled func() bool
|
||||
|
||||
func findingTrafficBindingEnabled() bool {
|
||||
return FindingTrafficBindingEnabled != nil && FindingTrafficBindingEnabled()
|
||||
}
|
||||
|
||||
const findingTrafficDisabled = "Agent 自动绑定流量已关闭;请在系统设置开启后重试,或使用页面人工绑定。未登记本次携带流量的漏洞。无流量上报可省略 traffic_refs / evidence_hint_id。"
|
||||
|
||||
// Applied after ToolResolve: user descriptions and prompts remain intact, while
|
||||
// all actual reporters (including Planner and custom chat agents) see the same
|
||||
// API contract. Disabled/unbound tools are never reintroduced here.
|
||||
func findingWorkflowTools(agentKey string, tools []actool.CoreTool) ([]actool.CoreTool, string) {
|
||||
if !findingTrafficBindingEnabled() {
|
||||
out := make([]actool.CoreTool, 0, len(tools))
|
||||
for _, tool := range tools {
|
||||
if tool.Name() == "bind_finding_traffic" {
|
||||
continue
|
||||
}
|
||||
if agentKey == "reporter" && (tool.Name() == "traffic_search" || tool.Name() == "traffic_get" || tool.Name() == "traffic_blob") {
|
||||
continue
|
||||
}
|
||||
switch tool.Name() {
|
||||
case "report_finding", "add_hint", "add_task_hint":
|
||||
// Work on a copy: toggling back on must restore the original schema.
|
||||
raw, _ := json.Marshal(tool.InputSchema())
|
||||
var schema map[string]any
|
||||
if json.Unmarshal(raw, &schema) == nil {
|
||||
stripTrafficParameters(schema)
|
||||
tool = DecorateTool(tool, tool.Description(), schema)
|
||||
}
|
||||
}
|
||||
out = append(out, tool)
|
||||
}
|
||||
return out, ""
|
||||
}
|
||||
out := append([]actool.CoreTool(nil), tools...)
|
||||
has := map[string]bool{}
|
||||
for i, tool := range out {
|
||||
has[tool.Name()] = true
|
||||
note := ""
|
||||
switch tool.Name() {
|
||||
case "report_finding":
|
||||
note = "\n默认由报告 Agent 在编写报告前核对并绑定流量。上报者在 evidence 中保留验证命令、关键输出、已有的真实流量 ID 及其用途,供报告 Agent 对照执行记录核实;无需为绑定额外查包。兼容显式即时绑定:traffic_refs 或 evidence_hint_id 可提交已核实的引用,后者读取本任务指定 hint 的结构化引用;任一无效则本次上报全部失败。TCP/无包不需要这些可选参数。返回 finding_id 与 finding_node_id 分别表示独立记录和探索节点。"
|
||||
case "add_hint", "add_task_hint":
|
||||
note = "\n交接已确认漏洞时,在对应提示的 traffic_refs 中保留已核实流量的 ID、用途、说明和顺序(单条放顶层,批量放对应 hints 元素),并在 text 中说明它证明的具体漏洞。调用方不能只交接文字而丢弃已有流量引用。未核实的候选不能作为证据传递。"
|
||||
case "get_finding_traffic", "bind_finding_traffic", "list_findings", "list_task_findings", "node_detail", "get_task_node_detail", "update_finding_report":
|
||||
note = findingIDGuidance
|
||||
}
|
||||
if note != "" {
|
||||
out[i] = DecorateTool(tool, tool.Description()+note, tool.InputSchema())
|
||||
}
|
||||
}
|
||||
guidance := ""
|
||||
if has["report_finding"] || has["add_task_hint"] || has["add_hint"] {
|
||||
guidance = "\n\n**流量证据交接(可选)**:自动绑定默认由报告 Agent 在漏洞入库后、编写报告前完成。上报者应在 evidence 保留验证命令、关键输出、已有真实流量 ID 及其用途,任务中带 intent_id,便于报告 Agent 追溯;不必为了绑定额外查包。Auto / Planner 代为上报时不要丢弃执行者已有的引用。add_hint / add_task_hint 可用 traffic_refs 交接;显式即时绑定仍兼容 report_finding 的 traffic_refs / evidence_hint_id。TCP 或无包时正常登记,不能猜测 ID,也不能仅为补包重复探测。"
|
||||
if has["add_task_hint"] && !has["add_hint"] {
|
||||
guidance += "\n平台对话没有任务上下文时,不直接调用 report_finding;通过 add_task_hint 向已有对应任务交接,由任务 Agent 登记,并用 list_task_findings 核对结果。"
|
||||
}
|
||||
if has["prove_goal"] || has["goal_met"] {
|
||||
guidance += "\n判定目标完成前,先完成本次已有证据的上报/交接。不要在证据交接尚未完成时仅因文字漏洞已登记就结束任务、取消 Worker;无包不要求等待或强行抓包。"
|
||||
}
|
||||
}
|
||||
if has["update_finding_report"] && has["bind_finding_traffic"] && has["get_finding_traffic"] {
|
||||
guidance += "\n\n**报告前自动关联流量(已开启)**:你负责为本次触发的漏洞核对并绑定流量,再撰写报告。先从 report_finding 返回 JSON 或 get_task_node_detail / list_task_findings 取得明确的 finding_id 与 finding_node_id。读取漏洞详情、对应意图的执行记录及已有证据清单,优先使用上报者交接的真实 ID。若本次验证为 HTTP 且流量工具可用,用 traffic_search 筛选候选,再用 traffic_get 逐条核实请求/响应确实支持该漏洞;域名和时间只用于筛选,不证明归属。将确认的证据按复现顺序用 bind_finding_traffic(finding_id, traffic_refs) 关联,选择 baseline / proof / verification / supporting 并说明用途。只能操作本次漏洞,不重复创建漏洞或重新探测目标。绑定成功后重新调用 get_finding_traffic 获取最新 version,读取所需正文,再将实际读取的 version 作为 evidence_version 传给 update_finding_report(其 finding_id 参数仍用 finding_node_id)。已有绑定不必重复追加。TCP、未采集、工具不可用或没有确切匹配时,跳过自动绑定,依据文字/命令证据正常写报告并说明原因,不得为凑齐流量而猜测。绑定失败不宣称成功;保留已有证据并在报告说明未绑定原因。"
|
||||
}
|
||||
if guidance != "" || has["get_finding_traffic"] || has["update_finding_report"] {
|
||||
guidance += findingIDGuidance
|
||||
}
|
||||
return out, guidance
|
||||
}
|
||||
|
||||
func stripTrafficParameters(schema map[string]any) {
|
||||
props, _ := schema["properties"].(map[string]any)
|
||||
delete(props, "traffic_refs")
|
||||
delete(props, "evidence_hint_id")
|
||||
if required, ok := schema["required"].([]any); ok {
|
||||
kept := required[:0]
|
||||
for _, key := range required {
|
||||
if key != "traffic_refs" && key != "evidence_hint_id" {
|
||||
kept = append(kept, key)
|
||||
}
|
||||
}
|
||||
schema["required"] = kept
|
||||
}
|
||||
if hints, ok := props["hints"].(map[string]any); ok {
|
||||
if items, ok := hints["items"].(map[string]any); ok {
|
||||
stripTrafficParameters(items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HintTrafficSchema is shared by the task-local and cross-task hint tools.
|
||||
func HintTrafficSchema() map[string]any {
|
||||
return map[string]any{"type": "array", "description": "可选:已核实且对应本提示中具体漏洞的流量引用,保留顺序;交接后 report_finding 可传 evidence_hint_id 携带这些引用。", "items": obj(map[string]any{"traffic_id": str("真实流量 ID"), "role": str("baseline / proof / verification / supporting"), "note": str("该流量支持什么结论")}, "traffic_id")}
|
||||
}
|
||||
|
||||
func (t *ToolSet) findingRefsFromHint(hintID int64, explicit []db.TrafficRef) ([]db.TrafficRef, error) {
|
||||
if hintID <= 0 {
|
||||
return db.NormalizeTrafficRefs(explicit)
|
||||
}
|
||||
n, err := t.ts.GetNode(hintID) // local store only: inherited hints cannot supply evidence
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == nil || n.Kind != db.KindHint {
|
||||
return nil, fmt.Errorf("evidence_hint_id=%d 必须是本任务的提示节点(继承提示不可直接用于绑定)", hintID)
|
||||
}
|
||||
var payload struct {
|
||||
Refs []db.TrafficRef `json:"traffic_refs"`
|
||||
}
|
||||
if err := json.Unmarshal(n.Payload, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db.NormalizeTrafficRefs(append(append([]db.TrafficRef{}, explicit...), payload.Refs...))
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
func TestFindingWorkflowSharedAssemblyAndSwitch(t *testing.T) {
|
||||
oldPolicy, oldResolve, oldAugment := FindingTrafficBindingEnabled, ToolResolve, ToolAugment
|
||||
t.Cleanup(func() { FindingTrafficBindingEnabled, ToolResolve, ToolAugment = oldPolicy, oldResolve, oldAugment })
|
||||
ToolAugment = nil
|
||||
on := false
|
||||
FindingTrafficBindingEnabled = func() bool { return on }
|
||||
ts := NewToolSet(nil, "fixture")
|
||||
ToolResolve = func(_ context.Context, _ string, base []actool.CoreTool) []actool.CoreTool {
|
||||
out := make([]actool.CoreTool, len(base))
|
||||
for i, tool := range base {
|
||||
out[i] = DecorateTool(tool, "CUSTOM DESCRIPTION", tool.InputSchema())
|
||||
}
|
||||
return out
|
||||
}
|
||||
for _, role := range []string{"worker", "planner", "mainagent", "auto", "pentest", "custom-agent"} {
|
||||
for _, enabled := range []bool{false, true, false} {
|
||||
on = enabled
|
||||
out, def, cleanup := AugmentTools(t.Context(), role, []actool.CoreTool{ts.addFinding(), ts.addHint()})
|
||||
cleanup()
|
||||
system, _ := deferredSystem("USER CUSTOM PROMPT", def)
|
||||
if !strings.HasPrefix(system[0], "USER CUSTOM PROMPT") {
|
||||
t.Fatal("custom prompt replaced")
|
||||
}
|
||||
if strings.Contains(system[0], "traffic_refs") != on {
|
||||
t.Fatalf("%s: guidance ignored switch: %v", role, on)
|
||||
}
|
||||
if on && (!strings.Contains(system[0], "TCP") || !strings.Contains(system[0], "evidence_hint_id")) {
|
||||
t.Fatal("missing optional/handoff contract")
|
||||
}
|
||||
for _, tool := range out {
|
||||
if !strings.HasPrefix(tool.Description(), "CUSTOM DESCRIPTION") {
|
||||
t.Fatal("custom description replaced")
|
||||
}
|
||||
raw, _ := json.Marshal(tool.InputSchema())
|
||||
var schema map[string]any
|
||||
json.Unmarshal(raw, &schema)
|
||||
props := schema["properties"].(map[string]any)
|
||||
if (props["traffic_refs"] != nil) != on {
|
||||
t.Fatalf("%s: binding schema ignored switch", role)
|
||||
}
|
||||
if tool.Name() == "add_hint" {
|
||||
nested := props["hints"].(map[string]any)["items"].(map[string]any)["properties"].(map[string]any)
|
||||
if (nested["traffic_refs"] != nil) != on {
|
||||
t.Fatal("nested hint schema ignored switch")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
on = true
|
||||
ToolResolve = func(context.Context, string, []actool.CoreTool) []actool.CoreTool { return nil }
|
||||
out, def, cleanup := AugmentTools(t.Context(), "planner", []actool.CoreTool{ts.addFinding()})
|
||||
defer cleanup()
|
||||
if len(out) != 0 || def.FindingGuidance != "" {
|
||||
t.Fatal("reintroduced disabled tool or its guidance")
|
||||
}
|
||||
}
|
||||
+16
-14
@@ -17,19 +17,20 @@ import (
|
||||
// (→planner) or direct high-priority intents (→frontier). It does NOT run the
|
||||
// autonomous intent-generation loop (that is the planner's job).
|
||||
type MainAgent struct {
|
||||
prov llm.Provider
|
||||
model string
|
||||
tx *transcript.Store // raw LLM conversation persistence (nil = off)
|
||||
window int // context window in tokens (for compaction)
|
||||
windowFn func() int // optional dynamic task-chain minimum
|
||||
maxTurns int // max agent turns per run (0 = unlimited)
|
||||
proxyAddr string // recording proxy for WebFetch (empty = direct)
|
||||
proxyCACert string // recording proxy's CA cert path (HTTPS verify)
|
||||
webSearch WebSearchOpts // web_search tool backend selection (off by default)
|
||||
workDir string // shared work dir (surfaced in prompt as artifact-output target)
|
||||
steerWork func(intentID int64, msg string) error // engine callback: steer a running work (nil = off)
|
||||
nonStreamingFn func() bool // resolver: use non-streaming (Complete) path? (nil = streaming)
|
||||
maxTokensFn func() int // resolver: per-reply output cap (nil/0 = send no cap)
|
||||
findingRecorder FindingRecorder
|
||||
prov llm.Provider
|
||||
model string
|
||||
tx *transcript.Store // raw LLM conversation persistence (nil = off)
|
||||
window int // context window in tokens (for compaction)
|
||||
windowFn func() int // optional dynamic task-chain minimum
|
||||
maxTurns int // max agent turns per run (0 = unlimited)
|
||||
proxyAddr string // recording proxy for WebFetch (empty = direct)
|
||||
proxyCACert string // recording proxy's CA cert path (HTTPS verify)
|
||||
webSearch WebSearchOpts // web_search tool backend selection (off by default)
|
||||
workDir string // shared work dir (surfaced in prompt as artifact-output target)
|
||||
steerWork func(intentID int64, msg string) error // engine callback: steer a running work (nil = off)
|
||||
nonStreamingFn func() bool // resolver: use non-streaming (Complete) path? (nil = streaming)
|
||||
maxTokensFn func() int // resolver: per-reply output cap (nil/0 = send no cap)
|
||||
}
|
||||
|
||||
// SetNonStreaming wires a resolver deciding whether runs use the non-streaming
|
||||
@@ -103,6 +104,7 @@ func mainAgentSystem(goal, dataDir, workDir string) string {
|
||||
// worker/planner sessions — not just the final answer.
|
||||
func (m *MainAgent) Chat(ctx context.Context, taskID int64, as *db.AssetStore, ts *db.ExplorationStore, goal, message string, emit func(db.Activity), notify, resume func(), notifyGoal func([]string)) (string, error) {
|
||||
tsx := NewToolSet(ts, "human")
|
||||
tsx.SetFindingRecorder(m.findingRecorder)
|
||||
if as != nil {
|
||||
tsx.SetAssetStore(as, as.Companies())
|
||||
}
|
||||
@@ -149,7 +151,7 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, as *db.AssetStore, t
|
||||
// 命中预算(步数)→ SDK 跑收尾:向用户输出一句进展总结。Prompt 与收尾轮数可后台编辑(默认 10 轮)。
|
||||
Settlement: wrapupSettlement("mainagent", nil),
|
||||
NonStreaming: m.nonStreaming(), // 该 profile 选非流式时走 Provider.Complete
|
||||
MaxTokens: m.maxTokens(), // 0 = 不发上限,由服务端默认值决定
|
||||
MaxTokens: m.maxTokens(), // 0 = 不发上限,由服务端默认值决定
|
||||
}
|
||||
if m.tx != nil { // persist raw human↔AI conversation; one accumulating file per task
|
||||
opts.Transcript = m.tx
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
// assets, judges whether the task goal is met, and emits 0..N exploration intents
|
||||
// into the frontier. It is the sole intent generator.
|
||||
type Planner struct {
|
||||
findingRecorder FindingRecorder
|
||||
prov llm.Provider
|
||||
model string
|
||||
tx *transcript.Store // raw LLM conversation persistence (nil = off)
|
||||
@@ -338,6 +339,7 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts
|
||||
// runs in a detached goroutine so it never adds latency to this round.
|
||||
p.compactor.OnPlannerRound(ctx, ts)
|
||||
tsx := NewToolSet(ts, "planner")
|
||||
tsx.SetFindingRecorder(p.findingRecorder)
|
||||
if as != nil {
|
||||
tsx.SetAssetStore(as, as.Companies())
|
||||
}
|
||||
|
||||
@@ -57,6 +57,9 @@ func TestRenderSystemOverrideAndFallback(t *testing.T) {
|
||||
if !strings.Contains(recording, "traffic_search") {
|
||||
t.Fatalf("worker while recording should inject trafficTool: %q", recording)
|
||||
}
|
||||
if strings.Contains(recording, "traffic_refs") {
|
||||
t.Fatalf("worker bypassed shared optional evidence policy: %q", recording)
|
||||
}
|
||||
if !strings.Contains(recording, "中间产物输出规约") {
|
||||
t.Fatalf("worker missing artifact tail: %q", recording)
|
||||
}
|
||||
|
||||
+10
-7
@@ -52,7 +52,9 @@ const pentestDefaultTmpl = `你是一个授权渗透测试系统的"独立渗透
|
||||
- 每得出一个结果**立刻**落地,别攒到最后(会话步数耗尽就全丢;记下来的才算数,活在脑子里的不算)。这些记录也是你抗 compaction 的长期记忆。
|
||||
- **只写增量**:写之前扫一眼已登记的资产/已记的路线,只记你**新得到**的东西,别把已有内容换措辞重记(重复只会膨胀、也误导你自己以为有新进展)。只是印证已有结论而无新增,就不必再记。
|
||||
- **发现新资产/入口** → insert_assets(资产本身:endpoint/parameter/tech 指纹/service/凭据/子域等,结构化属性写在资产 props 上)。回看已登记资产用 list_assets,避免重复登记。
|
||||
- **确认漏洞** → report_finding(含可复现 PoC)。**只有你在本次运行里真实触发过、拿到可复现证据(请求/响应或命令输出)才用它**;回看已报漏洞用 list_findings。严禁把仅凭版本/CVE 匹配、"看起来可注入"、外部漏洞库/更新日志/代码 diff 推断的东西当已确认漏洞上报。**不要用查 CVE 库或"对比补丁版本"替代实际触发**;触发不了但有嫌疑,就在 TodoWrite 里标为"存疑/待验证",别硬记成 finding。
|
||||
- **确认漏洞** → report_finding(含可复现 PoC)。**只有你在本次运行里真实触发过、拿到可复现证据(请求/响应或命令输出)才用它**;回看已报漏洞用 list_findings。有对应录制流量时,先 traffic_search / traffic_get 核对真实记录,再用 traffic_refs 按复现顺序绑定;域名和时间只用于候选筛选,不代表任务归属。严禁把仅凭版本/CVE 匹配、"看起来可注入"、外部漏洞库/更新日志/代码 diff 推断的东西当已确认漏洞上报。**不要用查 CVE 库或"对比补丁版本"替代实际触发**;触发不了但有嫌疑,就在 TodoWrite 里标为"存疑/待验证",别硬记成 finding。
|
||||
|
||||
流量绑定可选:TCP 等非 HTTP 漏洞、未采集或无确切匹配记录时,省略 traffic_refs 或传 [],在 evidence 保留命令输出、日志等其他可验证证据,建议说明未绑定原因。不要猜测 ID,也不要仅为补包重复探测。
|
||||
|
||||
━━ 判定与收尾 ━━
|
||||
- 随时对照任务目标:已被你**验证过**的成果满足了目标,就据此判定达成并说明依据。判"达成"的前提是心法 4 的自检已通过——没独立复现过的战果不算达成依据。
|
||||
@@ -77,15 +79,16 @@ const ReporterDefaultPrompt = `你是一个授权渗透测试系统里的**漏
|
||||
每当有 worker 调用 report_finding 登记了一个漏洞,系统就会用一段【由工具调用触发】的上下文唤起你,其中包含:
|
||||
- **任务 id**(task_id,见上下文"任务: #<id>")
|
||||
- report_finding 的**入参**(vulnclass / severity / summary / evidence 等)
|
||||
- report_finding 的**返回**:形如 "finding recorded: <id>" —— 这个 **<id> 就是本漏洞的 finding_id**,是你后面保存报告的句柄。
|
||||
- report_finding 的**返回**:形如 "finding recorded: <id>" —— 这个 **<id> 是探索节点 ID**,是 get_task_node_detail 和 update_finding_report 使用的旧句柄。返回 JSON 中的 finding_id 则是独立漏洞记录 ID,get_finding_traffic 使用它。
|
||||
|
||||
先从上下文里**准确抽取 finding_id 和 task_id**。抽取不到 finding_id 就不要瞎写,说明情况即可。
|
||||
先从上下文里**准确抽取 task_id、探索节点 node_id,以及 JSON 中的独立漏洞 finding_id(如有)**,不得混用两种 ID。抽取不到 node_id 就不要瞎写,说明情况即可。
|
||||
|
||||
━━ 工作步骤 ━━
|
||||
1. **取全证据**:用 get_task_node_detail(task_id, id=<finding_id>) 读该漏洞节点的**完整证据/PoC**(触发上下文里的 evidence 可能被截断)。
|
||||
2. **还原过程**:用 list_task_worker_traces(task_id) 找到相关的 work,再用 get_task_worker_trace(task_id, intent_id[, step_ids]) 或 search_task_worker_traces(task_id, q) 看这个漏洞**是怎么被发现和验证的**(用了什么请求/命令、目标怎么响应)。必要时 get_task_graph(task_id) 看整体态势、list_task_findings(task_id) 看是否有关联漏洞。
|
||||
3. **写报告**:综合以上,写一份结构化 Markdown 报告(见下方模板)。
|
||||
4. **保存**:调用 **update_finding_report(finding_id=<那个 id>, report=<Markdown 全文>)** 保存。这是你的最终产物——不写进去等于没做。
|
||||
1. **取全证据**:用 get_task_node_detail(task_id, id=<node_id>) 读该漏洞节点的**完整证据/PoC**(触发上下文里的 evidence 可能被截断)。
|
||||
2. **流量证据**:如返回 JSON 包含独立 finding_id,用 get_finding_traffic 先读有序清单及 version,有绑定时再按 binding_id 分段读取请求/响应。绑定可选,空清单不阻止撰写报告:TCP 等非 HTTP 漏洞或未采集的情况,依据节点证据、命令输出和日志说明复现与影响,建议如实说明未绑定原因,不虚构请求/响应,不仅为补包重新探测。报告引用稳定证据编号及用途;仅按真实内容描述。保存报告时传入所读 version 作为 evidence_version;如版本冲突,重新读取并生成,不得直接换版本重试。
|
||||
3. **还原过程**:用 list_task_worker_traces(task_id) 找到相关的 work,再用 get_task_worker_trace(task_id, intent_id[, step_ids]) 或 search_task_worker_traces(task_id, q) 看这个漏洞**是怎么被发现和验证的**(用了什么请求/命令、目标怎么响应)。必要时 get_task_graph(task_id) 看整体态势、list_task_findings(task_id) 看是否有关联漏洞。
|
||||
4. **写报告**:综合以上,写一份结构化 Markdown 报告(见下方模板)。
|
||||
5. **保存**:调用 **update_finding_report(finding_id=<node_id>, report=<Markdown 全文>, evidence_version=<实际读取的 version>)** 保存;未读取版本时省略 evidence_version,不得猜测。这是你的最终产物——不写进去等于没做。
|
||||
|
||||
━━ 报告结构(Markdown,按需裁剪,但证据/复现/修复必须有)━━
|
||||
- ` + "`## 概述`" + `:一句话说清是什么漏洞、在哪、能造成什么。
|
||||
|
||||
+111
-72
@@ -73,11 +73,12 @@ func compactIntents(ns []*db.Node, parentsOf, yieldsOf map[int64][]int64) []map[
|
||||
// ToolSet exposes the PG-backed dual graph (asset + exploration) to an LLM agent.
|
||||
// One ToolSet is created per planner/worker run; per-run signals live here.
|
||||
type ToolSet struct {
|
||||
as *db.AssetStore // asset store (optional; nil = asset tools not available)
|
||||
cs *db.CompanyStore // company store (optional)
|
||||
ts *db.ExplorationStore
|
||||
worker string
|
||||
taskID int64 // PG tasks.id; 0 when unknown (tests / orchestrator cross-task reads)
|
||||
findingRecorder FindingRecorder
|
||||
as *db.AssetStore // asset store (optional; nil = asset tools not available)
|
||||
cs *db.CompanyStore // company store (optional)
|
||||
ts *db.ExplorationStore
|
||||
worker string
|
||||
taskID int64 // PG tasks.id; 0 when unknown (tests / orchestrator cross-task reads)
|
||||
// coverageDisabled mirrors tasks.coverage_enabled=false. Stored inverted so the
|
||||
// zero value (all existing ToolSet constructions) means ENABLED — matching the
|
||||
// DB default (true). When true: graphOverviewData drops the coverage block, the
|
||||
@@ -398,7 +399,11 @@ func (t *ToolSet) graphOverviewData() map[string]any {
|
||||
for _, h := range hints {
|
||||
var p map[string]any
|
||||
_ = json.Unmarshal(h.Payload, &p)
|
||||
hsum = append(hsum, map[string]any{"id": h.ID, "state": h.State, "text": p["text"]})
|
||||
hint := map[string]any{"id": h.ID, "state": h.State, "text": p["text"]}
|
||||
if findingTrafficBindingEnabled() && p["traffic_refs"] != nil {
|
||||
hint["traffic_refs"] = p["traffic_refs"]
|
||||
}
|
||||
hsum = append(hsum, hint)
|
||||
}
|
||||
out["hints"] = hsum
|
||||
// lineage from the exploration edges: an intent's parents (what it
|
||||
@@ -1068,6 +1073,9 @@ func (t *ToolSet) listFindings() actool.CoreTool {
|
||||
obj(map[string]any{}),
|
||||
func(context.Context, json.RawMessage) (actool.Result, error) {
|
||||
f, _ := t.ts.ListByKindWithSources(db.KindFinding, 500)
|
||||
if err := t.ts.PopulateFindingTrafficIDs(f); err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
intentOf, _ := t.ts.FindingIntentsWithSources() // finding id -> 产生它的 intent id
|
||||
taskID := t.taskID
|
||||
if taskID <= 0 {
|
||||
@@ -1076,6 +1084,9 @@ func (t *ToolSet) listFindings() actool.CoreTool {
|
||||
out := make([]map[string]any, 0, len(f))
|
||||
for _, n := range f {
|
||||
m := compactFinding(n)
|
||||
if n.FindingID > 0 {
|
||||
m["finding_id"], m["finding_node_id"], m["traffic_count"] = n.FindingID, n.ID, n.TrafficCount
|
||||
}
|
||||
if n.Inherited {
|
||||
m["task_id"] = n.SourceTaskID
|
||||
} else {
|
||||
@@ -1167,7 +1178,10 @@ func (t *ToolSet) nodeDetail() actool.CoreTool {
|
||||
if n == nil {
|
||||
return actool.Errorf(fmt.Sprintf("未找到探索节点 %d。若你想查的是资产,请用 list_assets / asset_neighbors(资产与探索节点是不同的 id 空间,资产 id 不能传给 node_detail)。", id)), nil
|
||||
}
|
||||
return jsonResult(n) // full payload incl. detail / evidence
|
||||
if err := t.ts.PopulateFindingTrafficIDs([]*db.Node{n}); err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
return jsonResult(n) // full payload incl. detail / evidence, plus explicit finding IDs
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1358,67 +1372,79 @@ func (t *ToolSet) goalMet() actool.CoreTool {
|
||||
// --- worker write tools ---
|
||||
|
||||
func (t *ToolSet) addFinding() actool.CoreTool {
|
||||
return writeTool("report_finding", "[重要]发现漏洞时必须调用该工具进行记录!记录一个确认的漏洞发现。在任务上下文中 intent_id 必填(当前正在执行的意图 id);在会话上下文中 intent_id 可不填。",
|
||||
obj(map[string]any{
|
||||
"vulnclass": str("漏洞类(分类,如 SQL Injection / IDOR / XSS)"),
|
||||
"name": str("漏洞名称(具体可读的标题,如『用户中心订单接口存在越权访问』;建议填写,留空时前端回退展示 vulnclass)"),
|
||||
"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(可选,0/1/多个):参数/端点/站点等。一个漏洞影响多处可全填,纯观察可不填。"},
|
||||
"evidence": str("证据/PoC 文本"),
|
||||
}, "vulnclass", "severity", "summary"),
|
||||
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
|
||||
var a struct {
|
||||
VulnClass, Name, Severity, Summary, Evidence string
|
||||
IntentID json.RawMessage `json:"intent_id"`
|
||||
AssetIDs []json.RawMessage `json:"asset_ids"`
|
||||
return writeTool("report_finding", "记录确认的漏洞,用 evidence 提供命令输出、日志等可验证证据。任务上下文传当前 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")},
|
||||
}, "vulnclass", "severity", "summary"), func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
|
||||
var a struct {
|
||||
VulnClass, Name, Severity, Summary, Evidence string
|
||||
IntentID json.RawMessage `json:"intent_id"`
|
||||
AssetIDs []json.RawMessage `json:"asset_ids"`
|
||||
TrafficRefs []db.TrafficRef `json:"traffic_refs"`
|
||||
EvidenceHintID json.RawMessage `json:"evidence_hint_id"`
|
||||
}
|
||||
if err := json.Unmarshal(in, &a); err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
if t.ts == nil {
|
||||
return actool.Errorf("report_finding 需要任务上下文;平台对话请通过 add_task_hint 向对应任务交接漏洞,并在提示中携带已有的 traffic_refs,由任务 Agent 登记。已登记漏洞可用 bind_finding_traffic 补绑。"), nil
|
||||
}
|
||||
if !findingTrafficBindingEnabled() && (len(a.TrafficRefs) > 0 || pid(a.EvidenceHintID) > 0) {
|
||||
return actool.Errorf(findingTrafficDisabled), nil
|
||||
}
|
||||
if len(a.EvidenceHintID) > 0 && pid(a.EvidenceHintID) <= 0 {
|
||||
return actool.Errorf("evidence_hint_id 必须为有效的提示节点 ID;无交接提示时省略"), nil
|
||||
}
|
||||
refs, err := t.findingRefsFromHint(pid(a.EvidenceHintID), a.TrafficRefs)
|
||||
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)}
|
||||
var recorded *db.RecordedFinding
|
||||
if t.findingRecorder != nil {
|
||||
recorded, err = t.findingRecorder.Record(ctx, input, refs)
|
||||
} else if len(refs) > 0 {
|
||||
return actool.Errorf("流量证据存储不可用;未登记漏洞"), nil
|
||||
} else {
|
||||
recorded, err = t.ts.RecordFinding(ctx, input)
|
||||
}
|
||||
if err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
if t.notifyFinding != nil {
|
||||
iid := input.IntentID
|
||||
if iid <= 0 {
|
||||
iid = t.ownerNode
|
||||
}
|
||||
_ = json.Unmarshal(in, &a)
|
||||
payload := map[string]any{"vulnclass": a.VulnClass, "name": a.Name, "severity": a.Severity, "summary": a.Summary,
|
||||
"evidence": map[string]any{"by": t.worker, "poc": a.Evidence}}
|
||||
var anchors []int64
|
||||
for _, raw := range a.AssetIDs {
|
||||
if p := pid(raw); p > 0 {
|
||||
anchors = append(anchors, p)
|
||||
}
|
||||
t.notifyFinding(iid, a.Summary)
|
||||
} else if t.notify != nil {
|
||||
t.notify()
|
||||
}
|
||||
t.writes.Findings++
|
||||
// Keep the first line's node-ID contract for existing reporter triggers.
|
||||
for i := range recorded.Traffic.Bindings {
|
||||
recorded.Traffic.Bindings[i].Snapshot.ReqHead = ""
|
||||
recorded.Traffic.Bindings[i].Snapshot.RespHead = ""
|
||||
}
|
||||
result := struct {
|
||||
*db.RecordedFinding
|
||||
EvidenceStatus string `json:"evidence_status"`
|
||||
EvidenceNote string `json:"evidence_note,omitempty"`
|
||||
}{RecordedFinding: recorded, EvidenceStatus: "bound"}
|
||||
if len(recorded.Traffic.Bindings) == 0 {
|
||||
result.EvidenceStatus = "not_bound"
|
||||
result.EvidenceNote = "漏洞已保存,未绑定流量。TCP/无包情形可正常继续;若已有核实的 HTTP 流量,请用可用的 bind_finding_traffic 或漏洞页面补绑,再完成证据交接。不要重复创建漏洞。"
|
||||
if !findingTrafficBindingEnabled() {
|
||||
result.EvidenceNote = "漏洞已保存。Agent 自动绑定流量已关闭,可在页面人工关联流量。"
|
||||
}
|
||||
var id int64
|
||||
if t.ts != nil {
|
||||
intent := pid(a.IntentID)
|
||||
if intent > 0 {
|
||||
node, err := t.ts.GetNode(intent)
|
||||
if err != nil || node == nil || node.Kind != db.KindIntent {
|
||||
return actool.Errorf("intent_id 必须是本任务的意图(关联任务意图只读)"), nil
|
||||
}
|
||||
}
|
||||
var err error
|
||||
id, err = t.ts.AddNode(db.KindFinding, payload, 9, "confirmed", t.worker, anchors)
|
||||
if err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
if intent > 0 {
|
||||
_ = t.ts.Link(intent, db.RelYields, id) // chain: intent -> finding
|
||||
}
|
||||
_, _ = t.ts.AddStandaloneFinding(t.taskID, id, a.VulnClass, a.Name, a.Severity, a.Summary, a.Evidence, t.worker, anchors)
|
||||
// 确证漏洞落库 → 当场唤醒本任务 planner(不等 worker 收工,debounce 合并)。
|
||||
// 优先带上下文(哪个意图+finding摘要);intent 用工具参数,缺省回退到 owner 意图。
|
||||
if t.notifyFinding != nil {
|
||||
iid := pid(a.IntentID)
|
||||
if iid <= 0 {
|
||||
iid = t.ownerNode
|
||||
}
|
||||
t.notifyFinding(iid, a.Summary)
|
||||
} else if t.notify != nil {
|
||||
t.notify()
|
||||
}
|
||||
} else {
|
||||
// conversation context: no exploration store available, cannot record finding
|
||||
return actool.Errorf("report_finding 需要任务上下文(exploration store 未初始化)"), nil
|
||||
}
|
||||
t.writes.Findings++
|
||||
return actool.Text(fmt.Sprintf("finding recorded: %d", id)), nil
|
||||
})
|
||||
}
|
||||
raw, _ := json.Marshal(result)
|
||||
return actool.Text(fmt.Sprintf("finding recorded: %d\n%s", recorded.NodeID, raw)), nil
|
||||
})
|
||||
}
|
||||
|
||||
// recordFact writes a general exploration RESULT/conclusion (not a vuln, not a
|
||||
@@ -1530,12 +1556,16 @@ func (t *ToolSet) recordFact() actool.CoreTool {
|
||||
}
|
||||
|
||||
type hintItem struct {
|
||||
Text string `json:"text"`
|
||||
AssetIDs []json.RawMessage `json:"asset_ids"`
|
||||
Text string `json:"text"`
|
||||
AssetIDs []json.RawMessage `json:"asset_ids"`
|
||||
TrafficRefs []db.TrafficRef `json:"traffic_refs"`
|
||||
}
|
||||
|
||||
// addOneHint 挂一条 hint 节点(active/human)到探索图,可锚定资产,返回 id。
|
||||
func (t *ToolSet) addOneHint(it hintItem) (int64, error) {
|
||||
if len(it.TrafficRefs) > 0 && !findingTrafficBindingEnabled() {
|
||||
return 0, fmt.Errorf("Agent 自动绑定流量已关闭,未保存携带 traffic_refs 的提示;可在系统设置开启,或仅交接文字")
|
||||
}
|
||||
if strings.TrimSpace(it.Text) == "" {
|
||||
return 0, fmt.Errorf("text 不能为空")
|
||||
}
|
||||
@@ -1545,7 +1575,15 @@ func (t *ToolSet) addOneHint(it hintItem) (int64, error) {
|
||||
anchors = append(anchors, tid)
|
||||
}
|
||||
}
|
||||
id, err := t.ts.AddNode(db.KindHint, map[string]any{"text": it.Text}, 0, "active", "human", anchors)
|
||||
refs, err := db.NormalizeTrafficRefs(it.TrafficRefs)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
payload := map[string]any{"text": it.Text}
|
||||
if len(refs) > 0 {
|
||||
payload["traffic_refs"] = refs
|
||||
}
|
||||
id, err := t.ts.AddNode(db.KindHint, payload, 0, "active", "human", anchors)
|
||||
if err == nil && t.notify != nil {
|
||||
t.notify() // wake the planner so the new hint is read promptly (debounced)
|
||||
}
|
||||
@@ -1731,9 +1769,10 @@ func (t *ToolSet) addHint() actool.CoreTool {
|
||||
return writeTool("add_hint", "把人类/主 agent 的战略提示挂到探索图,规划者下次生成意图时会读到它。\n"+
|
||||
"★优先批量:多条提示放进 hints 数组一次提交(比逐条调用省往返)。返回 ids 数组,与 hints 等长同序(失败项 id=0,详情见 errors)。单条则省略 hints 直接给顶层 text。",
|
||||
obj(map[string]any{
|
||||
"hints": map[string]any{"type": "array", "description": "【优先用这个】要新增的提示数组,按顺序处理。每个元素字段同下方顶层字段(text/asset_ids)。返回 ids 与本数组等长、同序。", "items": map[string]any{"type": "object"}},
|
||||
"text": str("[单条] 提示内容,如'重点挖认证后接口'"),
|
||||
"asset_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, "description": "锚定的资产 id(可选,0/1/多个)"},
|
||||
"hints": map[string]any{"type": "array", "description": "【优先用这个】要新增的提示数组,按顺序处理。每个元素字段同下方顶层字段(text/asset_ids/traffic_refs)。返回 ids 与本数组等长、同序。", "items": obj(map[string]any{"text": str("提示内容"), "asset_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}}, "traffic_refs": HintTrafficSchema()})},
|
||||
"text": str("[单条] 提示内容,如'重点挖认证后接口'"),
|
||||
"traffic_refs": HintTrafficSchema(),
|
||||
"asset_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, "description": "锚定的资产 id(可选,0/1/多个)"},
|
||||
}),
|
||||
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
|
||||
var a struct {
|
||||
|
||||
+17
-12
@@ -41,17 +41,18 @@ type WebSearchOpts struct {
|
||||
}
|
||||
|
||||
type Worker struct {
|
||||
prov llm.Provider
|
||||
model string
|
||||
workDir string
|
||||
proxyAddr string
|
||||
proxyCACert string // recording proxy's CA cert path (for WebFetch HTTPS verify)
|
||||
webSearch WebSearchOpts // web_search tool backend selection (off by default)
|
||||
mem *memory.Store // cross-engagement tradecraft memory (G4)
|
||||
tx *transcript.Store // raw LLM conversation persistence (nil = off)
|
||||
window int // context window in tokens (for compaction)
|
||||
windowFn func() int // optional dynamic task-chain minimum
|
||||
maxTurns int // max agent turns per run (0 = unlimited)
|
||||
findingRecorder FindingRecorder
|
||||
prov llm.Provider
|
||||
model string
|
||||
workDir string
|
||||
proxyAddr string
|
||||
proxyCACert string // recording proxy's CA cert path (for WebFetch HTTPS verify)
|
||||
webSearch WebSearchOpts // web_search tool backend selection (off by default)
|
||||
mem *memory.Store // cross-engagement tradecraft memory (G4)
|
||||
tx *transcript.Store // raw LLM conversation persistence (nil = off)
|
||||
window int // context window in tokens (for compaction)
|
||||
windowFn func() int // optional dynamic task-chain minimum
|
||||
maxTurns int // max agent turns per run (0 = unlimited)
|
||||
// runTimeout is the wall-clock budget for the main exploration of one intent
|
||||
// (0 = unlimited). When it fires, the run is cut and a settlement round is
|
||||
// forced so already-identified facts get written back instead of being lost.
|
||||
@@ -204,7 +205,9 @@ const workerDefaultTmpl = `你是一个 ARTEX 平台授权渗透测试系统的"
|
||||
**边发现边写回**(写进图才算数,脑子/文字里的不算;每得一个结果立刻写,别攒到最后被步数耗尽丢掉)。三种写回,别串图:
|
||||
- **新资产/资源 → insert_assets(资产图)**:子域 / service / endpoint / 指纹 / 凭据 等一切资产【本身】。**这里只登记资产;探索结论/判断不写这里,用 record_fact。**
|
||||
- **探索结论/事实 → record_fact(探索图,传 intent_id)**:都用它。**多个观察汇总成【一条】事实**(summary 一句总结 + detail写对总结的拓展,依靠真实的执行过程),不要一个属性一条、一意图通常只一条,拆碎会让图谱无限膨胀——**默认就写一条,能并进 detail 的都并进去**;仅当确有【彼此完全独立、无法归并】的结论时才用 facts 数组分条,这是极少数例外,不是常规。**只写增量**:只记这次【新得到】的,别把已有事实换措辞重记(只印证已有、无新增就不必记)。**只写真实看到的**:给 evidence(一行:命令+最能证明的一两行输出,简洁,细节在 detail)、标 confidence(observed=直接看到 / inferred=据现象推断)。
|
||||
- **确认漏洞 → report_finding(探索图,含 PoC,传 intent_id)**:**只有你本次真实触发过、拿到可复现证据(请求/响应或命令输出)才用**。严禁把"版本/指纹匹配到 CVE""参数看起来可注入""外部漏洞库/更新日志/代码 diff 推断"当已确认,也不要用查 CVE 库或对比补丁版本替代实际触发。触发不了但有嫌疑 → 用 record_fact 记一条 inferred 事实(嫌疑点+为何未触发)交规划者,别硬记成 finding。
|
||||
- **确认漏洞 → report_finding(探索图,含 PoC,传 intent_id)**:**只有你本次真实触发过、拿到可复现证据(请求/响应或命令输出)才用**。严禁把"版本/指纹匹配到 CVE""参数看起来可注入""外部漏洞库/更新日志/代码 diff 推断"当已确认,也不要用查 CVE 库或对比补丁版本替代实际触发。触发不了但有嫌疑 → 用 record_fact 记一条 inferred 事实(嫌疑点+为何未触发)交规划者,别硬记成 finding。有对应录制流量时,先用 traffic_search / traffic_get 核对真实 ID,再用 traffic_refs 按复现顺序绑定多条请求/响应;域名和时间只用于候选筛选,不推定任务归属。
|
||||
|
||||
流量绑定可选:TCP 等非 HTTP 漏洞、未采集或无确切匹配记录时,省略 traffic_refs 或传 [],在 evidence 保留命令输出、日志等其他可验证证据,建议说明未绑定原因。不要猜测 ID,也不要仅为补包重复探测。
|
||||
|
||||
完成本意图后用一句话总结你做了什么、写回了哪些事实。务实、克制、聚焦这一条意图。`
|
||||
|
||||
@@ -255,6 +258,7 @@ func workerSystem(proxyAddr, caCert, dataDir, runDir string) string {
|
||||
body := renderSystem("worker", workerDefaultTmpl, WorkerVars{ProxyAddr: proxyAddr, DataDir: dataDir, Now: nowStr()})
|
||||
// caCert is present only when the recording MITM is on, which is exactly when
|
||||
// the traffic_* tools are registered — so it gates the traffic-tool note.
|
||||
// Optional finding guidance is added for every role after tool resolution.
|
||||
return body + workerTrafficBlock(caCert != "") + workerArtifactSpec(runDir)
|
||||
}
|
||||
|
||||
@@ -325,6 +329,7 @@ func (w *Worker) ExecuteWithMessage(ctx context.Context, name string, taskID int
|
||||
|
||||
func (w *Worker) execute(ctx context.Context, name string, taskID int64, as *db.AssetStore, ts *db.ExplorationStore, intent *db.Node, hooks harness.HookRunner, emit func(db.Activity), enr EnrichTrigger, notifyFinding func(int64, string), requestID, message string) (harness.TerminalReason, WriteCounts, error) {
|
||||
tsx := NewToolSet(ts, name)
|
||||
tsx.SetFindingRecorder(w.findingRecorder)
|
||||
tsx.SetTaskID(taskID)
|
||||
coverageEnabled := as == nil || as.CoverageEnabled(taskID)
|
||||
tsx.SetCoverageEnabled(coverageEnabled)
|
||||
|
||||
Reference in new issue
Block a user