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:", a company is "c:", a root // domain without an asset row is a synthesized "r:", 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:" 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 }