fix(assets): ip 字段拒收主机名,并告诉调用方该怎么改

上一次修复让非法 ip 不再打挂企业归属重算,但脏数据仍在持续产生。这次从源头
拦住:assets.ip 会被转成 inet 参与网段归属,存主机名的资产对所有 IP/CIDR
范围规则都是隐形的。

- db 层新增 ValidateAssetIP + ErrAssetIPInvalid,在 UpsertIP /
  UpsertHTTPService / UpsertOtherService / UpsertEndpoint 四个入口校验。
  放在 db 层是因为 Agent 工具和资产 API 都经由它们,不必写两遍;内部调用方
  (enrich 的解析结果、task_assets 的 ParseCIDR 输出、RecordValue 已 ParseIP
  过滤的分支)传的本就是合法地址,不受影响。
- 错误信息给的是改法而不只是「非法」:带上收到的原值,并指出主机名应改用
  type=subdomain 填 domain,或先解析 A/AAAA 记录再填 ip。insert_assets 的
  错误是逐条返回的({index, error}),同批其余资产照常入库,Agent 拿到后能
  定位到具体那条并在下一轮自行修正。
- 同步收紧 insert_assets 的工具描述与 ip 字段说明,在提示层先挡一道。
- 空 ip 仍然合法:service / endpoint 的 ip 是可选字段。

验证: 新增 db 层四类型拒收测试与 agent 层「一批混入一条非法、其余照常入库」
测试,均连 PG 实跑通过;干净库 go test -p 1 ./... 无新增失败(仅剩既有的
TestCompanyScopeInvalid)。
This commit is contained in:
Autumn-27 committed 2026-08-24 10:32:10 -04:00
1 parent 9e98407528
commit dfbbb33069
5 files changed
+147 -2

No files matched your search

+34
View File
@@ -3,6 +3,7 @@ package db
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"net"
"net/url"
@@ -300,6 +301,27 @@ RETURNING id`, domain, icpVal, companyID, taskIDs).Scan(&id)
return id, err
}
// ErrAssetIPInvalid marks a non-address value in an asset's ip field. Both
// insert_assets and the asset API report it per item with the item index, so one
// bad entry never costs the rest of the batch.
var ErrAssetIPInvalid = errors.New("invalid asset ip")
// ValidateAssetIP keeps hostnames out of assets.ip. Network attribution casts
// that column to inet (see try_inet in schema.sql), so a hostname stored here is
// silently invisible to every IP/CIDR scope rule — the asset simply never gets
// attributed and nobody can tell why. The message states the fix rather than
// just the fault, so an agent can correct the item on its next turn. An empty
// value is accepted: the ip field is optional for service and endpoint assets.
func ValidateAssetIP(value string) error {
if value == "" || net.ParseIP(value) != nil {
return nil
}
return fmt.Errorf(
"%w: ip 必须是 IPv4/IPv6 地址,收到 %q。若这是主机名,请改用 type=subdomain 并填 domain 字段;"+
"若确实要登记地址,请先解析出 A/AAAA 记录,再用解析出的地址填 ip",
ErrAssetIPInvalid, value)
}
// =====================================================================
// UpsertIP
// =====================================================================
@@ -317,6 +339,9 @@ func (s *AssetStore) UpsertIP(req UpsertIPReq) (int64, error) {
if req.IP == "" {
return 0, fmt.Errorf("ip is required")
}
if err := ValidateAssetIP(req.IP); err != nil {
return 0, err
}
if s.tx == nil {
return s.withCompanyScopeMutation(func(scoped *AssetStore) (int64, error) {
return scoped.UpsertIP(req)
@@ -659,6 +684,9 @@ func (s *AssetStore) UpsertHTTPService(req UpsertHTTPServiceReq) (int64, error)
if req.URL == "" {
return 0, fmt.Errorf("url is required")
}
if err := ValidateAssetIP(req.IP); err != nil {
return 0, err
}
if s.tx == nil {
return s.withCompanyScopeMutation(func(scoped *AssetStore) (int64, error) {
return scoped.UpsertHTTPService(req)
@@ -793,6 +821,9 @@ func (s *AssetStore) UpsertOtherService(req UpsertOtherServiceReq) (int64, error
if req.Domain == "" && req.IP == "" {
return 0, fmt.Errorf("domain or ip is required")
}
if err := ValidateAssetIP(req.IP); err != nil {
return 0, err
}
if req.Port == 0 {
return 0, fmt.Errorf("port is required")
}
@@ -936,6 +967,9 @@ func (s *AssetStore) UpsertEndpoint(req UpsertEndpointReq) (int64, error) {
if req.Method == "" {
return 0, fmt.Errorf("method is required")
}
if err := ValidateAssetIP(req.IP); err != nil {
return 0, err
}
if s.tx == nil {
return s.withCompanyScopeMutation(func(scoped *AssetStore) (int64, error) {
return scoped.UpsertEndpoint(req)
+53
View File
@@ -1,8 +1,10 @@
package db
import (
"errors"
"fmt"
"slices"
"strings"
"testing"
"time"
)
@@ -946,3 +948,54 @@ func TestAssetPaginationUsesStableIDTieBreaker(t *testing.T) {
t.Fatalf("DSL pagination order=%v want=%v", got, want)
}
}
// TestAssetIPRejectsHostname pins the write-side guard added after a hostname in
// assets.ip broke company attribution: every asset type that persists an ip must
// refuse a non-address, and the message must tell the caller how to fix it.
func TestAssetIPRejectsHostname(t *testing.T) {
d, av2, _ := testSetup(t)
defer d.Close()
hostname := "d63cd476.cdn.ucloud.com.cn"
cases := []struct {
name string
call func() (int64, error)
}{
{"ip", func() (int64, error) {
return av2.UpsertIP(UpsertIPReq{IP: hostname})
}},
{"http service", func() (int64, error) {
return av2.UpsertHTTPService(UpsertHTTPServiceReq{URL: "https://example.com/", IP: hostname})
}},
{"other service", func() (int64, error) {
return av2.UpsertOtherService(UpsertOtherServiceReq{IP: hostname, Port: 22, ServiceName: "ssh"})
}},
{"endpoint", func() (int64, error) {
return av2.UpsertEndpoint(UpsertEndpointReq{URL: "https://example.com/a", Method: "GET", IP: hostname})
}},
}
for _, tc := range cases {
id, err := tc.call()
if !errors.Is(err, ErrAssetIPInvalid) {
deleteAsset(d, id)
t.Fatalf("%s: err=%v, want %v", tc.name, err, ErrAssetIPInvalid)
}
// The caller has to learn the remedy, not just that something was wrong.
if !strings.Contains(err.Error(), hostname) || !strings.Contains(err.Error(), "type=subdomain") {
t.Fatalf("%s: message is not actionable: %v", tc.name, err)
}
}
// An empty ip stays legal for the types where it is optional, and a real
// address is still accepted.
id, err := av2.UpsertHTTPService(UpsertHTTPServiceReq{URL: "https://ip-guard.example.com/"})
if err != nil {
t.Fatalf("empty ip rejected: %v", err)
}
deleteAsset(d, id)
id, err = av2.UpsertIP(UpsertIPReq{IP: "203.0.113.7"})
if err != nil {
t.Fatalf("valid ip rejected: %v", err)
}
deleteAsset(d, id)
}