154 lines
8.7 KiB
Go
154 lines
8.7 KiB
Go
package agent
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"strings"
|
||
|
||
"github.com/Autumn-27/artex/db"
|
||
"github.com/Autumn-27/norma/agentcore"
|
||
acperm "github.com/Autumn-27/norma/permission"
|
||
actool "github.com/Autumn-27/norma/tool"
|
||
)
|
||
|
||
// goalsDefaultTmpl is the built-in EDITABLE body (段 [A]) of the goals-decomposer
|
||
// prompt, seeded into agent_prompts. No template vars are used today.
|
||
const goalsDefaultTmpl = `你是渗透测试目标分解器。你的职责是从用户输入中识别出**最终要达成的结果**,而不是规划攻击步骤。
|
||
|
||
**第一步(拆分目标之前先做):抽取操作约束**
|
||
从「任务目标 / 任务描述」里识别操作员对【可以做什么、不可以做什么操作】的明确规定,调用 set_constraints 逐条登记(如果描述、目标中不涉及操作约束可以不进行提取操作约束):
|
||
- type=deny:禁止的操作(如「不扫端口」「不得对生产环境做写/删操作」「禁止爆破」「不碰某子域」)。
|
||
- type=allow:明确允许/限定的操作范围(如「只允许被动侦察」「仅针对某域名」)。
|
||
- 约束 ≠ 目标,也 ≠ 攻击步骤:它是对操作行为边界的规定。
|
||
- **约束必须【自包含、写死具体目标】**:把「当前目标/当前端口/当前IP/当前域名/本站」这类**指代词**替换成任务目标/描述里的**具体值**。约束会被单独注入到执行阶段的提示里,脱离上下文后指代词无法判断指谁。
|
||
例:目标是 https://abc.example.net → 写「只允许测试 abc.example.net」而不是「只允许测试当前目标」;「仅测目标端口 443,不扫其他端口」而不是「只测当前端口」。若原文只说「当前目标」但目标地址已明确,就把地址填进去。
|
||
- **只登记目标/描述里【明确写出或强调】的约束,严禁臆造**;拿不准类型时用 deny(更保守)。
|
||
- 若目标/描述里确实没有任何操作约束,则**不要**调用 set_constraints。
|
||
登记完约束(如有)后,再进行下面的目标拆分。
|
||
|
||
**目标 = 最终可交付/可核验的结果**
|
||
|
||
**不是目标的内容(禁止列为子目标)**:
|
||
- 信息收集、侦察、端点扫描
|
||
- 漏洞分析与验证过程
|
||
- 攻击步骤、利用手段
|
||
- 结果验证步骤
|
||
|
||
**拆分原则**:
|
||
- 用户描述的最终目标只有一个 → 输出一个
|
||
- 存在多个**相互独立**的最终交付物 → 分别列出
|
||
- 能对应明确漏洞类的标注 vulnclass;信息收集/业务逻辑类目标留空
|
||
- 严禁臆造用户未提及的目标
|
||
|
||
调用 set_goals 提交结果。`
|
||
|
||
// goalsScopeTail is the code-owned tail appended after the editable goals body
|
||
// WHEN an asset store + task context are available. It teaches the decomposer to
|
||
// also lift the explicit asset scope out of the goal/description and register it
|
||
// via add_task_scope. Kept in code (not the DB-editable body) so it always applies
|
||
// on released DBs and can't be edited away — same pattern as the trafficTool tail.
|
||
const goalsScopeTail = `
|
||
|
||
**额外职责:登记测试资产范围**
|
||
除拆分目标外,你还要从「任务目标 / 任务描述」里识别出**明确给出的测试资产范围**,调用 add_task_scope 登记(资产测试覆盖度的分母,也是授权边界)。**最小范围原则:只登记用户明确点到的那一个目标,绝不擅自放大。**
|
||
- 目标是 URL 或带主机名的地址(如 https://xxx.example.com/path、app.example.com)→ 取其**完整主机名**,kind=subdomain,value=完整主机名。
|
||
例:目标 https://a1b2c3.lab.example.net/path → kind=subdomain,value=a1b2c3.lab.example.net(**不是** example.net)。
|
||
**严禁**把带子域的主机名缩成根域名——看到 xxx.example.com 就登记整个 example.com 会把范围扩到用户目标之外,违背最小范围原则。
|
||
- 仅当用户给的就是**裸根域名、且不含任何子域**(如直接写 example.com),或明确说“整个站点 / 所有子域 / 全域名” → 才用 kind=root_domain,value=example.com。
|
||
- 纯 IP 或网段 → kind=ip / cidr,value=IP 或 CIDR。
|
||
- **不要**登记公司范围(company)——任务刚建立、资产系统里通常还没有这家公司,登记不上,公司级范围交由后续 plan 阶段处理。
|
||
其它规则:
|
||
- 只登记**目标/描述里明确写出**的范围;严禁臆造或推断未提及的域名/IP。
|
||
- reason 简述依据来自哪句话,便于审计。
|
||
- 若目标/描述中没有任何明确资产范围,则**不要**调用 add_task_scope。
|
||
先用 add_task_scope 登记范围(如有),再调用 set_goals 提交目标。`
|
||
|
||
// GoalSpec is one decomposed objective.
|
||
type GoalSpec struct {
|
||
Text string `json:"text"`
|
||
VulnClass string `json:"vulnclass,omitempty"`
|
||
}
|
||
|
||
// DecomposeGoals asks the LLM to break a pentest task goal into discrete,
|
||
// independently-verifiable objectives (each becomes a goal node). Returns nil if
|
||
// no provider is configured or the call yields nothing — the caller then falls
|
||
// back to a rule-based split so goal nodes always exist.
|
||
//
|
||
// desc is the task's free-text description (背景:靶标范围/flag 数量/交战说明等).
|
||
// It is fed alongside the goal so the decomposer no longer splits blind — the
|
||
// prompt still forbids inventing anything the two texts don't state.
|
||
//
|
||
// emit, when non-nil, receives every LLM step (thinking/tool_use/result) with
|
||
// Worker="planner" so the round-0 goal-decomposition activity is visible in the UI.
|
||
//
|
||
// as + taskID, when non-nil/positive, wire the add_task_scope tool so the
|
||
// decomposer can register the explicit asset scope it extracts from the goal.
|
||
//
|
||
// ts is the task's exploration store: set_goals writes the decomposed goal nodes
|
||
// straight into it (the same managed tool the main agent uses to add goals at
|
||
// runtime). The returned specs are read back from the store so callers can emit
|
||
// per-goal activity and detect the "LLM produced nothing" case for their fallback.
|
||
func DecomposeGoals(ctx context.Context, c Config, dataDir, goalText, desc string, as *db.AssetStore, ts *db.ExplorationStore, taskID int64, emit func(db.Activity)) []GoalSpec {
|
||
prov, err := c.NewProvider()
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
// worker="goals" tags the goal nodes' provenance; ts/taskID let set_goals link
|
||
// each goal under the task root. This is the catalog's real set_goals tool, so a
|
||
// web-edited description/schema on it applies here too.
|
||
tsx := &ToolSet{as: as, ts: ts, taskID: taskID, worker: "goals"}
|
||
// Description rides in the user message (same channel as the goal), NOT via the
|
||
// {{.EngagementDescription}} template var — else a prompt that references the var
|
||
// would inject the description twice. System prompt stays pure static instructions.
|
||
sys := renderSystem("goals", goalsDefaultTmpl, GoalsVars{DataDir: dataDir, Now: nowStr()})
|
||
// set_constraints 始终可用(不依赖 asset store):正文已含「先抽操作约束再拆目标」这步
|
||
// (可在 agent 编辑页改措辞),这里只需接上工具。
|
||
tools := []actool.CoreTool{tsx.setGoals(), tsx.setConstraints()}
|
||
// Wire add_task_scope only when we have a real asset store + task to write to.
|
||
// The scope-extraction tail is appended in lockstep so the prompt never asks for
|
||
// a tool that isn't present.
|
||
if as != nil && taskID > 0 {
|
||
tools = append(tools, tsx.addTaskScope())
|
||
sys += goalsScopeTail
|
||
}
|
||
userMsg := "任务目标:\n" + goalText
|
||
if d := strings.TrimSpace(desc); d != "" {
|
||
userMsg += "\n\n任务描述(背景信息,可能含靶标范围/flag 数量/交战说明;仅供参考,不要臆造其中未提及的内容):\n" + d
|
||
}
|
||
// Use captureRun so every LLM step is emitted as an activity record (visible in
|
||
// the plan tab under the round-0 marker). Falls back gracefully when emit is nil.
|
||
captureEmit := func(r db.Activity) {
|
||
if emit != nil {
|
||
r.Worker = "planner"
|
||
emit(r)
|
||
}
|
||
}
|
||
captureRun(ctx, agentcore.Options{
|
||
Provider: prov,
|
||
SystemPrompt: []string{sys},
|
||
Tools: tools,
|
||
PermissionMode: acperm.ModeBypass,
|
||
DisableBackgroundTasks: true,
|
||
// 3 步(抽约束 → 登记范围 → 拆目标)各需一次工具调用,给足回合避免收尾前漏调 set_goals。
|
||
MaxTurns: 8,
|
||
}, userMsg, captureEmit)
|
||
// set_goals persisted the goals directly; read them back so the caller sees what
|
||
// was written (empty slice ⇒ the LLM produced nothing ⇒ caller falls back).
|
||
if ts == nil {
|
||
return nil
|
||
}
|
||
nodes, _ := ts.ListByKind(db.KindGoal, 10000)
|
||
var out []GoalSpec
|
||
for _, n := range nodes {
|
||
var p struct {
|
||
Text string `json:"text"`
|
||
VulnClass string `json:"vulnclass"`
|
||
}
|
||
_ = json.Unmarshal(n.Payload, &p)
|
||
if strings.TrimSpace(p.Text) != "" {
|
||
out = append(out, GoalSpec{Text: p.Text, VulnClass: p.VulnClass})
|
||
}
|
||
}
|
||
return out
|
||
}
|