mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
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
716 lines
21 KiB
Go
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
|
|
}
|