feat(asset): 新增按 host 精确删除资产的工具 delete_assets_by_host

DeleteByHost(host):DomainKey 归一化后按 domain/root_domain/ip 精确等值删除,
连带该 host 下的 service/endpoint;传根域名会连带其子域名。exploration_anchors
为 ON DELETE CASCADE,删除不被外键阻塞。工具层 delete_assets_by_host 返回按类型
分组的删除计数。硬删除、作用于全局资产库、不可撤销。

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Autumn-27andClaude Opus 4.8 committed 2026-08-09 04:18:21 -04:00
1 parent aab6b9c948
commit e30218f948
2 files changed
+81 -11

No files matched your search

+39
View File
@@ -521,6 +521,45 @@ func (t *ToolSet) listAssets() actool.CoreTool {
)
}
// deleteAssetsByHost hard-deletes every asset tied to one host (exact match).
func (t *ToolSet) deleteAssetsByHost() actool.CoreTool {
return writeTool(
"delete_assets_by_host",
"按 host 精确删除资产:删掉该 host 的域名/子域名,以及其下的服务(service)、接口(endpoint)。\n"+
"host 完全匹配(小写、去空格),不是模糊/通配。\n"+
"传根域名(如 example.com)会连带删除它的子域名及其服务/接口;传子域名(如 a.example.com)或 IP 只删该 host 自身及其服务/接口。\n"+
"⚠️ 硬删除且作用于全局资产库(跨任务共享),不可撤销;请先用 list_assets 确认范围再删。",
obj(map[string]any{
"host": str("要删除的 host:域名/子域名/IP。完全匹配,如 example.com 或 a.example.com 或 1.2.3.4"),
}, "host"),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
if t.as == nil {
return actool.Errorf("delete_assets_by_host 未启用: AssetStore 未初始化"), nil
}
var a struct {
Host string `json:"host"`
}
_ = json.Unmarshal(in, &a)
if strings.TrimSpace(a.Host) == "" {
return actool.Errorf("host 不能为空"), nil
}
counts, err := t.as.DeleteByHost(a.Host)
if err != nil {
return actool.Errorf("删除失败: " + err.Error()), nil
}
var total int64
for _, n := range counts {
total += n
}
return jsonResult(map[string]any{
"host": a.Host,
"deleted": total,
"deleted_by_type": counts,
})
},
)
}
// listCompanies lets an agent enumerate companies (企业) with their scope + asset count.
func (t *ToolSet) listCompanies() actool.CoreTool {
return readTool(
+42 -11
View File
@@ -15,16 +15,16 @@ import (
// Asset is a row in the assets table.
type Asset struct {
ID int64 `json:"id"`
Type string `json:"type"`
CompanyID *int64 `json:"company_id,omitempty"`
TaskIDs []int64 `json:"task_ids"`
Domain string `json:"domain,omitempty"`
RootDomain string `json:"root_domain,omitempty"`
IP string `json:"ip,omitempty"`
CSegment string `json:"c_segment,omitempty"`
Port *int `json:"port,omitempty"`
ICP string `json:"icp,omitempty"`
ID int64 `json:"id"`
Type string `json:"type"`
CompanyID *int64 `json:"company_id,omitempty"`
TaskIDs []int64 `json:"task_ids"`
Domain string `json:"domain,omitempty"`
RootDomain string `json:"root_domain,omitempty"`
IP string `json:"ip,omitempty"`
CSegment string `json:"c_segment,omitempty"`
Port *int `json:"port,omitempty"`
ICP string `json:"icp,omitempty"`
// ip fields
BoundDomains []string `json:"bound_domains,omitempty"`
OpenPorts []map[string]any `json:"open_ports,omitempty"`
@@ -986,7 +986,6 @@ const assetSelectCols = `SELECT id, type, company_id, array_to_json(task_ids)::t
COALESCE(method,''), array_to_json(params)::text, extra, last_seen::text
FROM assets`
// GetByIDs returns assets with the given ids (order preserved by id array order).
func (s *AssetStore) GetByIDs(ids []int64) ([]*Asset, error) {
if len(ids) == 0 {
@@ -1016,6 +1015,38 @@ func (s *AssetStore) DeleteByCompanyID(companyID int64) (int64, error) {
return res.RowsAffected()
}
// DeleteByHost hard-deletes every asset whose host exactly matches the given value:
// root_domain / subdomain / service / endpoint (they carry the host in domain or
// root_domain) plus an ip asset and its services/endpoints (ip column). The host is
// normalized the same way it is stored (DomainKey: lowercase/trim/strip trailing dot)
// so matching is exact, not fuzzy. Passing a root domain also removes its subdomains
// and their services/endpoints (they carry root_domain = that host); passing a
// subdomain/IP removes only that host's own assets. Referencing exploration_anchors
// rows are cleaned by ON DELETE CASCADE. Returns rows deleted, grouped by type.
func (s *AssetStore) DeleteByHost(host string) (map[string]int64, error) {
h := DomainKey(host)
if h == "" {
return nil, fmt.Errorf("host is required")
}
rows, err := s.db.Query(`
DELETE FROM assets
WHERE domain = $1 OR root_domain = $1 OR ip = $1
RETURNING type`, h)
if err != nil {
return nil, err
}
defer rows.Close()
counts := map[string]int64{}
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
return nil, err
}
counts[t]++
}
return counts, rows.Err()
}
// DeleteByIDs hard-deletes assets by their IDs. Returns the number of rows deleted.
func (s *AssetStore) DeleteByIDs(ids []int64) (int64, error) {
if len(ids) == 0 {