Files
ARTEX/db/finding_assets.go
BaaRai e233c02314 Translate all Chinese text to English
Comments, doc comments, UI labels, log and error messages, LLM prompt
strings, markdown docs, test data and assertions. Identifiers, enum
keys, routes, placeholders and code structure are unchanged.

Notes:
- docs/漏洞流量证据.md renamed to docs/vulnerability-traffic-evidence.md
- tests that exercised CJK specific handling (GBK zip names, Chinese
  full text search) now use ASCII equivalents; go build passes and the
  test suite matches the pre translation baseline (remaining failures
  need a PostgreSQL DSN and fail identically on the original)
- frontend mention syntax now uses English kinds (finding, asset, ...)
  matching the backend, chat mentions tests pass
2026-10-08 23:14:34 +02:00

716 lines
21 KiB
Go

package db
import (
"encoding/json"
"sort"
"strconv"
"strings"
"time"
)
// ---------------------------------------------------------------------------
// Finding asset tree — the "by asset" view of the global findings list.
//
// The hierarchy shares its source with BuildCoverageGraph (company →
// root_domain/ip/app → subdomain → service → endpoint), but that one is a
// force-directed graph of "in-scope assets of one task", while this one is a
// tree of "assets with findings across the whole DB": it only collects assets
// that have findings plus their ancestor chains, and nodes carry subtree
// aggregate counts. The parent-child priority rules of the two must stay
// consistent; when changing one, mirror the other in task_scope.go.
// ---------------------------------------------------------------------------
// FindingUnassignedAsset is the node key of the "unlinked assets" bucket, and
// also the filter sentinel for the list API: it matches findings whose
// asset_ids is empty, or whose referenced assets have been deleted.
const FindingUnassignedAsset = "__none__"
// findingAssetTreeMaxNodes is the node cap returned to the frontend. Beyond it,
// whole levels are dropped bottom-up (endpoint first, then service): their
// counts have already been accumulated into the parent nodes, so nodes are
// dropped without losing numbers.
const findingAssetTreeMaxNodes = 3000
// FindingAssetNode is a node of the asset tree. Key is isomorphic to the
// coverage graph: an asset row is "a:<id>", a company is "c:<id>", a root
// domain without an asset row is a synthesized "r:<domain>", and the unlinked
// bucket is "__none__".
type FindingAssetNode struct {
Key string `json:"key"`
Parent string `json:"parent,omitempty"`
Kind string `json:"kind"` // company|root_domain|subdomain|ip|service|app|endpoint|none
Label string `json:"label"`
AssetID int64 `json:"asset_id,omitempty"`
CompanyID int64 `json:"company_id,omitempty"`
// Self is the number of findings attached directly to this asset; Total
// includes all descendants, deduplicated by finding (a finding attached to
// multiple assets is counted only once, on the common ancestor).
Self int `json:"self"`
Total int `json:"total"`
Critical int `json:"critical"`
High int `json:"high"`
Medium int `json:"medium"`
Low int `json:"low"`
LastFoundAt time.Time `json:"last_found_at"`
}
// FindingAssetTree is a one-shot snapshot of the whole tree. Nodes are already
// sorted: under the same parent, by finding count descending then label
// ascending; "unlinked assets" is always last.
type FindingAssetTree struct {
Nodes []FindingAssetNode `json:"nodes"`
FindingTotal int `json:"finding_total"`
// Truncated=true means the levels in DroppedKinds were dropped to control size.
Truncated bool `json:"truncated"`
DroppedKinds []string `json:"dropped_kinds,omitempty"`
}
// assetRow is the subset of asset fields needed to build the tree.
type assetRow struct {
id int64
kind string
companyID int64
domain string
rootDomain string
ip string
url string
port int
serviceType string
appName string
}
func (a *assetRow) coverageNode() CoverageGraphNode {
return CoverageGraphNode{
Kind: a.kind, Domain: a.domain, RootDomain: a.rootDomain, IP: a.ip,
URL: a.url, Port: a.port, ServiceType: a.serviceType, AppName: a.appName,
}
}
// label reuses the coverage graph's label rule (URL > domain > ip > app_name >
// root_domain), but services without a URL (SMB, non-HTTP ports, etc.) get the
// port appended: otherwise their label would be identical to the host
// IP/domain row, and the parent-child pair would look fully duplicated on the
// tree.
func (a *assetRow) label() string {
if a.kind == "service" && a.url == "" {
if host, port := a.hostPort(); host != "" && port > 0 {
return host + ":" + strconv.Itoa(port)
}
}
n := a.coverageNode()
n.Key = assetKey(a.id)
return coverageNodeLabel(&n)
}
// hostPort matches the coverage graph: domain first, then the host in the URL,
// then ip.
func (a *assetRow) hostPort() (string, int) {
n := a.coverageNode()
return hostPortOf(&n)
}
const findingAssetSelectCols = `a.id, a.type, COALESCE(a.company_id,0),
COALESCE(a.domain,''), COALESCE(a.root_domain,''), COALESCE(a.ip,''),
COALESCE(a.url,''), COALESCE(a.port,0), COALESCE(a.service_type,''),
COALESCE(a.app_name,'')`
func scanAssetRows(rows interface {
Next() bool
Scan(...any) error
Err() error
Close() error
}) ([]*assetRow, error) {
defer rows.Close()
var out []*assetRow
for rows.Next() {
a := &assetRow{}
if err := rows.Scan(&a.id, &a.kind, &a.companyID, &a.domain, &a.rootDomain,
&a.ip, &a.url, &a.port, &a.serviceType, &a.appName); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
// findingAssetHit is the minimal information one finding needs during tree
// building.
type findingAssetHit struct {
severity string
ts time.Time
assetIDs []int64
}
// BuildFindingAssetTree builds the asset tree under the current filter.
// AssetScope itself does not participate (otherwise the tree would collapse
// into a single chain around the selected node).
func (d *DB) BuildFindingAssetTree(f FindingFilter) (*FindingAssetTree, error) {
return d.buildFindingAssetTree(f, findingAssetTreeMaxNodes)
}
// buildFindingAssetTree is the internal implementation with a node cap.
// maxNodes<=0 means no truncation — this mode must be used when resolving
// AssetScope, otherwise dropped endpoints would leave the subtree id set
// incomplete.
func (d *DB) buildFindingAssetTree(f FindingFilter, maxNodes int) (*FindingAssetTree, error) {
f.AssetScope = ""
f.assetIDs, f.assetNone, f.assetMiss = nil, false, false
where, args := f.where()
rows, err := d.Query(`SELECT COALESCE(f.severity,''), f.created_at,
COALESCE(f.asset_ids::text,'[]')
FROM findings f LEFT JOIN tasks t ON f.task_id = t.id`+where, args...)
if err != nil {
return nil, err
}
hits := []findingAssetHit{}
assetIDs := map[int64]bool{}
for rows.Next() {
var h findingAssetHit
var aidsJSON string
if err := rows.Scan(&h.severity, &h.ts, &aidsJSON); err != nil {
rows.Close()
return nil, err
}
_ = json.Unmarshal([]byte(aidsJSON), &h.assetIDs)
for _, id := range h.assetIDs {
if id > 0 {
assetIDs[id] = true
}
}
hits = append(hits, h)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
rows.Close()
tree := &FindingAssetTree{Nodes: []FindingAssetNode{}, FindingTotal: len(hits)}
byID, err := d.loadFindingAssetRows(assetIDs)
if err != nil {
return nil, err
}
nodes, parentOf := d.assembleFindingAssetNodes(byID)
if err := d.attachCompanyNodes(nodes, parentOf); err != nil {
return nil, err
}
// Counting: a finding walks up the ancestor chain of each of its assets,
// collecting a deduplicated set of keys before incrementing each by 1, so a
// parent node is never double-counted when one finding hangs off multiple
// child assets.
unassigned := &FindingAssetNode{Key: FindingUnassignedAsset, Kind: "none", Label: "Unlinked assets"}
touched := map[string]bool{}
for _, h := range hits {
clear(touched)
var direct []*FindingAssetNode
for _, id := range h.assetIDs {
node := nodes[assetKey(id)]
if node == nil {
continue
}
direct = append(direct, node)
for key := node.Key; key != ""; key = parentOf[key] {
touched[key] = true
}
}
if len(direct) == 0 {
countFinding(unassigned, h)
unassigned.Self++
continue
}
for _, node := range direct {
node.Self++
}
for key := range touched {
countFinding(nodes[key], h)
}
}
for _, node := range nodes {
if node.Total > 0 {
tree.Nodes = append(tree.Nodes, *node)
}
}
if unassigned.Total > 0 {
tree.Nodes = append(tree.Nodes, *unassigned)
}
sortFindingAssetNodes(tree.Nodes)
truncateFindingAssetTree(tree, maxNodes)
return tree, nil
}
// countFinding accumulates one finding onto a node (total / severity buckets /
// most recent finding time).
func countFinding(n *FindingAssetNode, h findingAssetHit) {
if n == nil {
return
}
n.Total++
switch h.severity {
case "critical":
n.Critical++
case "high":
n.High++
case "medium":
n.Medium++
case "low":
n.Low++
}
if h.ts.After(n.LastFoundAt) {
n.LastFoundAt = h.ts
}
}
// loadFindingAssetRows reads the matched asset rows and fills in ancestors round
// by round (a service's host domain/IP, a subdomain's root domain). An ancestor
// may itself have no findings, but the tree needs it to take shape.
func (d *DB) loadFindingAssetRows(ids map[int64]bool) (map[int64]*assetRow, error) {
byID := map[int64]*assetRow{}
if len(ids) == 0 {
return byID, nil
}
idList := make([]int64, 0, len(ids))
for id := range ids {
idList = append(idList, id)
}
rows, err := d.Query(`SELECT `+findingAssetSelectCols+` FROM assets a WHERE a.id = ANY($1::bigint[])`, idList)
if err != nil {
return nil, err
}
found, err := scanAssetRows(rows)
if err != nil {
return nil, err
}
for _, a := range found {
byID[a.id] = a
}
// Each round finds the host identifiers still missing a parent node and
// backfills one layer in bulk; the number of layers is fixed
// (endpoint→service→subdomain/ip→root_domain), so 4 rounds are enough to
// converge.
for range 4 {
want := missingParents(byID)
if want.empty() {
break
}
added, err := d.loadAssetsByHost(want, byID)
if err != nil {
return nil, err
}
if added == 0 {
break
}
}
return byID, nil
}
// missingHosts holds the host identifiers to look up in one backfill round,
// separated by the target asset kind.
type missingHosts struct {
services []string // endpoint hosts (looking for service rows)
domains []string // host domains of services/endpoints (looking for subdomain rows)
ips []string // host IPs of services/endpoints (looking for ip rows)
roots []string // root domains of subdomains (looking for root_domain rows)
}
func (m missingHosts) empty() bool {
return len(m.services) == 0 && len(m.domains) == 0 && len(m.ips) == 0 && len(m.roots) == 0
}
// missingParents collects the hosts not yet loaded: services (for endpoints to
// attach to), subdomains/IPs (for services and endpoints to attach to), and
// root domains (for subdomains to attach to).
func missingParents(byID map[int64]*assetRow) missingHosts {
haveService := map[string]bool{}
haveDomain := map[string]bool{}
haveIP := map[string]bool{}
haveRoot := map[string]bool{}
for _, a := range byID {
switch a.kind {
case "service":
if host, _ := a.hostPort(); host != "" {
haveService[host] = true
}
case "subdomain":
haveDomain[a.domain] = true
case "ip":
haveIP[a.ip] = true
case "root_domain":
haveRoot[a.domain] = true
}
}
wantService := map[string]bool{}
wantDomain := map[string]bool{}
wantIP := map[string]bool{}
wantRoot := map[string]bool{}
for _, a := range byID {
switch a.kind {
case "service", "endpoint":
host, _ := a.hostPort()
// An endpoint first looks for a service on the same host; a service
// whose port does not match ends up with Total=0 and is filtered out
// at the end, so it never pollutes the tree.
if a.kind == "endpoint" && host != "" && !haveService[host] {
wantService[host] = true
}
if host != "" && !haveDomain[host] && !haveIP[host] && !haveRoot[host] {
if isIPLiteral(host) {
wantIP[host] = true
} else {
wantDomain[host] = true
}
}
if a.ip != "" && !haveIP[a.ip] {
wantIP[a.ip] = true
}
case "subdomain":
if a.rootDomain != "" && !haveRoot[a.rootDomain] {
wantRoot[a.rootDomain] = true
}
}
}
return missingHosts{
services: keysOf(wantService),
domains: keysOf(wantDomain),
ips: keysOf(wantIP),
roots: keysOf(wantRoot),
}
}
func keysOf(m map[string]bool) []string {
if len(m) == 0 {
return nil
}
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// isIPLiteral roughly judges whether a host is an IP literal (used to decide
// whether to look up the host in the ip or the subdomain table).
func isIPLiteral(host string) bool {
if strings.Contains(host, ":") {
return true // IPv6
}
if host == "" {
return false
}
for _, part := range strings.Split(host, ".") {
if part == "" {
return false
}
if _, err := strconv.Atoi(part); err != nil {
return false
}
}
return strings.Count(host, ".") == 3
}
// loadAssetsByHost backfills asset rows in bulk by host identifier, returning
// the number of rows added in this round.
func (d *DB) loadAssetsByHost(want missingHosts, byID map[int64]*assetRow) (int, error) {
added := 0
load := func(q string, arg []string) error {
if len(arg) == 0 {
return nil
}
rows, err := d.Query(q, arg)
if err != nil {
return err
}
found, err := scanAssetRows(rows)
if err != nil {
return err
}
for _, a := range found {
if _, ok := byID[a.id]; ok {
continue
}
byID[a.id] = a
added++
}
return nil
}
if err := load(`SELECT `+findingAssetSelectCols+` FROM assets a
WHERE a.type='service' AND (a.domain = ANY($1::text[]) OR a.ip = ANY($1::text[]))`, want.services); err != nil {
return 0, err
}
if err := load(`SELECT `+findingAssetSelectCols+` FROM assets a
WHERE a.type='subdomain' AND a.domain = ANY($1::text[])`, want.domains); err != nil {
return 0, err
}
if err := load(`SELECT `+findingAssetSelectCols+` FROM assets a
WHERE a.type='ip' AND a.ip = ANY($1::text[])`, want.ips); err != nil {
return 0, err
}
if err := load(`SELECT `+findingAssetSelectCols+` FROM assets a
WHERE a.type='root_domain' AND a.domain = ANY($1::text[])`, want.roots); err != nil {
return 0, err
}
return added, nil
}
// assembleFindingAssetNodes turns asset rows into nodes and wires up the
// parent-child relations. When the parent is absent (the DB simply has no such
// root-domain asset), a "r:<domain>" placeholder node is synthesized, matching
// how the coverage graph handles it.
func (d *DB) assembleFindingAssetNodes(byID map[int64]*assetRow) (map[string]*FindingAssetNode, map[string]string) {
nodes := map[string]*FindingAssetNode{}
parentOf := map[string]string{}
rootByDomain := map[string]string{}
subByDomain := map[string]string{}
ipByAddr := map[string]string{}
svcByHost := map[string]string{}
svcByHostPort := map[string]string{}
for _, a := range byID {
key := assetKey(a.id)
nodes[key] = &FindingAssetNode{
Key: key, Kind: a.kind, Label: a.label(),
AssetID: a.id, CompanyID: a.companyID,
}
switch a.kind {
case "root_domain":
if a.domain != "" {
rootByDomain[a.domain] = key
}
case "subdomain":
if a.domain != "" {
subByDomain[a.domain] = key
}
case "ip":
if a.ip != "" {
ipByAddr[a.ip] = key
}
case "service":
if host, port := a.hostPort(); host != "" {
svcByHost[host] = key
svcByHostPort[host+"|"+strconv.Itoa(port)] = key
}
}
}
// When a subdomain's root domain has no asset row in the DB, synthesize a
// placeholder root so subdomains do not scatter into top-level nodes.
for _, a := range byID {
if a.kind != "subdomain" || a.rootDomain == "" {
continue
}
if _, ok := rootByDomain[a.rootDomain]; ok {
continue
}
key := "r:" + a.rootDomain
nodes[key] = &FindingAssetNode{Key: key, Kind: "root_domain", Label: a.rootDomain}
rootByDomain[a.rootDomain] = key
}
firstOf := func(keys ...string) string {
for _, k := range keys {
if k != "" {
if _, ok := nodes[k]; ok {
return k
}
}
}
return ""
}
for _, a := range byID {
key := assetKey(a.id)
var parent string
switch a.kind {
case "subdomain":
parent = firstOf(rootByDomain[a.rootDomain])
case "service":
host, _ := a.hostPort()
parent = firstOf(subByDomain[a.domain], subByDomain[host],
ipByAddr[a.ip], ipByAddr[host], rootByDomain[a.rootDomain], rootByDomain[host])
case "endpoint":
host, port := a.hostPort()
parent = firstOf(svcByHostPort[host+"|"+strconv.Itoa(port)], svcByHost[host],
subByDomain[host], subByDomain[a.domain], ipByAddr[host], ipByAddr[a.ip],
rootByDomain[a.rootDomain], rootByDomain[host])
}
if parent != "" && parent != key {
parentOf[key] = parent
nodes[key].Parent = parent
}
}
return nodes, parentOf
}
// attachCompanyNodes adds company parent nodes for top-level assets (root
// domains / IPs / apps) — the company layer only appears when an asset is
// actually attributed to a company; unattributed assets stay top-level
// themselves.
func (d *DB) attachCompanyNodes(nodes map[string]*FindingAssetNode, parentOf map[string]string) error {
want := map[int64]bool{}
for _, n := range nodes {
if n.Parent != "" || n.CompanyID <= 0 {
continue
}
switch n.Kind {
case "root_domain", "ip", "app":
want[n.CompanyID] = true
}
}
if len(want) == 0 {
return nil
}
ids := make([]int64, 0, len(want))
for id := range want {
ids = append(ids, id)
}
rows, err := d.Query(`SELECT id, COALESCE(name,'') FROM companies WHERE id = ANY($1::bigint[])`, ids)
if err != nil {
return err
}
defer rows.Close()
names := map[int64]string{}
for rows.Next() {
var id int64
var name string
if err := rows.Scan(&id, &name); err != nil {
return err
}
names[id] = name
}
if err := rows.Err(); err != nil {
return err
}
for id, name := range names {
key := companyKey(id)
if _, ok := nodes[key]; ok {
continue
}
if name == "" {
name = "Company #" + strconv.FormatInt(id, 10)
}
nodes[key] = &FindingAssetNode{Key: key, Kind: "company", Label: name, CompanyID: id}
}
for _, n := range nodes {
if n.Parent != "" || n.CompanyID <= 0 || n.Kind == "company" {
continue
}
switch n.Kind {
case "root_domain", "ip", "app":
key := companyKey(n.CompanyID)
if _, ok := nodes[key]; !ok {
continue
}
n.Parent = key
parentOf[n.Key] = key
}
}
return nil
}
// sortFindingAssetNodes sorts: more findings first, ties broken by label;
// "unlinked assets" is always last. The frontend attaches children in array
// order, so only the relative order under the same parent needs to be correct.
func sortFindingAssetNodes(nodes []FindingAssetNode) {
sort.SliceStable(nodes, func(i, j int) bool {
a, b := nodes[i], nodes[j]
if (a.Kind == "none") != (b.Kind == "none") {
return b.Kind == "none"
}
if a.Total != b.Total {
return a.Total > b.Total
}
return a.Label < b.Label
})
}
// truncateFindingAssetTree drops whole levels when there are too many nodes
// (endpoint first, then service). Counts have already been accumulated into
// parent nodes; only the expandable detail levels are dropped.
func truncateFindingAssetTree(tree *FindingAssetTree, maxNodes int) {
if maxNodes <= 0 || len(tree.Nodes) <= maxNodes {
return
}
for _, kind := range []string{"endpoint", "service"} {
kept := tree.Nodes[:0]
for _, n := range tree.Nodes {
if n.Kind == kind {
continue
}
kept = append(kept, n)
}
tree.Nodes = kept
tree.Truncated = true
tree.DroppedKinds = append(tree.DroppedKinds, kind)
if len(tree.Nodes) <= maxNodes {
return
}
}
}
// applyAssetScope resolves AssetScope (a node key) into a set of asset ids
// usable in SQL. Selecting a node means selecting its whole subtree, so the
// tree must be built first and then the descendants collected.
func (d *DB) applyAssetScope(f FindingFilter) (FindingFilter, error) {
scope := strings.TrimSpace(f.AssetScope)
f.assetIDs, f.assetNone, f.assetMiss = nil, false, false
if scope == "" {
return f, nil
}
if scope == FindingUnassignedAsset {
f.assetNone = true
return f, nil
}
// No truncation: dropped endpoints must still take part in id collection,
// otherwise the list would lose rows.
tree, err := d.buildFindingAssetTree(f, 0)
if err != nil {
return f, err
}
children := map[string][]FindingAssetNode{}
byKey := map[string]FindingAssetNode{}
for _, n := range tree.Nodes {
byKey[n.Key] = n
children[n.Parent] = append(children[n.Parent], n)
}
if _, ok := byKey[scope]; !ok {
// The selected node no longer exists under the current filter; the result
// should be empty rather than degrade into no filtering.
f.assetMiss = true
return f, nil
}
seen := map[string]bool{scope: true}
queue := []string{scope}
for len(queue) > 0 {
key := queue[0]
queue = queue[1:]
if id := byKey[key].AssetID; id > 0 {
f.assetIDs = append(f.assetIDs, id)
}
for _, child := range children[key] {
if seen[child.Key] {
continue
}
seen[child.Key] = true
queue = append(queue, child.Key)
}
}
if len(f.assetIDs) == 0 {
f.assetMiss = true
}
return f, nil
}
// assetIDContainments turns asset ids into the right-hand operand set of a
// jsonb containment check, used with idx_findings_asset_ids (GIN
// jsonb_path_ops).
func assetIDContainments(ids []int64) []string {
out := make([]string, 0, len(ids))
for _, id := range ids {
out = append(out, "["+strconv.FormatInt(id, 10)+"]")
}
return out
}