658 lines
27 KiB
Go
658 lines
27 KiB
Go
package agent
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/Autumn-27/artex/db"
|
||
actool "github.com/Autumn-27/norma/tool"
|
||
)
|
||
|
||
// =====================================================================
|
||
// Unified asset insertion tools
|
||
// =====================================================================
|
||
|
||
// SetAssetStore wires the asset store and company store onto this ToolSet
|
||
// so the insert_assets, add_company_scope, and list_assets tools are active.
|
||
func (t *ToolSet) SetAssetStore(as *db.AssetStore, cs *db.CompanyStore) {
|
||
t.as = as
|
||
t.cs = cs
|
||
}
|
||
|
||
// assetInputItem is one element of the insert_assets "assets" array.
|
||
type assetInputItem struct {
|
||
Type string `json:"type"` // root_domain|ip|subdomain|app|service|endpoint
|
||
|
||
// ---- root_domain / subdomain ----
|
||
Domain string `json:"domain"`
|
||
ICP string `json:"icp"`
|
||
RecordType string `json:"record_type"`
|
||
RecordValue []string `json:"record_value"`
|
||
|
||
// ---- ip ----
|
||
IP string `json:"ip"`
|
||
BoundDomains []string `json:"bound_domains"`
|
||
OpenPorts []db.PortService `json:"open_ports"`
|
||
|
||
// ---- app ----
|
||
AppName string `json:"app_name"`
|
||
BundleID string `json:"bundle_id"`
|
||
Category string `json:"category"`
|
||
Description string `json:"description"`
|
||
AppICP string `json:"app_icp"`
|
||
CompanyID *int64 `json:"company_id"` // explicit company link (app only; others auto-attribute via scope)
|
||
|
||
// ---- service (http) ----
|
||
URL string `json:"url"`
|
||
Technologies []string `json:"technologies"`
|
||
StatusCode *int `json:"status_code"`
|
||
ContentLength *int64 `json:"content_length"`
|
||
PageTitle string `json:"page_title"`
|
||
FaviconMMH3 string `json:"favicon_mmh3"`
|
||
Auth []map[string]any `json:"auth"`
|
||
ServiceName string `json:"service_name"`
|
||
ServiceIP string `json:"service_ip"` // optional enrichment IP
|
||
|
||
// ---- service (other) ----
|
||
Port int `json:"port"`
|
||
Proto string `json:"proto"`
|
||
|
||
// ---- endpoint ----
|
||
Method string `json:"method"`
|
||
Params []map[string]any `json:"params"`
|
||
|
||
// Related marks whether this asset is relevant to the CURRENT test task and
|
||
// should therefore auto-enter the coverage system (task_scope denominator).
|
||
// nil = default true (relevant → auto-scoped). false = insert into the shared
|
||
// asset库 but keep it OUT of this task's coverage. Only consulted when the task
|
||
// has the coverage feature enabled; ignored (no auto-scope either way) when off.
|
||
Related *bool `json:"related"`
|
||
}
|
||
|
||
// insertAssets is the unified insert_assets agent tool.
|
||
func (t *ToolSet) insertAssets() actool.CoreTool {
|
||
return writeTool(
|
||
"insert_assets",
|
||
"发现新资产时立即批量插入资产到资产库。支持一次提交多种资产类型:root_domain/ip/subdomain/app/service/endpoint。\n"+
|
||
"每条资产的字段说明:\n"+
|
||
"• root_domain:domain(根域名, 必填)、icp(备案, 可选)\n"+
|
||
"• ip:ip(IP地址, 必填)、bound_domains([域名])、open_ports([{port,service}])\n"+
|
||
"• subdomain:domain(子域名, 必填)、record_type(A/AAAA/CNAME等)、record_value(记录值)、icp(可选)\n"+
|
||
"• app:app_name(应用名, 必填)、bundle_id(bundle id, 可选)、category、description、app_icp、company_id(归属企业 id, 可选)\n"+
|
||
"• service(http):url(必填)、technologies([指纹])、status_code、content_length、page_title、favicon_mmh3、auth([{type,username,password,...}])、service_ip\n"+
|
||
"• service(other):service_name(必填)、ip或domain(至少一个)、port(必填)、auth([...])\n"+
|
||
"• endpoint:url(必填)、method(必填)、params([{location,name,value,type}])、service_ip\n"+
|
||
"auth/technologies/params 都是【追加合并】(append),不会覆盖原有值。\n"+
|
||
"返回:{results:[{index,id,type}], errors:[{index,error}]}",
|
||
obj(map[string]any{
|
||
// task_id 不暴露给模型:worker 归属哪个 task 由程序经 SetTaskID 权威赋值(见 handler)。
|
||
"assets": map[string]any{
|
||
"type": "array",
|
||
"description": "资产数组,每个元素对应一条资产记录",
|
||
"items": obj(map[string]any{
|
||
"type": map[string]any{
|
||
"type": "string",
|
||
"enum": []string{"root_domain", "ip", "subdomain", "app", "service", "endpoint"},
|
||
"description": "资产类型",
|
||
},
|
||
// root_domain / subdomain
|
||
"domain": str("根域名或子域名(root_domain/subdomain 必填)"),
|
||
"icp": str("ICP 备案号(可选)"),
|
||
"record_type": str("DNS 解析类型:A/AAAA/CNAME/MX 等(subdomain 可选)"),
|
||
"record_value": map[string]any{
|
||
"type": "array",
|
||
"items": map[string]any{"type": "string"},
|
||
"description": "DNS 解析值列表(subdomain 可选,如 [\"1.2.3.4\",\"2.3.4.5\"])",
|
||
},
|
||
// ip
|
||
"ip": str("IP 地址(ip 类型必填;service/endpoint 类型可填,用于关联 IP)"),
|
||
"bound_domains": map[string]any{
|
||
"type": "array",
|
||
"items": map[string]any{"type": "string"},
|
||
"description": "该 IP 绑定的域名列表(ip 类型可选)",
|
||
},
|
||
"open_ports": map[string]any{
|
||
"type": "array",
|
||
"description": "开放端口列表(ip 类型可选)",
|
||
"items": obj(map[string]any{
|
||
"port": intp("端口号"),
|
||
"service": str("服务名称,如 http/ssh/mysql 等(可选)"),
|
||
}, "port"),
|
||
},
|
||
// app
|
||
"app_name": str("应用名称(app 类型必填)"),
|
||
"bundle_id": str("Bundle ID(app 类型可选)"),
|
||
"category": str("应用分类(可选)"),
|
||
"description": str("应用描述(可选)"),
|
||
"app_icp": str("应用 ICP 备案(可选)"),
|
||
"company_id": intp("归属企业 id(app 类型可选;app 无法靠 scope 自动归因,需显式指定。id 由 add_company_scope 返回)"),
|
||
// service (http)
|
||
"url": str("完整 URL,含协议和端口(HTTP 服务必填;service_type 自动设为 http)"),
|
||
"status_code": intp("HTTP 响应状态码,如 200/301/403/404(可选)"),
|
||
"content_length": map[string]any{
|
||
"type": "integer",
|
||
"description": "HTTP 响应体字节数(可选)",
|
||
},
|
||
"page_title": str("页面 <title> 内容(可选)"),
|
||
"favicon_mmh3": str("favicon MMH3 哈希(可选)"),
|
||
"technologies": map[string]any{
|
||
"type": "array",
|
||
"items": map[string]any{"type": "string"},
|
||
"description": "指纹/技术栈列表,如 [\"Nginx\",\"Vue\",\"Bootstrap\"](可选)",
|
||
},
|
||
"auth": map[string]any{
|
||
"type": "array",
|
||
"description": "发现的认证信息列表,每条含 type/username/password 等字段(可选,追加不覆盖)",
|
||
"items": map[string]any{"type": "object"},
|
||
},
|
||
// service (other,非 HTTP)
|
||
"service_name": str("服务名称,如 ssh/mysql/redis(service 非 HTTP 时必填)"),
|
||
"port": intp("端口号(service 非 HTTP 时必填)"),
|
||
// endpoint
|
||
"method": str("HTTP 方法:GET/POST/PUT/PATCH/DELETE 等(endpoint 必填)"),
|
||
"params": map[string]any{
|
||
"type": "array",
|
||
"description": "请求参数列表,每条含 location(query/body/header/path)/name/value/type(可选,追加不覆盖)",
|
||
"items": map[string]any{"type": "object"},
|
||
},
|
||
// 覆盖度相关:该资产是否与当前测试任务有关。
|
||
"related": map[string]any{
|
||
"type": "boolean",
|
||
"description": "该资产是否与【当前测试任务】相关:true(默认)才自动纳入资产覆盖度(测试范围分母),该资产会进入待测资产中,如果是和任务无关的,例如CDN仅存储静态资源类,必须设置为false或不进行资产插入;false 则只入库、不计入本任务覆盖度(如顺带发现的旁站/无关资产)。仅在任务开启资产覆盖度功能时生效。",
|
||
},
|
||
}, "type"),
|
||
},
|
||
}, "assets"),
|
||
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
|
||
if t.as == nil {
|
||
return actool.Errorf("insert_assets 未启用: AssetStore 未初始化"), nil
|
||
}
|
||
var a struct {
|
||
Assets []assetInputItem `json:"assets"`
|
||
}
|
||
if err := json.Unmarshal(in, &a); err != nil {
|
||
return actool.Errorf("invalid input: " + err.Error()), nil
|
||
}
|
||
// task_id 由程序权威赋值(worker: SetTaskID),不接受模型传入——避免模型漏传/错传
|
||
// 导致资产未归任务或归错任务。无任务上下文的调用方(auto/pentest/chat)其 t.taskID=0。
|
||
taskID := t.taskID
|
||
|
||
type result struct {
|
||
Index int `json:"index"`
|
||
ID int64 `json:"id"`
|
||
Type string `json:"type"`
|
||
}
|
||
type errEntry struct {
|
||
Index int `json:"index"`
|
||
Error string `json:"error"`
|
||
}
|
||
|
||
var results []result
|
||
var errs []errEntry
|
||
|
||
for i, item := range a.Assets {
|
||
typ := strings.TrimSpace(item.Type)
|
||
var id int64
|
||
var err error
|
||
|
||
switch typ {
|
||
case "root_domain":
|
||
id, err = t.as.UpsertRootDomain(db.UpsertRootDomainReq{
|
||
Domain: item.Domain,
|
||
ICP: item.ICP,
|
||
TaskID: taskID,
|
||
})
|
||
|
||
case "ip":
|
||
id, err = t.as.UpsertIP(db.UpsertIPReq{
|
||
IP: item.IP,
|
||
BoundDomains: item.BoundDomains,
|
||
OpenPorts: item.OpenPorts,
|
||
TaskID: taskID,
|
||
})
|
||
|
||
case "subdomain":
|
||
id, err = t.as.UpsertSubdomain(db.UpsertSubdomainReq{
|
||
Domain: item.Domain,
|
||
RecordType: item.RecordType,
|
||
RecordValue: item.RecordValue,
|
||
ICP: item.ICP,
|
||
TaskID: taskID,
|
||
})
|
||
|
||
case "app":
|
||
id, err = t.as.UpsertApp(db.UpsertAppReq{
|
||
Name: item.AppName,
|
||
BundleID: item.BundleID,
|
||
Category: item.Category,
|
||
Description: item.Description,
|
||
ICP: item.AppICP,
|
||
CompanyID: item.CompanyID,
|
||
TaskID: taskID,
|
||
})
|
||
|
||
case "service":
|
||
// distinguish HTTP vs other by presence of url
|
||
if item.URL != "" {
|
||
// agent may send "ip" or "service_ip" for the enrichment IP; accept both
|
||
svcIP := item.ServiceIP
|
||
if svcIP == "" {
|
||
svcIP = item.IP
|
||
}
|
||
id, err = t.as.UpsertHTTPService(db.UpsertHTTPServiceReq{
|
||
URL: item.URL,
|
||
Technologies: item.Technologies,
|
||
StatusCode: item.StatusCode,
|
||
ContentLength: item.ContentLength,
|
||
PageTitle: item.PageTitle,
|
||
FaviconMMH3: item.FaviconMMH3,
|
||
Auth: item.Auth,
|
||
IP: svcIP,
|
||
TaskID: taskID,
|
||
})
|
||
} else {
|
||
id, err = t.as.UpsertOtherService(db.UpsertOtherServiceReq{
|
||
Domain: item.Domain,
|
||
IP: item.IP,
|
||
Port: item.Port,
|
||
ServiceName: item.ServiceName,
|
||
Auth: item.Auth,
|
||
TaskID: taskID,
|
||
})
|
||
}
|
||
|
||
case "endpoint":
|
||
id, err = t.as.UpsertEndpoint(db.UpsertEndpointReq{
|
||
URL: item.URL,
|
||
Method: item.Method,
|
||
Params: item.Params,
|
||
IP: item.ServiceIP,
|
||
TaskID: taskID,
|
||
})
|
||
|
||
default:
|
||
errs = append(errs, errEntry{Index: i, Error: "unknown type: " + typ})
|
||
continue
|
||
}
|
||
|
||
if err != nil {
|
||
errs = append(errs, errEntry{Index: i, Error: err.Error()})
|
||
continue
|
||
}
|
||
results = append(results, result{Index: i, ID: id, Type: typ})
|
||
t.writes.Assets++
|
||
t.anchorOwner(id)
|
||
if taskID > 0 {
|
||
var sourceNodeID *int64
|
||
if t.ownerNode > 0 {
|
||
nodeID := t.ownerNode
|
||
sourceNodeID = &nodeID
|
||
}
|
||
summary := "Agent 通过 insert_assets 登记"
|
||
if t.ownerNode > 0 {
|
||
summary = fmt.Sprintf("Worker 意图 #%d 通过 insert_assets 登记", t.ownerNode)
|
||
}
|
||
_ = t.as.SetTaskAssetSource(taskID, id, "agent", summary, sourceNodeID)
|
||
}
|
||
// 自动入测试范围(source='auto'):只对 worker 顶层显式插入的这一项,按其
|
||
// 类型加保守范围;side-effect 派生的资产不经此处,故范围不盲目扩大。taskID=0 时无操作。
|
||
// 资产覆盖度功能关闭时不再累积测试范围(分母);related=false(与当前任务无关)
|
||
// 的资产也只入库、不计入覆盖度。related 省略/null 视为 true(默认纳入)。
|
||
related := item.Related == nil || *item.Related
|
||
if !t.coverageDisabled && related {
|
||
svcIP := item.ServiceIP
|
||
if svcIP == "" {
|
||
svcIP = item.IP
|
||
}
|
||
_ = t.as.AddAutoScope(taskID, typ, item.Domain, item.URL, svcIP)
|
||
}
|
||
}
|
||
|
||
return jsonResult(map[string]any{
|
||
"results": results,
|
||
"errors": errs,
|
||
})
|
||
},
|
||
)
|
||
}
|
||
|
||
// addCompanyScope writes to company_scope table and triggers asset attribution.
|
||
func (t *ToolSet) addCompanyScope() actool.CoreTool {
|
||
return writeTool(
|
||
"add_company_scope",
|
||
"把域名/IP/CIDR/ICP备案/企业关键词加入某公司的【资产范围】——域名、网络和ICP会自动认领命中的资产,关键词只提供给Agent作为范围提示。\n"+
|
||
"公司名唯一:company 不存在则新建,已存在则复用(只把范围并进去)。\n"+
|
||
"scope 一行一条,系统自动识别:根域名 / URL / 单个 IP / CIDR 网段 / ICP备案 / 企业关键词。\n"+
|
||
"务必给 reason 说明归属依据(whois/证书/ASN 等)。\n"+
|
||
"护栏:拒绝裸 TLD 与过宽网段(IPv4前缀需为/16-/32、IPv6前缀需为/32-/128),非法行会被跳过并在 errors 返回。",
|
||
obj(map[string]any{
|
||
"company": str("公司名(不存在则新建、存在则复用;名称唯一)"),
|
||
"scope": str("资产范围,一行一条:域名 / URL / IP / CIDR / ICP备案 / 企业关键词"),
|
||
"reason": str("归属依据(证据/来源),务必填写"),
|
||
"logo": str("公司图标 URL(可选;仅新建公司时生效)"),
|
||
}, "company", "scope"),
|
||
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
|
||
if t.cs == nil {
|
||
return actool.Errorf("add_company_scope 未启用: CompanyStore 未初始化"), nil
|
||
}
|
||
var a struct {
|
||
Company string `json:"company"`
|
||
Scope string `json:"scope"`
|
||
Reason string `json:"reason"`
|
||
Logo string `json:"logo"`
|
||
}
|
||
if err := json.Unmarshal(in, &a); err != nil {
|
||
return actool.Errorf(err.Error()), nil
|
||
}
|
||
if strings.TrimSpace(a.Company) == "" {
|
||
return actool.Errorf("company 不能为空"), nil
|
||
}
|
||
companyID, _, err := t.cs.UpsertCompany(a.Company, a.Logo)
|
||
if err != nil {
|
||
return actool.Errorf("创建/获取公司失败: " + err.Error()), nil
|
||
}
|
||
lines := splitLines(a.Scope)
|
||
added, skipped, invalid, errMsgs := t.cs.AddScope(companyID, lines, a.Reason)
|
||
out := map[string]any{
|
||
"company_id": companyID,
|
||
"added": added,
|
||
"skipped": skipped,
|
||
"invalid": invalid,
|
||
}
|
||
if len(errMsgs) > 0 {
|
||
out["errors"] = errMsgs
|
||
}
|
||
return jsonResult(out)
|
||
},
|
||
)
|
||
}
|
||
|
||
// addTaskScope lets the plan agent add test scope to THE CURRENT TASK — the coverage
|
||
// denominator and the task's authorization edge. Worker discoveries are auto-scoped
|
||
// (precise host) by insertAssets; this tool is for DELIBERATELY WIDENING: pull a whole
|
||
// root domain or whole company into scope, or add a specific subdomain / ip.
|
||
func (t *ToolSet) addTaskScope() actool.CoreTool {
|
||
return writeTool(
|
||
"add_task_scope",
|
||
"把测试范围加入【本任务】——这是资产测试覆盖度的分母,也是本任务的授权边界。\n"+
|
||
"kind 支持:company(整个公司名下资产) / root_domain(整个根域,含所有子域) / subdomain(单个精确子域) / ip / cidr / icp / keyword。\n"+
|
||
"说明:worker 逐个碰到的主机会被系统【自动】加进范围(精确子域);本工具用于【主动扩大】——把整个根域/整个公司纳入,或补充指定某子域/IP。\n"+
|
||
"value:company 传公司名或 id(公司须已存在);root_domain/subdomain 传域名;ip/cidr 传 IP 或网段;icp/keyword 传备案号或企业关键词。\n"+
|
||
"务必给 reason 说明依据(可审计)。多条用 entries 数组。",
|
||
obj(map[string]any{
|
||
"entries": map[string]any{"type": "array", "description": "批量:[{kind, value}]。kind∈company/root_domain/subdomain/ip/cidr/icp/keyword。", "items": map[string]any{"type": "object"}},
|
||
"kind": str("[单条] company / root_domain / subdomain / ip / cidr / icp / keyword"),
|
||
"value": str("[单条] 公司名或id / 域名 / IP / CIDR / ICP / 关键词"),
|
||
"reason": str("加入依据(用于审计),务必填写"),
|
||
}),
|
||
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
|
||
if t.as == nil {
|
||
return actool.Errorf("add_task_scope 未启用: AssetStore 未初始化"), nil
|
||
}
|
||
if t.taskID <= 0 {
|
||
return actool.Errorf("add_task_scope 需要任务上下文(当前无 task)"), nil
|
||
}
|
||
type scopeEntry struct {
|
||
Kind string `json:"kind"`
|
||
Value string `json:"value"`
|
||
}
|
||
var a struct {
|
||
Entries []scopeEntry `json:"entries"`
|
||
scopeEntry // 单条模式
|
||
Reason string `json:"reason"`
|
||
}
|
||
_ = json.Unmarshal(in, &a)
|
||
items := a.Entries
|
||
if len(items) == 0 {
|
||
items = []scopeEntry{a.scopeEntry}
|
||
}
|
||
var added []map[string]any
|
||
errs := map[string]string{}
|
||
for i, e := range items {
|
||
ts, err := t.as.AddAgentScope(t.taskID, strings.TrimSpace(e.Kind), e.Value, a.Reason, "agent")
|
||
if err != nil {
|
||
errs[strconv.Itoa(i)] = err.Error()
|
||
continue
|
||
}
|
||
added = append(added, map[string]any{"kind": ts.Kind, "domain": ts.Domain, "net": ts.Net, "value": ts.Value, "company_id": ts.CompanyID})
|
||
}
|
||
out := map[string]any{"added": added}
|
||
if len(errs) > 0 {
|
||
out["errors"] = errs
|
||
}
|
||
return jsonResult(out)
|
||
},
|
||
)
|
||
}
|
||
|
||
// listUntestedAssets lets the plan agent pull the current + directly inherited
|
||
// scope's not-yet-tested assets on demand (filter by type, paginated).
|
||
func (t *ToolSet) listUntestedAssets() actool.CoreTool {
|
||
return readTool(
|
||
"list_untested_assets",
|
||
"查询【本任务及直接关联任务】范围内、还没被事实锚点覆盖的资产(关联范围只读,供你自己判断要不要补测,不代替你决策)。\n"+
|
||
"可选按资产类型过滤:root_domain/subdomain/service/app/endpoint/ip。\n"+
|
||
"分页:page 从 1 起、page_size 默认 10。返回 {assets:[{id,type,label}], total, page, page_size}。仅任务上下文可用。",
|
||
obj(map[string]any{
|
||
"type": str("资产类型过滤(可选):root_domain/subdomain/service/app/endpoint/ip"),
|
||
"page": intp("页码,从 1 起(默认 1)"),
|
||
"page_size": intp("每页数量(默认 10)"),
|
||
}),
|
||
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
|
||
if t.as == nil {
|
||
return actool.Errorf("list_untested_assets 未启用: AssetStore 未初始化"), nil
|
||
}
|
||
if t.taskID <= 0 || t.ts == nil {
|
||
return actool.Errorf("list_untested_assets 需要任务上下文"), nil
|
||
}
|
||
var a struct {
|
||
Type string `json:"type"`
|
||
Page int `json:"page"`
|
||
PageSize int `json:"page_size"`
|
||
}
|
||
_ = json.Unmarshal(in, &a)
|
||
if a.Page <= 0 {
|
||
a.Page = 1
|
||
}
|
||
if a.PageSize <= 0 {
|
||
a.PageSize = 10
|
||
}
|
||
offset := (a.Page - 1) * a.PageSize
|
||
assets, total, err := t.as.ListUntestedAssetsWithSources(t.taskID, strings.TrimSpace(a.Type), a.PageSize, offset)
|
||
if err != nil {
|
||
return actool.Errorf(err.Error()), nil
|
||
}
|
||
return jsonResult(map[string]any{
|
||
"assets": assets, "total": total, "page": a.Page, "page_size": a.PageSize,
|
||
})
|
||
},
|
||
)
|
||
}
|
||
|
||
// listAssets lets an agent query the asset table.
|
||
func (t *ToolSet) listAssets() actool.CoreTool {
|
||
return readTool(
|
||
"list_assets",
|
||
"查询资产库。支持 DSL 表达式搜索、按 id/ids 直接取,分页。\n"+
|
||
"DSL 语法:field=value 模糊(ILIKE) | field==value 精确 | field!=value 排除 | port>80 数字比较 | 裸词=全文模糊。\n"+
|
||
"逻辑运算:AND / OR(关键字,AND 优先级高于 OR),括号分组。\n"+
|
||
"资产类型用独立的 type 参数过滤,DSL 里不含 type 字段。\n"+
|
||
"约束:未传 id/ids 时 dsl 必须非空——不允许无条件查询全部资产,必须带查询条件。\n"+
|
||
"字段一览:\n"+
|
||
" domain 域名(根域名/子域名/服务域名)\n"+
|
||
" root_domain 根域名(仅子域名/服务资产有)\n"+
|
||
" ip IPv4/IPv6 地址\n"+
|
||
" url 完整 URL(服务/接口)\n"+
|
||
" page_title 页面标题(HTTP 服务)\n"+
|
||
" icp ICP 备案号(根域名)\n"+
|
||
" service_name 服务名称(非 HTTP 服务)\n"+
|
||
" app_name 应用名称(app 类型)\n"+
|
||
" method HTTP 方法(接口类型,如 GET/POST)\n"+
|
||
" service_type HTTP 服务类型:http|other\n"+
|
||
" record_type DNS 解析类型(子域名,如 A/CNAME)\n"+
|
||
" technology 指纹/技术栈(数组字段,= 模糊 == 精确)\n"+
|
||
" port 端口号(整数,支持 > >= < <=)\n"+
|
||
" status_code HTTP 状态码(整数,支持 > >= < <=)\n"+
|
||
" company_id 归属企业 id(整数)\n"+
|
||
" task_id 来源任务 id(整数)\n"+
|
||
"示例:status_code>=400 AND technology=shiro\n"+
|
||
" method==POST AND url=/api/admin\n"+
|
||
" icp=京 OR icp=沪\n"+
|
||
" (port==80 OR port==443) AND technology=nginx",
|
||
obj(map[string]any{
|
||
"dsl": str(`DSL 查询表达式,参见工具描述。未传 id/ids 时必须非空。例:status_code>=400 technology=shiro / method==POST url=/api/admin`),
|
||
"type": str("资产类型过滤:root_domain|ip|subdomain|app|service|endpoint(独立字段,可与 dsl 叠加;单独 type 不足以查询,仍需 dsl)"),
|
||
"id": intp("直接按单个资产 id 取(可选,与 dsl/type 互斥)"),
|
||
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, "description": "直接按多个资产 id 取(可选,与 dsl/type 互斥)"},
|
||
"limit": intp("返回上限,默认 10(可选)"),
|
||
"offset": intp("分页偏移,默认 0(可选)"),
|
||
}),
|
||
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
|
||
if t.as == nil {
|
||
return actool.Errorf("list_assets 未启用: AssetStore 未初始化"), nil
|
||
}
|
||
var a struct {
|
||
DSL string `json:"dsl"`
|
||
Type string `json:"type"`
|
||
ID int64 `json:"id"`
|
||
IDs []int64 `json:"ids"`
|
||
Limit int `json:"limit"`
|
||
Offset int `json:"offset"`
|
||
}
|
||
_ = json.Unmarshal(in, &a)
|
||
if a.Limit <= 0 {
|
||
a.Limit = 10
|
||
}
|
||
|
||
var assets []*db.Asset
|
||
var err error
|
||
switch {
|
||
case a.ID > 0:
|
||
assets, err = t.as.GetByIDs([]int64{a.ID})
|
||
case len(a.IDs) > 0:
|
||
assets, err = t.as.GetByIDs(a.IDs)
|
||
case a.DSL != "":
|
||
assets, err = t.as.QueryDSL(a.DSL, a.Type, a.Limit, a.Offset)
|
||
default:
|
||
return actool.Errorf("未传 id/ids 时 dsl 不能为空:不允许无条件查询全部资产,请提供查询条件"), nil
|
||
}
|
||
if err != nil {
|
||
return actool.Errorf("DSL 错误: " + err.Error()), nil
|
||
}
|
||
return jsonResult(map[string]any{
|
||
"count": len(assets),
|
||
"assets": assets,
|
||
})
|
||
},
|
||
)
|
||
}
|
||
|
||
// listCompanies lets an agent enumerate companies (企业) with their scope + asset count.
|
||
func (t *ToolSet) listCompanies() actool.CoreTool {
|
||
return readTool(
|
||
"list_companies",
|
||
"列出资产库中的【企业/公司】及其资产范围(scope)与已归属资产数。用于查看有哪些公司、"+
|
||
"拿到 company_id(insert_assets 关联 app、list_assets 按 company_id 过滤时用)。"+
|
||
"可选 search 按公司名模糊过滤(不区分大小写),留空返回全部。",
|
||
obj(map[string]any{
|
||
"search": str("按公司名模糊过滤(可选,不区分大小写);留空返回全部"),
|
||
}),
|
||
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
|
||
if t.cs == nil {
|
||
return actool.Errorf("list_companies 未启用: CompanyStore 未初始化"), nil
|
||
}
|
||
var a struct {
|
||
Search string `json:"search"`
|
||
}
|
||
_ = json.Unmarshal(in, &a)
|
||
cos, err := t.cs.ListCompanies()
|
||
if err != nil {
|
||
return actool.Errorf("查询公司失败: " + err.Error()), nil
|
||
}
|
||
q := strings.ToLower(strings.TrimSpace(a.Search))
|
||
type companyOut struct {
|
||
ID int64 `json:"id"`
|
||
Name string `json:"name"`
|
||
AssetCount int `json:"asset_count"`
|
||
Scope []string `json:"scope"`
|
||
}
|
||
out := make([]companyOut, 0, len(cos))
|
||
for _, c := range cos {
|
||
if q != "" && !strings.Contains(strings.ToLower(c.Name), q) {
|
||
continue
|
||
}
|
||
scope := make([]string, 0, len(c.Scope))
|
||
for _, r := range c.Scope {
|
||
scope = append(scope, r.Raw)
|
||
}
|
||
out = append(out, companyOut{ID: c.ID, Name: c.Name, AssetCount: c.AssetCount, Scope: scope})
|
||
}
|
||
return jsonResult(map[string]any{"count": len(out), "companies": out})
|
||
},
|
||
)
|
||
}
|
||
|
||
// splitLines splits a multi-line string into non-empty trimmed lines.
|
||
func splitLines(s string) []string {
|
||
var out []string
|
||
for _, line := range strings.Split(s, "\n") {
|
||
line = strings.TrimSpace(line)
|
||
if line != "" {
|
||
out = append(out, line)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// WorkerTools returns the tool set for a work agent.
|
||
func (t *ToolSet) WorkerTools() []actool.CoreTool {
|
||
return []actool.CoreTool{
|
||
t.listFindings(), t.listFacts(), t.nodeDetail(),
|
||
t.addFinding(), t.recordFact(),
|
||
// cross-work retrieval
|
||
t.searchAllWorkerTraces(), t.listWorkerTraces(), t.getWorkerTrace(),
|
||
// asset management (handlers guard nil store internally)
|
||
t.insertAssets(), t.addCompanyScope(), t.listAssets(), t.listCompanies(),
|
||
}
|
||
}
|
||
|
||
// MainAgentTools returns the human-interface tool set.
|
||
func (t *ToolSet) MainAgentTools() []actool.CoreTool {
|
||
return []actool.CoreTool{
|
||
t.graphOverview(), t.listFindings(), t.listFacts(), t.nodeDetail(),
|
||
t.getWorkerOutput(), t.getWorkerTrace(), t.searchAllWorkerTraces(), t.addHint(), t.addIntent(),
|
||
// steer_work:人可对某条正在运行的意图(work)实时注入纠偏指令(不打断、不丢进展)。
|
||
t.steerWorkTool(),
|
||
// set_goals:人可在运行时给本任务补一个新的最终目标(规划者据此重判是否达成)。
|
||
t.setGoals(),
|
||
// set_constraints:人可在运行时给本任务补/改操作约束(allow/deny),约束 planner/worker 的探索边界。
|
||
t.setConstraints(),
|
||
// asset management (handlers guard nil store internally)
|
||
t.insertAssets(), t.addCompanyScope(), t.listAssets(),
|
||
t.addFinding(), t.recordFact(),
|
||
t.addTaskScope(),
|
||
// list_untested_assets:按需查本任务范围内未测资产(类型+分页),自行决定补测。
|
||
t.listUntestedAssets(),
|
||
}
|
||
}
|
||
|
||
// AllDomainTools returns the union of all domain tools across all agent types,
|
||
// deduped by name (mainagent order wins). Used by the server to build a registry
|
||
// for injecting domain tools into agents (Auto, custom) that don't own a per-task
|
||
// ToolSet. The caller provides real stores; tools are callable at taskID=0 scope.
|
||
func (t *ToolSet) AllDomainTools() []actool.CoreTool {
|
||
seen := map[string]bool{}
|
||
var out []actool.CoreTool
|
||
all := append(append(t.MainAgentTools(), t.PlannerTools()...), t.WorkerTools()...)
|
||
for _, tool := range all {
|
||
if !seen[tool.Name()] {
|
||
seen[tool.Name()] = true
|
||
out = append(out, tool)
|
||
}
|
||
}
|
||
return out
|
||
}
|