Files
ARTEX/db/task_assets_context.go
T
Autumn-27 9e98407528 fix(assets): 企业归属重算不再被单行非法 ip 打挂,并明确告知是哪些资产
assets.ip 是自由文本(Agent 的 insert_assets 和资产 API 都可能写进主机名),
而 db/companies.go 的 IP/CIDR 归属匹配裸转 a.ip::inet。只要表里有一行不是
合法 IP,PostgreSQL 就抛 22P02 中断整条语句,连带同事务的新增/修改企业资产
范围、删除企业全部失败回滚。这个裸转从项目初始化提交就存在。

- schema.sql 新增 try_inet(text):非法值返回 NULL 而不是抛错。没用
  pg_input_is_valid 是因为那要 PG16+,这里要兼容更老的存量库。
- 四处 IP/CIDR 匹配统一改用 try_inet。原先 task_scope.go 和
  task_assets_context.go 用的正则 a.ip !~ '[^0-9a-fA-F:.]' 只检查字符集,
  abc.def 这类全 hex 字母的主机名照样漏过去后炸,一并换掉。
- 跳过不能是静默的:重算会统计并抽样列出这些资产(id + 取值),说明它们不会
  被网段规则归属。信息通过企业范围接口新增的 warnings 字段返回并在前端以
  长停留 toast 展示,同时无条件写入服务端日志,覆盖没有前端响应的路径
  (删除企业、scopesentry 同步、Agent 写入)。
- warnings 与 errors 分开:errors 仍只表示本次提交里不合法的行,既有数据
  问题不该被记到用户这次输入头上。

验证: 干净库上 go test -p 1 ./... 仅剩 1 个既有失败(TestCompanyScopeInvalid,
与本改动无关,stash 后同样失败);改动前在含真实数据的库上有 14 个失败,现已
全部通过。go build / vet / gofmt / tsc / biome 均通过。
2026-08-24 10:16:17 -04:00

225 lines
7.6 KiB
Go

