diff --git a/CHANGELOG.md b/CHANGELOG.md index 27f22d6..bfd9c84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ #### 新增的功能 - 发现页新增「全部发现」平铺视图并设为默认,与原「按任务分组」视图通过页头 Tab 切换:分组视图要逐个任务展开才看得到漏洞,只想跨任务扫一眼列表时反而绕路。平铺视图是一张跨任务大表(每页 10/20/50/100 条),行为与分组视图完全一致——勾选导出、行内展开看证据与详细报告、行内改名称/类别/严重度、改处置状态、深入、删除。两个视图共用统计卡片与筛选条,切换不丢筛选,当前视图与筛选项一并记在本地;轮询只打当前视图,行内改动会同步写两边缓存,切过去不会看到过期数据。 +- 发现页新增「按资产」视图:左侧资产树(企业 → 根域名 / IP → 子域名 → 服务 → 接口,企业层只在资产确实有归属时才出现),右侧是选中节点整棵子树下的发现,与另外两个视图共用同一张表和同一批筛选条件。树只收有发现的资产,祖先链按需补齐(发现只挂在最深的接口上时,上面几层照样能拼出来),节点上的计数是子树聚合并按发现去重的;资产已删除或本就没关联资产的发现进「未关联资产」桶。与另外两个视图不同,资产视图不轮询——进入视图、改筛选、页内增删改发现、或点树上的刷新按钮时才查询,左树是导航结构,没必要每 5 秒重算。树上每层只显示相对上一层的增量(子域名去掉根域名后缀、服务显示 `https :443`、接口只显示路径),完整值在悬停提示与面包屑里;节点过多时整层丢弃接口/服务层级并给出提示,计数仍计入上层。 ## [0.3.6] - 2026-08-27 diff --git a/db/finding_assets.go b/db/finding_assets.go new file mode 100644 index 0000000..f1cd90d --- /dev/null +++ b/db/finding_assets.go @@ -0,0 +1,669 @@ +package db + +import ( + "encoding/json" + "sort" + "strconv" + "strings" + "time" +) + +// --------------------------------------------------------------------------- +// Finding asset tree — the「按资产」view of the global findings list. +// +// 层级与 BuildCoverageGraph 同源(company → root_domain/ip/app → subdomain → +// service → endpoint),但那份是「某个任务的范围内资产」的力导向图,这份是 +// 「全库有发现的资产」的树:只收有发现的资产及其祖先链,节点带子树聚合计数。 +// 两者的父子优先级规则必须保持一致,改一处请对照 task_scope.go 改另一处。 +// --------------------------------------------------------------------------- + +// FindingUnassignedAsset 是「未关联资产」的节点 key,也是列表接口的筛选哨兵: +// 命中 asset_ids 为空、或所指资产已被删除的发现。 +const FindingUnassignedAsset = "__none__" + +// findingAssetTreeMaxNodes 是返回给前端的节点上限。超出时自底向上丢弃整层 +// (endpoint 优先,其次 service):它们的计数已经累加进父节点,丢节点不丢数字。 +const findingAssetTreeMaxNodes = 3000 + +// FindingAssetNode 是资产树的一个节点。Key 与覆盖图同构:资产行是 "a:"、 +// 企业是 "c:"、没有资产行的根域名是合成的 "r:"、未关联桶是 "__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 是直接挂在该资产上的发现数;Total 含全部子孙且按 finding 去重 + // (一个发现挂多个资产时,只在共同祖先上计一次)。 + 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 是整棵树的一次性快照。Nodes 已排好序:同一父节点下按发现数 +// 降序、标签升序,「未关联资产」恒在最后。 +type FindingAssetTree struct { + Nodes []FindingAssetNode `json:"nodes"` + FindingTotal int `json:"finding_total"` + // Truncated=true 表示为控制体积丢弃了 DroppedKinds 里的层级。 + Truncated bool `json:"truncated"` + DroppedKinds []string `json:"dropped_kinds,omitempty"` +} + +// assetRow 是构树需要的资产字段子集。 +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 复用覆盖图的标签规则(URL > domain > ip > app_name > root_domain)。 +func (a *assetRow) label() string { + n := a.coverageNode() + n.Key = assetKey(a.id) + return coverageNodeLabel(&n) +} + +// hostPort 与覆盖图一致:优先 domain,其次 URL 里的 host,最后 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 是一条发现在构树阶段需要的最小信息。 +type findingAssetHit struct { + severity string + ts time.Time + assetIDs []int64 +} + +// BuildFindingAssetTree 按当前筛选构建资产树。AssetScope 自身不参与(否则树会随 +// 选中节点塌缩成一条链)。 +func (d *DB) BuildFindingAssetTree(f FindingFilter) (*FindingAssetTree, error) { + return d.buildFindingAssetTree(f, findingAssetTreeMaxNodes) +} + +// buildFindingAssetTree 是带节点上限的内部实现。maxNodes<=0 表示不截断——解析 +// AssetScope 时必须用这个模式,否则被丢掉的 endpoint 会让子树 id 集合不全。 +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 + } + + // 计数:一条发现沿它每个资产的祖先链向上,收集去重后的 key 集合再逐个 +1, + // 所以父节点不会因为一条发现挂了多个子资产而重复计数。 + unassigned := &FindingAssetNode{Key: FindingUnassignedAsset, Kind: "none", Label: "未关联资产"} + 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 把一条发现累加到节点上(总数 / 严重度分桶 / 最近发现时间)。 +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 读取命中的资产行,并逐轮补齐祖先(service 的宿主域名/IP、 +// 子域名的根域名)。祖先自身可能没有任何发现,但树需要它们才能成形。 +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 + } + + // 每轮找出还缺父节点的宿主标识,批量补一层;层数固定(endpoint→service→ + // subdomain/ip→root_domain),4 轮足够收敛。 + 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 是一轮补齐里要去库里找的宿主标识,按目标资产类型分开。 +type missingHosts struct { + services []string // endpoint 的宿主(找 service 行) + domains []string // service/endpoint 的宿主域名(找 subdomain 行) + ips []string // service/endpoint 的宿主 IP(找 ip 行) + roots []string // 子域名的根域名(找 root_domain 行) +} + +func (m missingHosts) empty() bool { + return len(m.services) == 0 && len(m.domains) == 0 && len(m.ips) == 0 && len(m.roots) == 0 +} + +// missingParents 汇总还没被加载的宿主:service(供 endpoint 挂靠)、子域名/IP(供 +// service 与 endpoint 挂靠)与根域名(供子域名挂靠)。 +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() + // endpoint 先找同宿主的 service;端口对不上的 service 会以 Total=0 + // 被最终过滤掉,不会污染树。 + 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 粗判一个 host 是不是 IP 字面量(用于决定去 ip 还是 subdomain 表找宿主)。 +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 按宿主标识批量补齐资产行,返回本轮新增的行数。 +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 把资产行变成节点并连上父子关系。父节点缺位时(库里 +// 根本没有那条根域名资产)合成 "r:" 占位节点,与覆盖图的处理一致。 +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 + } + } + } + + // 子域名的根域名在库里没有资产行时,合成一个占位根,免得子域名散成顶层。 + 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 给顶层资产(根域名 / IP / 应用)补企业父节点——只有资产确实 +// 归属了企业才会出现企业层,没归属的资产仍然自己就是顶层。 +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 = "企业 #" + 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 排序:发现多的在前,同数按标签;「未关联资产」恒在最后。 +// 前端按数组顺序挂子节点,所以只要同一父节点下的相对顺序正确即可。 +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 在节点过多时整层丢弃(先 endpoint 再 service)。计数已 +// 累加到父节点,丢的只是可展开的细节层级。 +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 把 AssetScope(节点 key)解析成可用于 SQL 的资产 id 集合。选中 +// 一个节点等于选中它的整棵子树,所以要先把树建出来再收集子孙。 +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 + } + // 不截断:被丢掉的 endpoint 同样要参与 id 收集,否则列表会少数据。 + 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 { + // 选中的节点在当前筛选下已经不存在,结果应当为空而不是退化成不过滤。 + 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 把资产 id 变成 jsonb 包含判断的右操作数集合,配合 +// 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 +} diff --git a/db/finding_assets_test.go b/db/finding_assets_test.go new file mode 100644 index 0000000..d8629b0 --- /dev/null +++ b/db/finding_assets_test.go @@ -0,0 +1,233 @@ +package db + +import ( + "strconv" + "strings" + "testing" +) + +// cleanupTreeFixtures 删除一个用例造出来的资产与发现。必须用 defer 注册(而不是 +// t.Cleanup):t.Cleanup 跑在测试函数返回之后,那时 defer d.Close() 已经把连接关了, +// 清理会静默失败并把脏数据留在共享开发库里。 +func cleanupTreeFixtures(d *DB, taskID int64, rootDomains ...string) { + d.Exec(`DELETE FROM assets WHERE root_domain = ANY($1::text[])`, rootDomains) //nolint:errcheck + d.DeleteFindingsByTask(taskID) //nolint:errcheck +} + +// seedTreeAsset inserts one asset row. +func seedTreeAsset(t *testing.T, d *DB, kind string, cols map[string]any) int64 { + t.Helper() + names := []string{"type"} + values := []any{kind} + placeholders := []string{"$1"} + for k, v := range cols { + values = append(values, v) + names = append(names, k) + placeholders = append(placeholders, "$"+strconv.Itoa(len(values))) + } + q := "INSERT INTO assets(" + strings.Join(names, ",") + ") VALUES (" + + strings.Join(placeholders, ",") + ") RETURNING id" + var id int64 + if err := d.QueryRow(q, values...).Scan(&id); err != nil { + t.Fatalf("seed %s asset: %v", kind, err) + } + return id +} + +func nodeByKey(tree *FindingAssetTree, key string) *FindingAssetNode { + for i := range tree.Nodes { + if tree.Nodes[i].Key == key { + return &tree.Nodes[i] + } + } + return nil +} + +// TestBuildFindingAssetTree covers the whole shape of the「按资产」tree: the +// root→subdomain→service→endpoint chain gets rebuilt from a finding that only +// points at the leaf, ancestors aggregate their subtree, assets without any +// finding stay out, and a finding whose asset row is gone lands in the +// unassigned bucket. +func TestBuildFindingAssetTree(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer d.Close() + + tk, err := d.CreateTask("资产树测试", "目标", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + defer d.DeleteTask(tk.ID) + + const root = "tree-test.example" + const sub = "api.tree-test.example" + defer cleanupTreeFixtures(d, tk.ID, root) + rootID := seedTreeAsset(t, d, "root_domain", map[string]any{"domain": root, "root_domain": root}) + subID := seedTreeAsset(t, d, "subdomain", map[string]any{"domain": sub, "root_domain": root}) + svcID := seedTreeAsset(t, d, "service", map[string]any{ + "domain": sub, "root_domain": root, "url": "https://" + sub, "port": 443, "service_type": "http", + }) + epID := seedTreeAsset(t, d, "endpoint", map[string]any{ + "domain": sub, "root_domain": root, "url": "https://" + sub + "/admin", "port": 443, "method": "GET", + }) + // 同域名下另一个服务,不挂任何发现 —— 不应出现在树里。 + seedTreeAsset(t, d, "service", map[string]any{ + "domain": sub, "root_domain": root, "url": "http://" + sub + ":8080", "port": 8080, "service_type": "http", + }) + + // 只把发现挂在最深的 endpoint 上,祖先链要靠构树自己补出来。 + if _, err := d.AddFinding(tk.ID, 0, "XSS", "反射型 XSS", "high", "s", "e", "w", []int64{epID}); err != nil { + t.Fatal(err) + } + // 直接挂在服务上的一条,用来验证 Self 与 Total 的区别。 + if _, err := d.AddFinding(tk.ID, 0, "Info", "信息泄露", "low", "s", "e", "w", []int64{svcID}); err != nil { + t.Fatal(err) + } + // 资产行不存在(已删除资产)→ 未关联桶。 + if _, err := d.AddFinding(tk.ID, 0, "Misc", "孤儿", "medium", "s", "e", "w", []int64{999000111}); err != nil { + t.Fatal(err) + } + + tree, err := d.BuildFindingAssetTree(FindingFilter{TaskID: strconv.FormatInt(tk.ID, 10)}) + if err != nil { + t.Fatal(err) + } + if tree.FindingTotal != 3 { + t.Fatalf("finding_total: want 3, got %d", tree.FindingTotal) + } + + rootNode := nodeByKey(tree, assetKey(rootID)) + subNode := nodeByKey(tree, assetKey(subID)) + svcNode := nodeByKey(tree, assetKey(svcID)) + epNode := nodeByKey(tree, assetKey(epID)) + for name, n := range map[string]*FindingAssetNode{ + "root": rootNode, "subdomain": subNode, "service": svcNode, "endpoint": epNode, + } { + if n == nil { + t.Fatalf("%s node missing from tree", name) + } + } + + // 父子链:endpoint → service → subdomain → root_domain。 + if epNode.Parent != svcNode.Key { + t.Errorf("endpoint parent: want %s, got %s", svcNode.Key, epNode.Parent) + } + if svcNode.Parent != subNode.Key { + t.Errorf("service parent: want %s, got %s", subNode.Key, svcNode.Parent) + } + if subNode.Parent != rootNode.Key { + t.Errorf("subdomain parent: want %s, got %s", rootNode.Key, subNode.Parent) + } + if rootNode.Parent != "" { + t.Errorf("root parent: want top level, got %s", rootNode.Parent) + } + + // 聚合:根域名两条(endpoint 的 high + service 的 low),service 自身一条、子树两条。 + if rootNode.Total != 2 || rootNode.High != 1 || rootNode.Low != 1 { + t.Errorf("root totals: want 2/high1/low1, got %d/high%d/low%d", rootNode.Total, rootNode.High, rootNode.Low) + } + if rootNode.Self != 0 { + t.Errorf("root self: want 0 (只是祖先), got %d", rootNode.Self) + } + if svcNode.Total != 2 || svcNode.Self != 1 { + t.Errorf("service total/self: want 2/1, got %d/%d", svcNode.Total, svcNode.Self) + } + if epNode.Total != 1 || epNode.Self != 1 { + t.Errorf("endpoint total/self: want 1/1, got %d/%d", epNode.Total, epNode.Self) + } + + // 没有发现的兄弟服务不进树。 + for _, n := range tree.Nodes { + if n.Label == "http://"+sub+":8080" { + t.Errorf("asset without findings should be hidden: %+v", n) + } + } + + // 未关联桶收下那条指向已删资产的发现。 + none := nodeByKey(tree, FindingUnassignedAsset) + if none == nil || none.Total != 1 || none.Medium != 1 { + t.Fatalf("unassigned bucket: want 1 medium, got %+v", none) + } +} + +// TestFindingAssetScopeFilter verifies选中一个节点 narrows the findings list to +// that node's whole subtree, and that the unassigned sentinel works too. +func TestFindingAssetScopeFilter(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer d.Close() + + tk, err := d.CreateTask("资产筛选测试", "目标", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + defer d.DeleteTask(tk.ID) + + const root = "scope-test.example" + const sub = "api.scope-test.example" + const other = "other-scope-test.example" + defer cleanupTreeFixtures(d, tk.ID, root, other) + rootID := seedTreeAsset(t, d, "root_domain", map[string]any{"domain": root, "root_domain": root}) + subID := seedTreeAsset(t, d, "subdomain", map[string]any{"domain": sub, "root_domain": root}) + otherID := seedTreeAsset(t, d, "root_domain", map[string]any{"domain": other, "root_domain": other}) + + if _, err := d.AddFinding(tk.ID, 0, "A", "子域名上的", "high", "s", "e", "w", []int64{subID}); err != nil { + t.Fatal(err) + } + if _, err := d.AddFinding(tk.ID, 0, "B", "别的根域名上的", "high", "s", "e", "w", []int64{otherID}); err != nil { + t.Fatal(err) + } + if _, err := d.AddFinding(tk.ID, 0, "C", "没有资产的", "high", "s", "e", "w", nil); err != nil { + t.Fatal(err) + } + // 指向已删除资产的发现,和 asset_ids 为空的一样属于「未关联」——树的桶收下它, + // 列表筛选也必须查得出来,两处口径不一致会让桶上的数字大于点开后的条数。 + if _, err := d.AddFinding(tk.ID, 0, "D", "资产已删除", "high", "s", "e", "w", []int64{999000333}); err != nil { + t.Fatal(err) + } + + base := FindingFilter{TaskID: strconv.FormatInt(tk.ID, 10)} + cases := []struct { + name string + scope string + want int + }{ + {"整棵子树", assetKey(rootID), 1}, // 根域名下只有子域名那条 + {"叶子节点", assetKey(subID), 1}, // 子域名自身 + {"另一棵树", assetKey(otherID), 1}, // 互不串味 + {"未关联", FindingUnassignedAsset, 2}, // asset_ids 为空的 + 指向已删资产的 + {"不存在的节点", "a:999000222", 0}, // 当前筛选下没有该节点 → 空结果,不是不过滤 + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := base + f.AssetScope = tc.scope + items, total, err := d.ListFindingsPage(f, 1, 50) + if err != nil { + t.Fatal(err) + } + if total != tc.want || len(items) != tc.want { + t.Fatalf("scope %s: want %d findings, got total=%d items=%d", tc.scope, tc.want, total, len(items)) + } + }) + } + + // 不带 scope 时四条都在。 + if _, total, err := d.ListFindingsPage(base, 1, 50); err != nil || total != 4 { + t.Fatalf("unscoped: want 4, got %d (%v)", total, err) + } + + // 树上未关联桶的计数必须与点开后查到的条数一致 —— 这正是两处口径分家时会崩的断言。 + tree, err := d.BuildFindingAssetTree(base) + if err != nil { + t.Fatal(err) + } + none := nodeByKey(tree, FindingUnassignedAsset) + if none == nil || none.Total != 2 { + t.Fatalf("unassigned bucket count: want 2, got %+v", none) + } +} diff --git a/db/findings.go b/db/findings.go index bae1304..18c9a16 100644 --- a/db/findings.go +++ b/db/findings.go @@ -148,6 +148,14 @@ type FindingFilter struct { TaskID string // 任务 id(字符串形式;空/非法 = 不按任务筛选) Query string // 名称/类型/摘要/证据/报告正文的模糊检索关键词 Sort string // "severity" | "time" + // AssetScope 是资产树的节点 key(a: / c: / r: / __none__), + // 选中一个节点等于选中它的整棵子树。空 = 不按资产筛选。 + AssetScope string + + // 下面三个由 applyAssetScope 从 AssetScope 解析而来,调用方不用设置。 + assetIDs []int64 // 子树里所有资产 id + assetNone bool // 只要「未关联资产」的发现 + assetMiss bool // 选中的节点在当前筛选下不存在 → 结果恒空 } // FindingUnassignedTask is the task filter sentinel for findings whose task is @@ -178,6 +186,25 @@ func (f FindingFilter) where() (string, []any) { args = append(args, tid) conds = append(conds, fmt.Sprintf("f.task_id = $%d", len(args))) } + // 资产筛选:asset_ids 是 jsonb 数组,@> ANY(...) 能走 idx_findings_asset_ids。 + switch { + case f.assetMiss: + conds = append(conds, "FALSE") + case f.assetNone: + // 「未关联资产」= asset_ids 为空,或者里面的 id 一个都不在 assets 表里 + // (资产已被删除)。两类都进资产树的未关联桶,这里必须同样收下,否则桶上 + // 的计数会大于点开后能查到的条数。 + conds = append(conds, `( + jsonb_array_length(COALESCE(f.asset_ids, '[]'::jsonb)) = 0 + OR NOT EXISTS ( + SELECT 1 FROM jsonb_array_elements_text(f.asset_ids) e(v) + JOIN assets a ON a.id = e.v::bigint + ) + )`) + case len(f.assetIDs) > 0: + args = append(args, assetIDContainments(f.assetIDs)) + conds = append(conds, fmt.Sprintf("f.asset_ids @> ANY($%d::jsonb[])", len(args))) + } if query := strings.TrimSpace(f.Query); query != "" { escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(query) args = append(args, "%"+escaped+"%") @@ -205,6 +232,10 @@ func (d *DB) ListFindingsPage(f FindingFilter, page, pageSize int) ([]*DBFinding if pageSize <= 0 { pageSize = 20 } + f, err := d.applyAssetScope(f) + if err != nil { + return nil, 0, err + } where, args := f.where() var total int @@ -264,6 +295,10 @@ func (d *DB) ListFindingGroups(f FindingFilter, page, pageSize int) ([]FindingGr if pageSize <= 0 { pageSize = 10 } + f, err := d.applyAssetScope(f) + if err != nil { + return nil, 0, 0, err + } where, args := f.where() grouped := ` FROM findings f LEFT JOIN tasks t ON f.task_id=t.id` + where + ` GROUP BY t.id, t.name, t.description, t.status, t.paused, t.queued` @@ -444,7 +479,11 @@ func (d *DB) ListFindingsForExport(f FindingFilter, ids []int64) ([]*DBFinding, WHERE f.id IN (` + strings.Join(ph, ",") + `) ` + order } else { - where, wargs := f.where() + scoped, err := d.applyAssetScope(f) + if err != nil { + return nil, err + } + where, wargs := scoped.where() q = `SELECT ` + cols + ` FROM findings f LEFT JOIN tasks t ON f.task_id = t.id` + where + ` diff --git a/db/schema.sql b/db/schema.sql index fe6924e..b08a646 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -960,6 +960,8 @@ ALTER TABLE findings ADD COLUMN IF NOT EXISTS report TEXT NOT NULL DEFAULT ''; CREATE INDEX IF NOT EXISTS idx_findings_task ON findings(task_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_findings_time ON findings(created_at DESC); CREATE INDEX IF NOT EXISTS idx_findings_status ON findings(status, created_at DESC); +-- 「按资产」视图靠 asset_ids @> '[]' 反查发现,没有这个 GIN 索引就是全表扫。 +CREATE INDEX IF NOT EXISTS idx_findings_asset_ids ON findings USING GIN(asset_ids jsonb_path_ops); -- ===================================================================== -- M. 后端日志持久化 diff --git a/server/findings_groups.go b/server/findings_groups.go index e370069..f7b47f0 100644 --- a/server/findings_groups.go +++ b/server/findings_groups.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "strings" "unicode/utf8" @@ -24,6 +25,32 @@ func findingPaginationParam(raw string, fallback, upperBound int) int { return value } +// findingFilterFromQuery builds the shared findings filter from a request's +// query string. 列表 / 分组 / 资产树 / 导出走同一份解析,新增筛选项只改这里。 +func findingFilterFromQuery(q url.Values) db.FindingFilter { + return db.FindingFilter{ + Severity: normFilter(q.Get("severity")), + Status: normFilter(q.Get("status")), + VulnClass: normFilter(q.Get("vulnclass")), + // task_id(独立于会切到「按任务节点」分支的 task 参数):全局表按任务筛选。 + TaskID: normFilter(q.Get("task_id")), + Query: q.Get("q"), + Sort: q.Get("sort"), + AssetScope: strings.TrimSpace(q.Get("asset_scope")), + } +} + +// findingAssetTree serves the「按资产」view's left-hand tree: every asset that +// carries at least one matching finding, plus the ancestors needed to place it. +func (s *Server) findingAssetTree(w http.ResponseWriter, r *http.Request) { + tree, err := s.m.pg.BuildFindingAssetTree(findingFilterFromQuery(r.URL.Query())) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, tree) +} + // findingGroups serves the task-group page used by the global findings view. // Findings inside a group continue to use the existing flat findings endpoint, // preserving dashboard/export clients and giving every expanded group its own @@ -32,15 +59,7 @@ func (s *Server) findingGroups(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() page := findingPaginationParam(q.Get("page"), 1, 0) limit := findingPaginationParam(q.Get("limit"), 10, 100) - filter := db.FindingFilter{ - Severity: normFilter(q.Get("severity")), - Status: normFilter(q.Get("status")), - VulnClass: normFilter(q.Get("vulnclass")), - TaskID: normFilter(q.Get("task_id")), - Query: q.Get("q"), - Sort: q.Get("sort"), - } - groups, total, findingTotal, err := s.m.pg.ListFindingGroups(filter, page, limit) + groups, total, findingTotal, err := s.m.pg.ListFindingGroups(findingFilterFromQuery(q), page, limit) if err != nil { writeErr(w, 500, err.Error()) return diff --git a/server/server.go b/server/server.go index 7f0689a..bbd694e 100644 --- a/server/server.go +++ b/server/server.go @@ -775,6 +775,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/exploration/frontier", s.frontier) mux.HandleFunc("GET /api/exploration/findings", s.findings) mux.HandleFunc("GET /api/exploration/findings/groups", s.findingGroups) + mux.HandleFunc("GET /api/exploration/findings/asset-tree", s.findingAssetTree) mux.HandleFunc("GET /api/exploration/findings/stats", s.findingStats) mux.HandleFunc("GET /api/exploration/findings/export", s.findingsExport) mux.HandleFunc("GET /api/exploration/findings/{id}", s.getFinding) @@ -1887,16 +1888,7 @@ func (s *Server) findings(w http.ResponseWriter, r *http.Request) { } page := findingPaginationParam(q.Get("page"), 1, 0) limit := findingPaginationParam(q.Get("limit"), 20, 200) - filter := db.FindingFilter{ - Severity: normFilter(q.Get("severity")), - Status: normFilter(q.Get("status")), - VulnClass: normFilter(q.Get("vulnclass")), - // task_id(独立于会切到「按任务节点」分支的 task 参数):全局表按任务筛选。 - TaskID: normFilter(q.Get("task_id")), - Query: q.Get("q"), - Sort: q.Get("sort"), - } - fs, total, err := s.m.pg.ListFindingsPage(filter, page, limit) + fs, total, err := s.m.pg.ListFindingsPage(findingFilterFromQuery(q), page, limit) if err != nil { writeErr(w, 500, err.Error()) return @@ -2092,14 +2084,7 @@ func (s *Server) findingsExport(w http.ResponseWriter, r *http.Request) { case "all": // 空 filter = 不加任何条件。 case "filtered", "": - filter = db.FindingFilter{ - Severity: normFilter(q.Get("severity")), - Status: normFilter(q.Get("status")), - VulnClass: normFilter(q.Get("vulnclass")), - TaskID: normFilter(q.Get("task_id")), - Query: q.Get("q"), - Sort: q.Get("sort"), - } + filter = findingFilterFromQuery(q) default: writeErr(w, 400, "bad scope: "+scope) return diff --git a/web/src/app/(main)/function/findings/_components/asset-tree.tsx b/web/src/app/(main)/function/findings/_components/asset-tree.tsx new file mode 100644 index 0000000..bfc9246 --- /dev/null +++ b/web/src/app/(main)/function/findings/_components/asset-tree.tsx @@ -0,0 +1,371 @@ +"use client"; + +import * as React from "react"; + +import { + BuildingIcon, + ChevronRightIcon, + CircleDashedIcon, + GlobeIcon, + LayoutTemplateIcon, + LinkIcon, + type LucideIcon, + NetworkIcon, + RefreshCwIcon, + SearchIcon, + SmartphoneIcon, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import type { FindingAssetKind, FindingAssetNode } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +// 图标沿用资产页的类型映射,同一种资产在两处长得一样。 +const KIND_ICON: Record = { + company: BuildingIcon, + root_domain: GlobeIcon, + subdomain: GlobeIcon, + ip: NetworkIcon, + app: SmartphoneIcon, + service: LayoutTemplateIcon, + endpoint: LinkIcon, + none: CircleDashedIcon, +}; + +const KIND_LABEL: Record = { + company: "企业", + root_domain: "根域名", + subdomain: "子域名", + ip: "IP", + app: "应用", + service: "服务", + endpoint: "接口", + none: "未关联", +}; + +// TreeNode 是节点数组组装出来的树。后端已按「同父下发现多的在前」排好序, +// 这里只需按数组顺序挂载。 +interface TreeNode extends FindingAssetNode { + children: TreeNode[]; + depth: number; + /** 树上真正渲染的文字;完整 label 仍保留在 label 里(悬停提示与面包屑用)。 */ + display: string; +} + +function stripBrackets(host: string) { + return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; +} + +function parseAssetURL(raw: string): URL | null { + try { + return new URL(raw); + } catch { + return null; + } +} + +// shortLabel 去掉与父节点重复的前缀。service / endpoint 的 label 是完整 URL,而 +// 宿主域名/IP 上一行已经写过了 —— 深层节点本来就窄,再把 host 重复一遍,真正有 +// 信息量的端口和路径就全被截断掉了。完整值仍在 title 与面包屑里。 +function shortLabel(node: FindingAssetNode, parent?: FindingAssetNode): string { + if (!parent) return node.label; + + // 子域名挂在根域名下:去掉根域名后缀,只留自己那一段。 + if (node.kind === "subdomain" && node.label.endsWith(`.${parent.label}`)) { + return node.label.slice(0, -(parent.label.length + 1)) || node.label; + } + if (node.kind !== "service" && node.kind !== "endpoint") return node.label; + + // 接口挂在服务下:直接砍掉服务那段前缀,剩下的就是路径。 + if (node.label.startsWith(parent.label)) { + return node.label.slice(parent.label.length) || node.label; + } + + const url = parseAssetURL(node.label); + if (!url) return node.label; + // 只有父节点确实就是这个 URL 的宿主时才简写,否则会丢掉辨识信息。 + if (stripBrackets(url.hostname) !== parent.label) return node.label; + + if (node.kind === "endpoint") return `${url.pathname}${url.search}` || "/"; + const scheme = url.protocol.replace(":", ""); + const port = url.port || (url.protocol === "https:" ? "443" : "80"); + return `${scheme} :${port}`; +} + +export function buildAssetTree(nodes: FindingAssetNode[]): TreeNode[] { + const byKey = new Map(); + for (const node of nodes) { + byKey.set(node.key, { ...node, children: [], depth: 0, display: node.label }); + } + const roots: TreeNode[] = []; + for (const node of nodes) { + const current = byKey.get(node.key); + if (!current) continue; + const parent = node.parent ? byKey.get(node.parent) : undefined; + // 父节点缺失(被截断层级丢掉)时上提为顶层,不让子树整个消失。 + if (parent) { + parent.children.push(current); + current.display = shortLabel(node, parent); + } else { + roots.push(current); + } + } + const setDepth = (node: TreeNode, depth: number) => { + node.depth = depth; + for (const child of node.children) setDepth(child, depth + 1); + }; + for (const root of roots) setDepth(root, 0); + return roots; +} + +// assetPathOf 返回从顶层到该节点的路径,用于右侧面包屑。每一级同样只显示相对 +// 上一级的增量(display),完整值留在 label 里。 +export function assetPathOf(nodes: FindingAssetNode[], key: string | null): (FindingAssetNode & { display: string })[] { + if (!key) return []; + const byKey = new Map(nodes.map((n) => [n.key, n])); + const path: FindingAssetNode[] = []; + const seen = new Set(); + let current = byKey.get(key); + while (current && !seen.has(current.key)) { + seen.add(current.key); + path.unshift(current); + current = current.parent ? byKey.get(current.parent) : undefined; + } + return path.map((node, index) => ({ ...node, display: shortLabel(node, path[index - 1]) })); +} + +// filterTree 按关键词过滤:命中的节点保留,并保留其整条祖先链(祖先自身可以不命中)。 +// 命中节点的子孙一并保留,便于继续下钻。 +function filterTree(nodes: TreeNode[], keyword: string): TreeNode[] { + const kw = keyword.trim().toLowerCase(); + if (!kw) return nodes; + const walk = (node: TreeNode): TreeNode | null => { + const hit = node.label.toLowerCase().includes(kw); + if (hit) return node; + const children = node.children.map(walk).filter((c): c is TreeNode => c !== null); + if (children.length === 0) return null; + return { ...node, children }; + }; + return nodes.map(walk).filter((n): n is TreeNode => n !== null); +} + +// collectKeys 收集一棵(子)树里的全部 key,用于「展开全部匹配项」。 +function collectKeys(nodes: TreeNode[], out: Set = new Set()): Set { + for (const node of nodes) { + out.add(node.key); + collectKeys(node.children, out); + } + return out; +} + +interface AssetTreeProps { + nodes: FindingAssetNode[]; + selected: string | null; + onSelect: (key: string | null) => void; + loading?: boolean; + truncated?: boolean; + droppedKinds?: string[]; + /** 未选中任何资产时右侧展示的发现总数,用于「全部资产」那一行。 */ + findingTotal: number; + /** 资产视图不轮询,树的计数靠这个按钮或页面内的增删改来刷新。 */ + onRefresh?: () => void; +} + +export function AssetTree({ + nodes, + selected, + onSelect, + loading, + truncated, + droppedKinds, + findingTotal, + onRefresh, +}: AssetTreeProps) { + const [keyword, setKeyword] = React.useState(""); + const [expanded, setExpanded] = React.useState>(() => new Set()); + // 记住用户手动折叠过的节点,免得「默认展开顶层」在每次刷新后又把它们撑开。 + const [collapsed, setCollapsed] = React.useState>(() => new Set()); + + const roots = React.useMemo(() => buildAssetTree(nodes), [nodes]); + const visible = React.useMemo(() => filterTree(roots, keyword), [roots, keyword]); + + // 搜索时把匹配到的分支全部展开,否则命中项藏在折叠节点里等于没搜。 + const searching = keyword.trim() !== ""; + const searchKeys = React.useMemo(() => (searching ? collectKeys(visible) : null), [searching, visible]); + + const isExpanded = React.useCallback( + (node: TreeNode) => { + if (searchKeys) return searchKeys.has(node.key); + if (expanded.has(node.key)) return true; + // 顶层默认展开一层:再深的层级要用户自己点开,免得一次铺开上千行。 + return node.depth === 0 && !collapsed.has(node.key); + }, + [collapsed, expanded, searchKeys], + ); + + const toggle = React.useCallback( + (node: TreeNode) => { + const open = isExpanded(node); + setExpanded((prev) => { + const next = new Set(prev); + if (open) next.delete(node.key); + else next.add(node.key); + return next; + }); + setCollapsed((prev) => { + const next = new Set(prev); + if (open) next.add(node.key); + else next.delete(node.key); + return next; + }); + }, + [isExpanded], + ); + + let emptyHint = "当前筛选下没有关联到资产的发现。"; + if (loading) emptyHint = "加载中…"; + else if (searching) emptyHint = "没有匹配的资产。"; + + const rows: React.ReactNode[] = []; + const pushRows = (list: TreeNode[]) => { + for (const node of list) { + const open = isExpanded(node); + rows.push( + toggle(node)} + onSelect={() => onSelect(selected === node.key ? null : node.key)} + />, + ); + if (open && node.children.length > 0) pushRows(node.children); + } + }; + pushRows(visible); + + return ( +
+
+ + setKeyword(event.target.value)} + placeholder="过滤资产" + aria-label="过滤资产" + /> + + + + {onRefresh && ( + + )} +
+ + + + +
+ {rows} + {rows.length === 0 &&

{emptyHint}

} +
+
+ + {truncated && ( +

+ 资产过多,已隐藏{(droppedKinds ?? []).map((k) => KIND_LABEL[k as FindingAssetKind] ?? k).join(" / ")} + 层级(计数仍已计入上层)。用筛选或过滤框收窄可看到完整层级。 +

+ )} +
+ ); +} + +function AssetTreeRow({ + node, + open, + selected, + onToggle, + onSelect, +}: { + node: TreeNode; + open: boolean; + selected: boolean; + onToggle: () => void; + onSelect: () => void; +}) { + const Icon = KIND_ICON[node.kind] ?? GlobeIcon; + const hasChildren = node.children.length > 0; + return ( +
+ {hasChildren ? ( + + ) : ( + + )} + + + {node.critical > 0 && ( + + {node.critical} + + )} + {node.high > 0 && ( + + {node.high} + + )} + + {node.total} + + +
+ ); +} diff --git a/web/src/app/(main)/function/findings/page.tsx b/web/src/app/(main)/function/findings/page.tsx index 1cb9f64..33df03d 100644 --- a/web/src/app/(main)/function/findings/page.tsx +++ b/web/src/app/(main)/function/findings/page.tsx @@ -40,9 +40,10 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { api } from "@/lib/api"; import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client"; import { statusMeta } from "@/lib/status"; -import type { Finding, FindingGroup, FindingStats, FindingStatus, Severity } from "@/lib/types"; +import type { Finding, FindingAssetNode, FindingGroup, FindingStats, FindingStatus, Severity } from "@/lib/types"; import { cn } from "@/lib/utils"; +import { AssetTree, assetPathOf } from "./_components/asset-tree"; import { FINDING_STATUSES, type FindingEdit, @@ -57,10 +58,31 @@ import { const FINDING_LIST_PREFERENCE_KEY = "artex_finding_list_preferences"; -// 列表视图:flat = 跨任务平铺大表(默认);grouped = 按任务分组折叠。 -type FindingView = "flat" | "grouped"; +// 列表视图:flat = 跨任务平铺大表(默认);grouped = 按任务分组折叠; +// asset = 左侧资产树 + 右侧该子树下的发现。 +type FindingView = "flat" | "grouped" | "asset"; -const FINDING_VIEWS: FindingView[] = ["flat", "grouped"]; +const FINDING_VIEWS: FindingView[] = ["flat", "grouped", "asset"]; + +// 资产树的一次性快照。与另外两个视图不同,资产视图不轮询:进入视图、改筛选、 +// 或本页改动了发现之后才重新查询。 +interface AssetTreeState { + nodes: FindingAssetNode[]; + findingTotal: number; + truncated: boolean; + droppedKinds: string[]; + loaded: boolean; + loading: boolean; +} + +const EMPTY_ASSET_TREE: AssetTreeState = { + nodes: [], + findingTotal: 0, + truncated: false, + droppedKinds: [], + loaded: false, + loading: false, +}; // 分组视图里每个已展开任务组自带一份分页状态,彼此独立。 interface GroupFindingsState { @@ -110,6 +132,8 @@ export default function FindingsPage() { const [flat, setFlat] = React.useState(EMPTY_FLAT_STATE); const [flatPage, setFlatPage] = React.useState(1); const [flatPageSize, setFlatPageSize] = React.useState(20); + const [assetTree, setAssetTree] = React.useState(EMPTY_ASSET_TREE); + const [assetScope, setAssetScope] = React.useState(null); const [groups, setGroups] = React.useState([]); const [groupTotal, setGroupTotal] = React.useState(0); const [expandedGroups, setExpandedGroups] = React.useState>(() => new Set()); @@ -234,6 +258,7 @@ export default function FindingsPage() { } const flatRequest = React.useRef(0); + const assetTreeRequest = React.useRef(0); const groupRequests = React.useRef>({}); const groupsRequest = React.useRef(0); const flatStateRef = React.useRef(flat); @@ -245,6 +270,9 @@ export default function FindingsPage() { groupFindingsRef.current = groupFindings; visibleGroupKeysRef.current = new Set(groups.map(findingGroupKey)); + // 资产视图右侧列表 = 平铺列表 + 选中子树的筛选,所以两个视图共用一份列表状态。 + const activeAssetScope = view === "asset" ? assetScope : null; + // loadFlat 拉取平铺视图的当前页;task 筛选交给后端,与分组视图共用同一批筛选条件。 const loadFlat = React.useCallback(async () => { const requestFilter = filterFingerprint; @@ -261,6 +289,7 @@ export default function FindingsPage() { task, query, sort, + assetScope: activeAssetScope ?? undefined, }); if (request !== flatRequest.current || activeFilterFingerprint.current !== requestFilter) return; setFlat({ items: result.items, total: result.total, loaded: true, loading: false }); @@ -269,7 +298,32 @@ export default function FindingsPage() { // Polling keeps the last successful snapshot visible. setFlat((current) => ({ ...current, loading: false })); } - }, [filterFingerprint, flatPage, flatPageSize, severity, status, vulnclass, task, query, sort]); + }, [activeAssetScope, filterFingerprint, flatPage, flatPageSize, severity, status, vulnclass, task, query, sort]); + + // loadAssetTree 取整棵资产树。树不随选中节点变化(否则选一下就塌成一条链), + // 所以这里不带 assetScope。 + const loadAssetTree = React.useCallback(async () => { + const requestFilter = filterFingerprint; + if (activeFilterFingerprint.current !== requestFilter) return; + const request = ++assetTreeRequest.current; + setAssetTree((current) => ({ ...current, loading: true })); + try { + const result = await api.findingAssetTree({ severity, status, vulnclass, task, query, sort }); + if (request !== assetTreeRequest.current || activeFilterFingerprint.current !== requestFilter) return; + setAssetTree({ + nodes: result.nodes ?? [], + findingTotal: result.finding_total ?? 0, + truncated: Boolean(result.truncated), + droppedKinds: result.dropped_kinds ?? [], + loaded: true, + loading: false, + }); + } catch (e) { + if (request !== assetTreeRequest.current || activeFilterFingerprint.current !== requestFilter) return; + setAssetTree((current) => ({ ...current, loading: false })); + toast.error(`资产树加载失败:${(e as Error).message}`); + } + }, [filterFingerprint, severity, status, vulnclass, task, query, sort]); const refreshGroups = React.useCallback(async () => { const requestFilter = filterFingerprint; @@ -381,6 +435,12 @@ export default function FindingsPage() { // 行内改动后刷新当前视图:平铺视图重拉当前页,分组视图刷组头 + 该发现所在的组。 const refreshAfterMutation = React.useCallback( (finding: Finding, removed = false) => { + if (view === "asset") { + // 资产视图不轮询,所以改完要顺带把树的计数也重新算一次。 + void loadFlat(); + void loadAssetTree(); + return; + } if (view === "flat") { // 删空最后一页时,页码由越界修正 effect 回退并连带重新加载。 void loadFlat(); @@ -395,7 +455,7 @@ export default function FindingsPage() { void loadGroup(key, Math.min(state.page, lastPage), state.pageSize); } }, - [loadFlat, loadGroup, refreshGroups, view], + [loadAssetTree, loadFlat, loadGroup, refreshGroups, view], ); // Reset every view's pagination and expansion when a shared finding filter changes. @@ -407,14 +467,31 @@ export default function FindingsPage() { setGroupFindings({}); setFlatPage(1); setFlat(EMPTY_FLAT_STATE); + // 筛选变了树也会变,原先选中的节点可能已经不在树里,退回「全部资产」。 + setAssetScope(null); + setAssetTree(EMPTY_ASSET_TREE); }, [filterFingerprint]); + // 换资产节点等于换了一份结果集,回到第一页。 + React.useEffect(() => { + void assetScope; + setFlatPage(1); + }, [assetScope]); + + // 资产树只在进入视图 / 筛选变化时查一次(以及本页改动发现后由 + // refreshAfterMutation 主动重拉),不做轮询。 + React.useEffect(() => { + if (!preferencesHydrated || view !== "asset") return; + void loadAssetTree(); + }, [loadAssetTree, preferencesHydrated, view]); + // 只轮询当前视图:平铺视图刷当前页,分组视图刷组头与每个已展开的组(其分页彼此独立)。 + // 资产视图只查一次(见下面的 return),它的左树是导航结构,没必要每 5 秒重算。 // 等偏好水合后再发首个请求,否则会先按默认视图/筛选白拉一次。 React.useEffect(() => { if (!preferencesHydrated) return; const refresh = () => { - if (view === "flat") { + if (view === "flat" || view === "asset") { if (!flatStateRef.current.loading) void loadFlat(); return; } @@ -426,6 +503,7 @@ export default function FindingsPage() { } }; refresh(); + if (view === "asset") return; const timer = setInterval(refresh, 5000); return () => clearInterval(timer); }, [loadFlat, loadGroup, preferencesHydrated, refreshGroups, view]); @@ -640,7 +718,12 @@ export default function FindingsPage() { ]; // 导出弹窗里「当前筛选」的条数:两个视图的筛选一致,只是统计口径来源不同。 - const filteredTotal = view === "flat" ? flat.total : total; + // 平铺与资产视图共用 flat 列表状态,分组视图的口径来自组接口的 finding_total。 + const filteredTotal = view === "grouped" ? total : flat.total; + const assetPath = React.useMemo( + () => (view === "asset" ? assetPathOf(assetTree.nodes, assetScope) : []), + [assetScope, assetTree.nodes, view], + ); const rowProps = { selectedIds, @@ -658,6 +741,34 @@ export default function FindingsPage() { onDelete: deleteFinding, }; + // 平铺视图与资产视图右侧是同一张表 + 同一份分页,只是筛选条件不同。 + const flatListCard = ( + + + {flat.loading && !flat.loaded ? ( +
+ +
+ ) : ( + <> + + { + setFlatPageSize(nextSize); + setFlatPage(1); + }} + pageSizeOptions={[10, 20, 50, 100]} + /> + + )} +
+
+ ); + return (
@@ -669,6 +780,7 @@ export default function FindingsPage() { 全部发现 按任务分组 + 按资产
@@ -802,32 +914,63 @@ export default function FindingsPage() {
- {view === "flat" ? ( - - - {flat.loading && !flat.loaded ? ( -
- -
- ) : ( - <> - - { - setFlatPageSize(nextSize); - setFlatPage(1); - }} - pageSizeOptions={[10, 20, 50, 100]} + {view === "flat" && flatListCard} + + {view === "asset" && ( +
+ + + {assetTree.loading && !assetTree.loaded ? ( +
+ +
+ ) : ( + void loadAssetTree()} /> - - )} -
-
- ) : ( + )} + + +
+
+ + {assetPath.map((node) => ( + + + ))} + 共 {flat.total} 条 +
+ {flatListCard} +
+
+ )} + + {view === "grouped" && (
{groups.map((group) => { const key = findingGroupKey(group); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index f2b5b3f..d7b8507 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -29,6 +29,7 @@ import type { DeleteTaskResult, Edge, Finding, + FindingAssetTree, FindingDeepenResponse, FindingGroupsPage, FindingQuery, @@ -178,6 +179,20 @@ const del = (p: string, body?: unknown) => const arr = (x: T[] | null | undefined): T[] => x ?? []; const tq = (task?: string, sep: "?" | "&" = "?") => (task ? `${sep}task=${encodeURIComponent(task)}` : ""); +// findingFilterParams 把发现页的筛选条件序列化成 query string。列表 / 分组 / +// 资产树 / 导出共用同一份,新增筛选项只改这里(后端也只解析这一份)。 +function findingFilterParams(q: Omit): URLSearchParams { + const p = new URLSearchParams(); + if (q.severity && q.severity !== "all") p.set("severity", q.severity); + if (q.status && q.status !== "all") p.set("status", q.status); + if (q.vulnclass && q.vulnclass !== "all") p.set("vulnclass", q.vulnclass); + if (q.task && q.task !== "all") p.set("task_id", q.task); + if (q.query?.trim()) p.set("q", q.query.trim()); + if (q.sort) p.set("sort", q.sort); + if (q.assetScope) p.set("asset_scope", q.assetScope); + return p; +} + export const api = { // 后端应用版本号(release 时由 ldflags 注入,默认 "dev")。 health: () => get<{ ok: boolean; service: string; version: string }>("/health"), @@ -456,25 +471,21 @@ export const api = { frontier: (task?: string) => get(`/exploration/frontier${tq(task)}`).then(arr), findings: (task?: string) => get(`/exploration/findings${tq(task)}`).then(arr), findingsPage: (q: FindingQuery) => { - const p = new URLSearchParams({ page: String(q.page), limit: String(q.pageSize) }); - if (q.severity && q.severity !== "all") p.set("severity", q.severity); - if (q.status && q.status !== "all") p.set("status", q.status); - if (q.vulnclass && q.vulnclass !== "all") p.set("vulnclass", q.vulnclass); - if (q.task && q.task !== "all") p.set("task_id", q.task); - if (q.query?.trim()) p.set("q", q.query.trim()); - if (q.sort) p.set("sort", q.sort); + const p = findingFilterParams(q); + p.set("page", String(q.page)); + p.set("limit", String(q.pageSize)); return get(`/exploration/findings?${p.toString()}`); }, findingGroups: (q: FindingQuery) => { - const p = new URLSearchParams({ page: String(q.page), limit: String(q.pageSize) }); - if (q.severity && q.severity !== "all") p.set("severity", q.severity); - if (q.status && q.status !== "all") p.set("status", q.status); - if (q.vulnclass && q.vulnclass !== "all") p.set("vulnclass", q.vulnclass); - if (q.task && q.task !== "all") p.set("task_id", q.task); - if (q.query?.trim()) p.set("q", q.query.trim()); - if (q.sort) p.set("sort", q.sort); + const p = findingFilterParams(q); + p.set("page", String(q.page)); + p.set("limit", String(q.pageSize)); return get(`/exploration/findings/groups?${p.toString()}`); }, + // findingAssetTree 取「按资产」视图的左侧树:只含有发现的资产及其祖先, + // 节点带子树聚合计数。不分页——树是导航结构,一次取完。 + findingAssetTree: (q: Omit) => + get(`/exploration/findings/asset-tree?${findingFilterParams(q).toString()}`), findingStats: () => get("/exploration/findings/stats"), // exportFindings 触发发现页导出并下载文件。scope=selected 时传 ids(finding_id 列表); // scope=filtered 时传当前筛选(沿用 FindingQuery 的筛选字段);scope=all 忽略筛选。 @@ -488,13 +499,7 @@ export const api = { if (opts.scope === "selected") { p.set("ids", (opts.ids ?? []).join(",")); } else if (opts.scope === "filtered" && opts.filters) { - const f = opts.filters; - if (f.severity && f.severity !== "all") p.set("severity", f.severity); - if (f.status && f.status !== "all") p.set("status", f.status); - if (f.vulnclass && f.vulnclass !== "all") p.set("vulnclass", f.vulnclass); - if (f.task && f.task !== "all") p.set("task_id", f.task); - if (f.query?.trim()) p.set("q", f.query.trim()); - if (f.sort) p.set("sort", f.sort); + for (const [k, v] of findingFilterParams(opts.filters)) p.set(k, v); } const token = getToken(); const r = await fetch(`/api/exploration/findings/export?${p.toString()}`, { diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 808ffad..b633f71 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -548,8 +548,41 @@ export interface FindingQuery { task?: string; // 任务 id;"all"/空 = 不按任务筛选 query?: string; sort?: "severity" | "time"; + // 资产树节点 key;选中一个节点 = 选中它的整棵子树。空 = 不按资产筛选。 + assetScope?: string; } +// ---- Findings by asset (资产视图) ---- +export type FindingAssetKind = "company" | "root_domain" | "subdomain" | "ip" | "service" | "app" | "endpoint" | "none"; + +// FindingAssetNode 是资产树的一个节点。key 形如 a:(资产)、c:(企业)、 +// r:(库里没有资产行的根域名)、__none__(未关联资产)。 +export interface FindingAssetNode { + key: string; + parent?: string; + kind: FindingAssetKind; + label: string; + asset_id?: number; + company_id?: number; + self: number; // 直接挂在该资产上的发现数 + total: number; // 含子孙、按发现去重 + critical: number; + high: number; + medium: number; + low: number; + last_found_at: string; +} + +export interface FindingAssetTree { + nodes: FindingAssetNode[]; + finding_total: number; + truncated: boolean; + dropped_kinds?: string[]; +} + +// FINDING_UNASSIGNED_ASSET 与后端 db.FindingUnassignedAsset 对应。 +export const FINDING_UNASSIGNED_ASSET = "__none__"; + // ---- Activity / sessions ---- export type ActivityKind = | "tool_use"