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:
1 parent
aab6b9c948
commit
e30218f948
2 files changed
+81
-11
No files matched your search
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user