From 6cad678699bf24fd027286452603d433e53ef924 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:19:14 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A2=84=E7=BD=AE=E3=80=8C=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E6=92=B0=E5=86=99=E3=80=8D=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=20agent,=E5=8F=91=E7=8E=B0=E6=BC=8F=E6=B4=9E=E6=97=B6=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=86=99=E8=AF=A6=E7=BB=86=E6=8A=A5=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 host 工具 update_finding_report(finding_id, report):按 node_id 写 findings.report(任务无关,无需 task 上下文);get_task_node_detail(task_id, id) 读取漏洞完整证据/PoC。均注册进 orchestrationTools。 - promptcatalog 新增 ReporterDefaultPrompt:详述报告应含的结构(概述/影响/ 受影响范围/复现步骤/证据PoC/根因/修复建议)与只据实证撰写的纪律。 - seedReporterAgent(一次性,flag 守卫):预置 builtin=false 的自定义 agent 「报告撰写」(reporter),UI 可编辑/删除;绑定 update_finding_report + 任务 查询工具(node_detail/worker_trace/findings/graph);建 on_tool_call 触发器 (tool_names=[report_finding]) —— 每登记漏洞即唤起它写报告并回写。 - 移除上一提交里绑到 worker/planner/mainagent 的 in-task update_finding_report 工具及 ExplorationStore 包装(触发态是会话上下文,须用 host 工具);保留 db.SetFindingReportByNodeID;新增 ToolSet.NodeDetailTool 访问器。 go build / agent / server 测试通过。reporter 为自定义 agent,不影响内置 agent 计数。 --- agent/promptcatalog.go | 35 ++++++++++++++ agent/tools.go | 36 +------------- agent/tools_insert.go | 4 +- db/exploration.go | 6 --- server/orchestration.go | 105 ++++++++++++++++++++++++++++++++++++++-- 5 files changed, 139 insertions(+), 47 deletions(-) diff --git a/agent/promptcatalog.go b/agent/promptcatalog.go index 308e813..d25e72c 100644 --- a/agent/promptcatalog.go +++ b/agent/promptcatalog.go @@ -67,6 +67,41 @@ const pentestDefaultTmpl = `你是一个授权渗透测试系统的"独立渗透 // render fallback in RunChat when the DB prompt is somehow missing. const DefaultAssistantPrompt = `你是一个乐于助人的 AI 助手。请用简洁、准确的中文回答用户的问题;在需要时使用可用的工具来完成任务。只做用户要求的事,不臆造信息。` +// ReporterDefaultPrompt is the seeded prompt for the "报告撰写"(reporter) custom +// agent — triggered when report_finding fires. It gathers the finding's full +// evidence + how it was found, writes a Markdown vulnerability report, and saves +// it via update_finding_report. +const ReporterDefaultPrompt = `你是一个授权渗透测试系统里的**漏洞报告撰写 agent**。你不亲自渗透、不做利用——你的唯一职责是:为**刚刚被确认登记的某一个漏洞**撰写一份专业、可复现、面向修复的**详细报告(Markdown)**,并保存回该漏洞。 + +━━ 你是怎么被唤起的 ━━ +每当有 worker 调用 report_finding 登记了一个漏洞,系统就会用一段【由工具调用触发】的上下文唤起你,其中包含: +- **任务 id**(task_id,见上下文"任务: #") +- report_finding 的**入参**(vulnclass / severity / summary / evidence 等) +- report_finding 的**返回**:形如 "finding recorded: " —— 这个 ** 就是本漏洞的 finding_id**,是你后面保存报告的句柄。 + +先从上下文里**准确抽取 finding_id 和 task_id**。抽取不到 finding_id 就不要瞎写,说明情况即可。 + +━━ 工作步骤 ━━ +1. **取全证据**:用 get_task_node_detail(task_id, 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,按需裁剪,但证据/复现/修复必须有)━━ +- ` + "`## 概述`" + `:一句话说清是什么漏洞、在哪、能造成什么。 +- ` + "`## 影响与危害`" + `:结合业务讲清最坏后果(数据泄露/接管/RCE/横向…),给出**严重等级**判断及理由。 +- ` + "`## 受影响范围`" + `:受影响的资产/接口/参数/版本。 +- ` + "`## 复现步骤`" + `:**可照做复现**的分步操作(请求/命令/参数),能贴 PoC 就贴。 +- ` + "`## 证据 / PoC`" + `:关键请求响应片段、命令输出、回显——用代码块。 +- ` + "`## 根因分析`" + `:为什么会有这个漏洞(缺校验/危险函数/配置错误…)。 +- ` + "`## 修复建议`" + `:具体、可落地的整改措施(不是空话),可含加固与长期建议。 + +━━ 纪律 ━━ +- **只基于真实证据**:报告里的每一条都要能从 finding 证据或 work 执行过程里找到支撑;**绝不臆造**请求、响应、CVE 或结论。证据不足的地方如实标注"未验证/需进一步确认"。 +- **面向修复、可核验**:复现步骤要能照做,修复建议要能落地。 +- **精炼**:不写套话废话、不复述模板本身。 +- 全程**中文**。做完(已成功调用 update_finding_report)就结束,用一两句话说明你为哪个漏洞写了报告即可。` + // BuiltinPromptSeeds returns each built-in agent's default EDITABLE prompt body // keyed by agent key. The server seeds these into agent_prompts on startup (only // when an agent has no prompt yet), so the DB becomes the authoritative, editable diff --git a/agent/tools.go b/agent/tools.go index f6adf62..93b71b7 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -156,6 +156,7 @@ func (t *ToolSet) ListFindingsTool() actool.CoreTool { return t.listFindin func (t *ToolSet) GetWorkerTraceTool() actool.CoreTool { return t.getWorkerTrace() } func (t *ToolSet) ListWorkerTracesTool() actool.CoreTool { return t.listWorkerTraces() } func (t *ToolSet) SearchWorkerTracesTool() actool.CoreTool { return t.searchAllWorkerTraces() } +func (t *ToolSet) NodeDetailTool() actool.CoreTool { return t.nodeDetail() } func (t *ToolSet) AddHintTool() actool.CoreTool { return t.addHint() } // SetEnrich wires the async enrichment engine (DNS/HTTP auto-completion). @@ -696,39 +697,6 @@ func (t *ToolSet) addFinding() actool.CoreTool { }) } -// updateFindingReport sets/overwrites a finding's detailed Markdown report -// (findings.report), addressed by the id report_finding returned. -func (t *ToolSet) updateFindingReport() actool.CoreTool { - return writeTool("update_finding_report", - "为已登记的漏洞补充/更新【详细报告】。report 传 Markdown 全文(建议含:漏洞概述、影响、复现步骤、证据/PoC、修复建议)。finding_id 传 report_finding 返回的那个 id。整段覆盖该漏洞已有的报告。", - obj(map[string]any{ - "finding_id": idp("目标漏洞 id(report_finding 返回的 id)"), - "report": str("详细报告全文,Markdown 格式"), - }, "finding_id", "report"), - func(_ context.Context, in json.RawMessage) (actool.Result, error) { - var a struct { - FindingID json.RawMessage `json:"finding_id"` - Report string `json:"report"` - } - _ = json.Unmarshal(in, &a) - nodeID := pid(a.FindingID) - if nodeID <= 0 { - return actool.Errorf("finding_id 无效"), nil - } - if t.ts == nil { - return actool.Errorf("update_finding_report 需要任务上下文(exploration store 未初始化)"), nil - } - n, err := t.ts.SetFindingReport(nodeID, a.Report) - if err != nil { - return actool.Errorf(err.Error()), nil - } - if n == 0 { - return actool.Errorf(fmt.Sprintf("未找到 finding_id=%d 对应的漏洞记录(先用 report_finding 登记)", nodeID)), nil - } - return actool.Text(fmt.Sprintf("finding %d report updated (%d chars)", nodeID, len(a.Report))), nil - }) -} - // recordFact writes a general exploration RESULT/conclusion (not a vuln, not a // new asset) into the EXPLORATION graph, chained to the intent that produced it. // This is the home for observations and — importantly — negative results @@ -1283,7 +1251,7 @@ func (t *ToolSet) PlannerTools() []actool.CoreTool { t.getWorkerOutput(), t.getWorkerTrace(), t.searchAllWorkerTraces(), t.listGoals(), t.addIntent(), t.proveGoal(), t.goalMet(), t.killWorkTool(), t.steerWorkTool(), // report_finding:规划态势研判时若自身已确证漏洞,可直接登记(与 worker 同工具)。 - t.addFinding(), t.updateFindingReport(), + t.addFinding(), // list_companies:查看企业列表 + scope + 资产数(拿 company_id / 理解归属范围)。 t.listCompanies(), // add_task_scope:主动把整根域/整公司/某子域/IP 纳入本任务测试范围(覆盖度分母)。 diff --git a/agent/tools_insert.go b/agent/tools_insert.go index a0109e7..c808988 100644 --- a/agent/tools_insert.go +++ b/agent/tools_insert.go @@ -582,7 +582,7 @@ func splitLines(s string) []string { func (t *ToolSet) WorkerTools() []actool.CoreTool { return []actool.CoreTool{ t.listFindings(), t.listFacts(), t.nodeDetail(), - t.addFinding(), t.updateFindingReport(), t.recordFact(), + t.addFinding(), t.recordFact(), // cross-work retrieval t.searchAllWorkerTraces(), t.listWorkerTraces(), t.getWorkerTrace(), // asset management (handlers guard nil store internally) @@ -599,7 +599,7 @@ func (t *ToolSet) MainAgentTools() []actool.CoreTool { t.setGoals(), // asset management (handlers guard nil store internally) t.insertAssets(), t.addCompanyScope(), t.listAssets(), - t.addFinding(), t.updateFindingReport(), t.recordFact(), + t.addFinding(), t.recordFact(), t.addTaskScope(), // list_untested_assets:按需查本任务范围内未测资产(类型+分页),自行决定补测。 t.listUntestedAssets(), diff --git a/db/exploration.go b/db/exploration.go index c3f70a5..00f6024 100644 --- a/db/exploration.go +++ b/db/exploration.go @@ -889,9 +889,3 @@ FROM activity WHERE exploration_id=$1 AND kind<>'thinking' AND id IN (`+strings. 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) } - -// SetFindingReport sets a finding's Markdown detail report, addressed by its -// finding node id (the id report_finding returns). Returns rows affected. -func (s *ExplorationStore) SetFindingReport(nodeID int64, report string) (int64, error) { - return s.db.SetFindingReportByNodeID(nodeID, report) -} diff --git a/server/orchestration.go b/server/orchestration.go index d3c7f76..bbd5dad 100644 --- a/server/orchestration.go +++ b/server/orchestration.go @@ -10,6 +10,7 @@ import ( "time" "github.com/Autumn-27/artex/agent" + "github.com/Autumn-27/artex/db" "github.com/Autumn-27/norma/permission" actool "github.com/Autumn-27/norma/tool" ) @@ -72,6 +73,8 @@ func (s *Server) orchestrationTools() []actool.CoreTool { s.toolGetWorkerTrace(), s.toolListWorkerTraces(), s.toolSearchWorkerTraces(), + s.toolGetTaskNodeDetail(), + s.toolUpdateFindingReport(), } } @@ -234,11 +237,11 @@ func (s *Server) toolSpawnTask() actool.CoreTool { return wrTool("spawn_task", "新建一个子任务并启动探索引擎,返回 task_id。用于把一件事(如一道题/一个目标)派成独立任务。parent_ref 可选:填当前编排关联的父任务 id 做父子关联。", objSchema(map[string]any{ - "description": strParam("任务描述(简短标题)"), - "goal": strParam("任务目标(要达成什么)"), - "parent_ref": strParam("可选:父任务 id(做父子关联)"), - "llm_profile_id": map[string]any{"type": "integer", "description": "可选:指定本子任务 planner/worker 用的 LLM 配置 id(见 list_llm_profiles);留空则继承父任务、再回退全局激活配置"}, - "timeout_seconds": map[string]any{"type": "integer", "description": "可选:任务级超时(秒)。到点后触发优雅收尾并进入 timeout 终态;留空或 0 = 不限时"}, + "description": strParam("任务描述(简短标题)"), + "goal": strParam("任务目标(要达成什么)"), + "parent_ref": strParam("可选:父任务 id(做父子关联)"), + "llm_profile_id": map[string]any{"type": "integer", "description": "可选:指定本子任务 planner/worker 用的 LLM 配置 id(见 list_llm_profiles);留空则继承父任务、再回退全局激活配置"}, + "timeout_seconds": map[string]any{"type": "integer", "description": "可选:任务级超时(秒)。到点后触发优雅收尾并进入 timeout 终态;留空或 0 = 不限时"}, "plan_heartbeat_seconds": map[string]any{"type": "integer", "description": "可选:planner 心跳触发间隔(秒)。距上轮规划结束/任务开始满该值且期间无触发 → 触发一轮规划(兜底死锁 + 唤醒去监督飞行中的 worker)。留空或 0 = 默认 600(10min);"}, "seed_first_intent": map[string]any{"type": "boolean", "description": "可选:对于简单任务可开启,创建时直接下发一条种子意图(内容=描述+目标)让 worker 免等首轮 planner 直接开跑测试;默认 false(走标准先规划再执行)。"}, }, "description", "goal"), @@ -365,6 +368,50 @@ func (s *Server) toolSearchWorkerTraces() actool.CoreTool { }) } +func (s *Server) toolGetTaskNodeDetail() actool.CoreTool { + return roTool("get_task_node_detail", + "读指定任务里某个探索图节点的完整内容(发现/事实/意图/目标:摘要 + 详情/证据/PoC)。id 为探索节点 id(如 report_finding 返回、或 list_task_findings 里的 id)。写漏洞报告前用它取该漏洞的完整证据。", + objSchema(map[string]any{ + "task_id": strParam("任务 id"), + "id": map[string]any{"type": "integer", "description": "探索图节点 id(非资产 id)"}, + }, "task_id", "id"), + func(ctx context.Context, in json.RawMessage) (actool.Result, error) { + return s.delegateToTask(ctx, in, (*agent.ToolSet).NodeDetailTool) + }) +} + +// toolUpdateFindingReport writes/overwrites a finding's detailed Markdown report. +// finding_id is the id report_finding returned ("finding recorded: ", the +// finding node id). The write (SetFindingReportByNodeID) is keyed by node_id and +// task-agnostic, so this host tool needs no task_id / exploration store. +func (s *Server) toolUpdateFindingReport() actool.CoreTool { + return wrTool("update_finding_report", + "为已登记的漏洞写入/更新【详细报告】(Markdown 全文,整段覆盖旧内容)。finding_id 传 report_finding 返回的那个 id(\"finding recorded: \" 里的数字)。报告建议包含:漏洞概述、影响与危害、复现步骤、证据/PoC、修复建议。", + objSchema(map[string]any{ + "finding_id": map[string]any{"type": "integer", "description": "目标漏洞 id(report_finding 返回的 id)"}, + "report": strParam("详细报告全文,Markdown 格式"), + }, "finding_id", "report"), + func(_ context.Context, in json.RawMessage) (actool.Result, error) { + var a struct { + FindingID json.RawMessage `json:"finding_id"` + Report string `json:"report"` + } + _ = json.Unmarshal(in, &a) + nodeID := parseProfileID(a.FindingID) // 复用「数字或数字字符串」解析 + if nodeID <= 0 { + return actool.Errorf("finding_id 无效"), nil + } + n, err := s.m.pg.SetFindingReportByNodeID(nodeID, a.Report) + if err != nil { + return actool.Errorf(err.Error()), nil + } + if n == 0 { + return actool.Errorf(fmt.Sprintf("未找到 finding_id=%d 对应的漏洞记录(先用 report_finding 登记)", nodeID)), nil + } + return actool.Text(fmt.Sprintf("finding %d report updated (%d chars)", nodeID, len(a.Report))), nil + }) +} + // deriveTaskStatus mirrors listTasks' status derivation for the list_tasks tool. func (s *Server) deriveTaskStatus(t *Task) string { switch { @@ -398,6 +445,7 @@ func (s *Server) seedOrchestrationTools() { s.seedPlannerDefaultBindings() s.seedAutoReportFindingBinding() s.unbindGoalMetDefault() + s.seedReporterAgent() // 预置「报告撰写」agent + 工具绑定 + finding 触发器(一次性) // 注:pentest 的默认工具绑定无需迁移——BuiltinToolSeeds 在全新初始化时就把 // list_assets/insert_assets/report_finding/list_findings/list_companies 连同 // pentest 一起 seed 好了(项目尚无旧库,不做迁移)。 @@ -452,6 +500,53 @@ func (s *Server) unbindGoalMetDefault() { _ = s.m.pg.SetSetting(flag, "true") } +// seedReporterAgent 预置一个「报告撰写」自定义 agent(builtin=false,可在 UI 编辑/删除): +// 绑定 update_finding_report + 任务查询工具,并挂一个「report_finding 被调用即触发」的 +// 触发器 —— 每登记一个漏洞就唤起它写详细报告。一次性(settings flag 守卫):用户删掉后不再重建。 +// 依赖:orchestration 工具已在本函数上方 SeedTool 入库,故绑定得上。 +func (s *Server) seedReporterAgent() { + const flag = "reporter_agent_seed_v1" + if v, _, _ := s.m.pg.GetSetting(flag); v == "true" { + return + } + defer func() { _ = s.m.pg.SetSetting(flag, "true") }() // 无论成功与否只尝试一次 + + if exist, _ := s.m.pg.GetAgentByKey("reporter"); exist != nil { + return // key 已被占用(用户手建过)——不覆盖 + } + a, err := s.m.pg.CreateAgent("reporter", "报告撰写", + "漏洞详细报告撰写:发现漏洞时自动触发,查取证据与执行过程后写 Markdown 报告并回写。") + if err != nil { + log.Printf("[reporter] 创建 agent 失败: %v", err) + return + } + if err := s.m.pg.SeedPromptIfEmpty(a.ID, agent.ReporterDefaultPrompt); err != nil { + log.Printf("[reporter] seed prompt 失败: %v", err) + } + // 绑定它需要的工具:写报告 + 读证据/执行过程/态势。 + if err := s.m.pg.AddAgentToToolBinding("reporter", []string{ + "update_finding_report", "get_task_node_detail", "list_task_findings", + "get_task_worker_trace", "list_task_worker_traces", "search_task_worker_traces", + "get_task_graph", + }); err != nil { + log.Printf("[reporter] 绑定工具失败: %v", err) + } + // 触发器:report_finding 被调用即触发(工具返回 "finding recorded: " 带上 finding_id, + // 任务 id 也在触发消息里)。 + if _, err := s.m.pg.CreateTrigger(&db.AgentTrigger{ + AgentKey: "reporter", + Enabled: true, + OnToolCall: true, + ToolNames: []string{"report_finding"}, + ToolCallMessage: "上面刚有一个漏洞被 report_finding 登记。请从触发上下文里取出 finding_id" + + "(工具返回 \"finding recorded: \" 里的数字)与任务 id,按你的职责撰写该漏洞的详细报告," + + "最后调用 update_finding_report(finding_id, report) 保存。", + }); err != nil { + log.Printf("[reporter] 创建触发器失败: %v", err) + } + log.Printf("[reporter] 已预置「报告撰写」agent + finding 触发器") +} + // seedAutoReportFindingBinding adds "auto" to report_finding's binding ONCE so // conversation-context agents can call it without requiring an intent_id. func (s *Server) seedAutoReportFindingBinding() {