package db
import (
"net/url"
"sort"
"strconv"
"strings"
)
// directTaskContextCTE is the current task plus exactly its explicitly related
// source tasks. It is deliberately non-recursive.
const directTaskContextCTE = `
context_tasks AS (
SELECT t.id AS task_id, t.exploration_id
FROM tasks t
WHERE t.id=$1 AND t.deleted_at IS NULL
UNION ALL
SELECT source.id, source.exploration_id
FROM task_relations relation
JOIN tasks source ON source.id=relation.source_task_id AND source.deleted_at IS NULL
WHERE relation.task_id=$1
)`
const contextCoverageCTE = directTaskContextCTE + `,
target AS (
SELECT DISTINCT a.id, a.type,
COALESCE(a.url, a.domain, a.ip, a.app_name, a.root_domain, '') AS label
FROM assets a
JOIN task_scope ts ON (
(ts.kind='company' AND a.company_id = ts.company_id)
OR (ts.kind='root_domain' AND a.root_domain = ts.domain)
OR (ts.kind='subdomain' AND a.domain = ts.domain)
OR (ts.kind IN ('ip','cidr') AND ts.net >>= try_inet(a.ip))
OR (ts.kind='icp' AND (
lower(regexp_replace(COALESCE(a.icp,''), '[[:space:]]+', '', 'g')) = ts.value
OR lower(regexp_replace(COALESCE(a.app_icp,''), '[[:space:]]+', '', 'g')) = ts.value
))
)
JOIN context_tasks ctx ON ctx.task_id=ts.task_id
UNION
SELECT a.id, a.type,
COALESCE(a.url, a.domain, a.ip, a.app_name, a.root_domain, '') AS label
FROM assets a
JOIN exploration_anchors ea ON ea.asset_id=a.id
JOIN exploration_nodes en ON en.id=ea.node_id
JOIN context_tasks ctx ON ctx.exploration_id=en.exploration_id
),
tested AS (
SELECT DISTINCT ea.asset_id
FROM exploration_anchors ea
JOIN exploration_nodes en ON en.id=ea.node_id AND en.kind='fact'
JOIN context_tasks ctx ON ctx.exploration_id=en.exploration_id
)`
// ListTaskScopeWithSources returns the current task's scope followed by the
// scopes of its direct source tasks. TaskScope.TaskID preserves provenance.
func (s *AssetStore) ListTaskScopeWithSources(taskID int64) ([]TaskScope, error) {
rows, err := s.db.Query(`WITH `+directTaskContextCTE+`
SELECT ts.id, ts.task_id, ts.kind, COALESCE(ts.company_id,0), COALESCE(c.name,''), COALESCE(ts.domain,''),
COALESCE(ts.net::text,''), COALESCE(ts.value,''), ts.source, COALESCE(ts.reason,'')
FROM task_scope ts
JOIN context_tasks ctx ON ctx.task_id=ts.task_id
LEFT JOIN companies c ON c.id=ts.company_id
ORDER BY CASE WHEN ts.task_id=$1 THEN 0 ELSE 1 END, ts.id`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []TaskScope{}
for rows.Next() {
var scope TaskScope
var companyID int64
if err := rows.Scan(&scope.ID, &scope.TaskID, &scope.Kind, &companyID, &scope.CompanyName, &scope.Domain, &scope.Net, &scope.Value, &scope.Source, &scope.Reason); err != nil {
return nil, err
}
if companyID > 0 {
scope.CompanyID = &companyID
}
out = append(out, scope)
}
return out, rows.Err()
}
// TaskCoverageWithSources computes one coverage view over the union of the
// current task and its direct sources: source scopes and anchored assets extend
// the denominator, while fact anchors count as tested. No row is copied.
func (s *AssetStore) TaskCoverageWithSources(taskID int64) (*Coverage, error) {
cov := &Coverage{ByType: []CoverageByType{}}
_ = s.db.QueryRow(`WITH `+directTaskContextCTE+`
SELECT count(*) FROM task_scope ts JOIN context_tasks ctx ON ctx.task_id=ts.task_id`, taskID).Scan(&cov.ScopeRows)
rows, err := s.db.Query(`WITH `+contextCoverageCTE+`
SELECT target.type, count(*) AS total,
count(*) FILTER (WHERE target.id IN (SELECT asset_id FROM tested)) AS tested
FROM target GROUP BY target.type ORDER BY target.type`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var byType CoverageByType
if err := rows.Scan(&byType.Type, &byType.Total, &byType.Tested); err != nil {
return nil, err
}
cov.ByType = append(cov.ByType, byType)
cov.Denominator += byType.Total
cov.Tested += byType.Tested
}
if err := rows.Err(); err != nil {
return nil, err
}
if cov.Denominator > 0 {
pct := float64(cov.Tested) / float64(cov.Denominator)
cov.Pct = &pct
}
return cov, nil
}
// ListUntestedAssetsWithSources is the direct-source-aware backlog query used
// by inherited tasks. Source fact anchors remove assets from the backlog.
func (s *AssetStore) ListUntestedAssetsWithSources(taskID int64, typ string, limit, offset int) ([]CoverageAsset, int, error) {
if limit <= 0 {
limit = 10
}
if offset < 0 {
offset = 0
}
typeFilter := ""
args := []any{taskID}
if typ != "" {
typeFilter = " AND target.type = $2"
args = append(args, typ)
}
var total int
if err := s.db.QueryRow(`WITH `+contextCoverageCTE+`
SELECT count(*) FROM target WHERE target.id NOT IN (SELECT asset_id FROM tested)`+typeFilter, args...).Scan(&total); err != nil {
return nil, 0, err
}
pageArgs := append(append([]any{}, args...), limit, offset)
limitPosition := strconv.Itoa(len(args) + 1)
offsetPosition := strconv.Itoa(len(args) + 2)
rows, err := s.db.Query(`WITH `+contextCoverageCTE+`
SELECT target.id, target.type, target.label FROM target
WHERE target.id NOT IN (SELECT asset_id FROM tested)`+typeFilter+`
ORDER BY target.id LIMIT $`+limitPosition+` OFFSET $`+offsetPosition, pageArgs...)
if err != nil {
return nil, total, err
}
defer rows.Close()
out := []CoverageAsset{}
for rows.Next() {
var asset CoverageAsset
if err := rows.Scan(&asset.ID, &asset.Type, &asset.Label); err != nil {
return nil, total, err
}
out = append(out, asset)
}
return out, total, rows.Err()
}
// HostsByTaskWithSources resolves exact HTTP host candidates from assets that
// are attached to, anchored by, or in scope for the current task or a direct
// source. Traffic remains global and is not copied. This read helper must not be
// used for destructive task cleanup; HostsByTask intentionally retains that
// narrower, task-owned behavior.
func (s *AssetStore) HostsByTaskWithSources(taskID int64) ([]string, error) {
rows, err := s.db.Query(`WITH `+directTaskContextCTE+`,
context_assets AS (
SELECT DISTINCT a.id
FROM assets a
WHERE EXISTS (SELECT 1 FROM context_tasks ctx WHERE ctx.task_id=ANY(a.task_ids))
UNION
SELECT ea.asset_id
FROM exploration_anchors ea
JOIN exploration_nodes en ON en.id=ea.node_id
JOIN context_tasks ctx ON ctx.exploration_id=en.exploration_id
UNION
SELECT DISTINCT a.id
FROM assets a
JOIN task_scope ts ON (
(ts.kind='company' AND a.company_id=ts.company_id)
OR (ts.kind='root_domain' AND a.root_domain=ts.domain)
OR (ts.kind='subdomain' AND a.domain=ts.domain)
OR (ts.kind IN ('ip','cidr') AND ts.net >>= try_inet(a.ip))
OR (ts.kind='icp' AND (
lower(regexp_replace(COALESCE(a.icp,''), '[[:space:]]+', '', 'g')) = ts.value
OR lower(regexp_replace(COALESCE(a.app_icp,''), '[[:space:]]+', '', 'g')) = ts.value
))
)
JOIN context_tasks ctx ON ctx.task_id=ts.task_id
)
SELECT COALESCE(a.domain,''), COALESCE(a.ip,''), COALESCE(a.url,'')
FROM assets a JOIN context_assets ctx ON ctx.id=a.id`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
hosts := map[string]struct{}{}
add := func(host string) {
host = strings.TrimSpace(strings.ToLower(host))
if host != "" {
hosts[host] = struct{}{}
}
}
for rows.Next() {
var domain, ip, rawURL string
if err := rows.Scan(&domain, &ip, &rawURL); err != nil {
return nil, err
}
add(domain)
add(ip)
if parsed, err := url.Parse(rawURL); err == nil {
add(parsed.Hostname())
}
}
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]string, 0, len(hosts))
for host := range hosts {
out = append(out, host)
}
sort.Strings(out)
return out, nil
}