feat(findings): 发现页新增「按资产」视图
左侧资产树 + 右侧该子树下的发现,与另外两个视图共用同一张表和同一批筛选。 后端 - db/finding_assets.go:按当前筛选构建资产树。只收有发现的资产,祖先链逐层 补齐(endpoint→service→subdomain/ip→root_domain),父子优先级与 BuildCoverageGraph 同源;计数沿祖先链去重累加,一条发现挂多个资产不会让 父节点重复计数 - FindingFilter 加 AssetScope:选中节点=选中整棵子树,解析成资产 id 集合后 下推 SQL,列表 / 分组 / 导出三个入口共用,导出「当前筛选」自动带上 - 「未关联资产」同时收 asset_ids 为空与指向已删资产的发现,与树上桶的口径 一致(只判空数组会让桶上的数字大于点开后的条数) - findingFilterFromQuery 统一四处的筛选解析;新增 GIN 索引 idx_findings_asset_ids,按资产反查发现不再全表扫 前端 - 树默认展开一层,支持过滤(匹配项自动展开)、面包屑下钻、手动刷新 - 每层只显示相对上一层的增量:子域名去根域名后缀、服务显示 `https :443`、 接口只留路径。完整值在 title 与面包屑,搜索仍按完整值匹配 - 不轮询:进入视图 / 改筛选 / 页内改动发现 / 点刷新时才查 - 节点超上限时整层丢弃接口/服务并提示,计数已在上层不会丢
This commit is contained in:
1 parent
796dbf5600
commit
23cfb9ed4e
11 files changed
+1582
-82
No files matched your search
@@ -15,6 +15,7 @@
|
||||
#### 新增的功能
|
||||
|
||||
- 发现页新增「全部发现」平铺视图并设为默认,与原「按任务分组」视图通过页头 Tab 切换:分组视图要逐个任务展开才看得到漏洞,只想跨任务扫一眼列表时反而绕路。平铺视图是一张跨任务大表(每页 10/20/50/100 条),行为与分组视图完全一致——勾选导出、行内展开看证据与详细报告、行内改名称/类别/严重度、改处置状态、深入、删除。两个视图共用统计卡片与筛选条,切换不丢筛选,当前视图与筛选项一并记在本地;轮询只打当前视图,行内改动会同步写两边缓存,切过去不会看到过期数据。
|
||||
- 发现页新增「按资产」视图:左侧资产树(企业 → 根域名 / IP → 子域名 → 服务 → 接口,企业层只在资产确实有归属时才出现),右侧是选中节点整棵子树下的发现,与另外两个视图共用同一张表和同一批筛选条件。树只收有发现的资产,祖先链按需补齐(发现只挂在最深的接口上时,上面几层照样能拼出来),节点上的计数是子树聚合并按发现去重的;资产已删除或本就没关联资产的发现进「未关联资产」桶。与另外两个视图不同,资产视图不轮询——进入视图、改筛选、页内增删改发现、或点树上的刷新按钮时才查询,左树是导航结构,没必要每 5 秒重算。树上每层只显示相对上一层的增量(子域名去掉根域名后缀、服务显示 `https :443`、接口只显示路径),完整值在悬停提示与面包屑里;节点过多时整层丢弃接口/服务层级并给出提示,计数仍计入上层。
|
||||
|
||||
## [0.3.6] - 2026-08-27
|
||||
|
||||
|
||||
@@ -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:<id>"、
|
||||
// 企业是 "c:<id>"、没有资产行的根域名是合成的 "r:<domain>"、未关联桶是 "__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:<domain>" 占位节点,与覆盖图的处理一致。
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+40
-1
@@ -148,6 +148,14 @@ type FindingFilter struct {
|
||||
TaskID string // 任务 id(字符串形式;空/非法 = 不按任务筛选)
|
||||
Query string // 名称/类型/摘要/证据/报告正文的模糊检索关键词
|
||||
Sort string // "severity" | "time"
|
||||
// AssetScope 是资产树的节点 key(a:<id> / c:<id> / r:<domain> / __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 + `
|
||||
|
||||
@@ -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 @> '[<id>]' 反查发现,没有这个 GIN 索引就是全表扫。
|
||||
CREATE INDEX IF NOT EXISTS idx_findings_asset_ids ON findings USING GIN(asset_ids jsonb_path_ops);
|
||||
|
||||
-- =====================================================================
|
||||
-- M. 后端日志持久化
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-18
@@ -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
|
||||
|
||||
@@ -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<FindingAssetKind, LucideIcon> = {
|
||||
company: BuildingIcon,
|
||||
root_domain: GlobeIcon,
|
||||
subdomain: GlobeIcon,
|
||||
ip: NetworkIcon,
|
||||
app: SmartphoneIcon,
|
||||
service: LayoutTemplateIcon,
|
||||
endpoint: LinkIcon,
|
||||
none: CircleDashedIcon,
|
||||
};
|
||||
|
||||
const KIND_LABEL: Record<FindingAssetKind, string> = {
|
||||
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<string, TreeNode>();
|
||||
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<string>();
|
||||
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<string> = new Set()): Set<string> {
|
||||
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<Set<string>>(() => new Set());
|
||||
// 记住用户手动折叠过的节点,免得「默认展开顶层」在每次刷新后又把它们撑开。
|
||||
const [collapsed, setCollapsed] = React.useState<Set<string>>(() => 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(
|
||||
<AssetTreeRow
|
||||
key={node.key}
|
||||
node={node}
|
||||
open={open}
|
||||
selected={selected === node.key}
|
||||
onToggle={() => toggle(node)}
|
||||
onSelect={() => onSelect(selected === node.key ? null : node.key)}
|
||||
/>,
|
||||
);
|
||||
if (open && node.children.length > 0) pushRows(node.children);
|
||||
}
|
||||
};
|
||||
pushRows(visible);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<InputGroup className="flex-1">
|
||||
<InputGroupInput
|
||||
type="search"
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="过滤资产"
|
||||
aria-label="过滤资产"
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-8 shrink-0 text-muted-foreground"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
aria-label="刷新资产树"
|
||||
title="刷新资产树"
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(null)}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-sm",
|
||||
selected === null ? "bg-accent font-medium" : "hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<span>全部资产</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{findingTotal}</span>
|
||||
</button>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="flex flex-col pr-2">
|
||||
{rows}
|
||||
{rows.length === 0 && <p className="px-2 py-8 text-center text-xs text-muted-foreground">{emptyHint}</p>}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{truncated && (
|
||||
<p className="px-1 text-xs text-muted-foreground">
|
||||
资产过多,已隐藏{(droppedKinds ?? []).map((k) => KIND_LABEL[k as FindingAssetKind] ?? k).join(" / ")}
|
||||
层级(计数仍已计入上层)。用筛选或过滤框收窄可看到完整层级。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-center gap-1 rounded-md pr-1 text-sm",
|
||||
selected ? "bg-accent" : "hover:bg-accent/50",
|
||||
)}
|
||||
style={{ paddingLeft: `${node.depth * 10}px` }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground"
|
||||
aria-label={open ? "折叠" : "展开"}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<ChevronRightIcon className={cn("size-3.5 transition-transform", open && "rotate-90")} />
|
||||
</button>
|
||||
) : (
|
||||
<span className="size-5 shrink-0" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 py-1 text-left"
|
||||
title={`${KIND_LABEL[node.kind] ?? node.kind} · ${node.label}`}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span className={cn("min-w-0 truncate", selected && "font-medium")}>{node.display}</span>
|
||||
</button>
|
||||
<span className="flex shrink-0 items-center gap-1 text-xs tabular-nums">
|
||||
{node.critical > 0 && (
|
||||
<span className="text-rose-600" title={`严重 ${node.critical}`}>
|
||||
{node.critical}
|
||||
</span>
|
||||
)}
|
||||
{node.high > 0 && (
|
||||
<span className="text-red-500" title={`高危 ${node.high}`}>
|
||||
{node.high}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground" title={`共 ${node.total} 条发现`}>
|
||||
{node.total}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<FlatFindingsState>(EMPTY_FLAT_STATE);
|
||||
const [flatPage, setFlatPage] = React.useState(1);
|
||||
const [flatPageSize, setFlatPageSize] = React.useState(20);
|
||||
const [assetTree, setAssetTree] = React.useState<AssetTreeState>(EMPTY_ASSET_TREE);
|
||||
const [assetScope, setAssetScope] = React.useState<string | null>(null);
|
||||
const [groups, setGroups] = React.useState<FindingGroup[]>([]);
|
||||
const [groupTotal, setGroupTotal] = React.useState(0);
|
||||
const [expandedGroups, setExpandedGroups] = React.useState<Set<string>>(() => new Set());
|
||||
@@ -234,6 +258,7 @@ export default function FindingsPage() {
|
||||
}
|
||||
|
||||
const flatRequest = React.useRef(0);
|
||||
const assetTreeRequest = React.useRef(0);
|
||||
const groupRequests = React.useRef<Record<string, number>>({});
|
||||
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 = (
|
||||
<Card className="gap-0 py-0">
|
||||
<CardContent className="px-0">
|
||||
{flat.loading && !flat.loaded ? (
|
||||
<div className="flex min-h-36 items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FindingsTable items={flat.items} selectAllLabel="选择当前页全部" {...rowProps} />
|
||||
<TablePagination
|
||||
page={flatPage}
|
||||
pageSize={flatPageSize}
|
||||
total={flat.total}
|
||||
onPageChange={setFlatPage}
|
||||
onPageSizeChange={(nextSize) => {
|
||||
setFlatPageSize(nextSize);
|
||||
setFlatPage(1);
|
||||
}}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 md:gap-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
@@ -669,6 +780,7 @@ export default function FindingsPage() {
|
||||
<TabsList>
|
||||
<TabsTrigger value="flat">全部发现</TabsTrigger>
|
||||
<TabsTrigger value="grouped">按任务分组</TabsTrigger>
|
||||
<TabsTrigger value="asset">按资产</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -802,32 +914,63 @@ export default function FindingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === "flat" ? (
|
||||
<Card className="gap-0 py-0">
|
||||
<CardContent className="px-0">
|
||||
{flat.loading && !flat.loaded ? (
|
||||
<div className="flex min-h-36 items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FindingsTable items={flat.items} selectAllLabel="选择当前页全部" {...rowProps} />
|
||||
<TablePagination
|
||||
page={flatPage}
|
||||
pageSize={flatPageSize}
|
||||
total={flat.total}
|
||||
onPageChange={setFlatPage}
|
||||
onPageSizeChange={(nextSize) => {
|
||||
setFlatPageSize(nextSize);
|
||||
setFlatPage(1);
|
||||
}}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
{view === "flat" && flatListCard}
|
||||
|
||||
{view === "asset" && (
|
||||
<div className="grid min-h-0 items-start gap-4 lg:grid-cols-[20rem_minmax(0,1fr)] xl:grid-cols-[24rem_minmax(0,1fr)]">
|
||||
<Card className="gap-0 py-3 lg:sticky lg:top-4">
|
||||
<CardContent className="flex max-h-[24rem] flex-col px-3 lg:max-h-[calc(100vh-14rem)]">
|
||||
{assetTree.loading && !assetTree.loaded ? (
|
||||
<div className="flex min-h-36 items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<AssetTree
|
||||
nodes={assetTree.nodes}
|
||||
selected={assetScope}
|
||||
onSelect={setAssetScope}
|
||||
loading={assetTree.loading}
|
||||
truncated={assetTree.truncated}
|
||||
droppedKinds={assetTree.droppedKinds}
|
||||
findingTotal={assetTree.findingTotal}
|
||||
onRefresh={() => void loadAssetTree()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1 text-sm text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
className={cn("hover:text-foreground", assetScope === null && "font-medium text-foreground")}
|
||||
onClick={() => setAssetScope(null)}
|
||||
>
|
||||
全部资产
|
||||
</button>
|
||||
{assetPath.map((node) => (
|
||||
<React.Fragment key={node.key}>
|
||||
<ChevronRightIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"max-w-[16rem] truncate hover:text-foreground",
|
||||
node.key === assetScope && "font-medium text-foreground",
|
||||
)}
|
||||
title={node.label}
|
||||
onClick={() => setAssetScope(node.key)}
|
||||
>
|
||||
{node.display}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<span className="ml-auto shrink-0 text-xs tabular-nums">共 {flat.total} 条</span>
|
||||
</div>
|
||||
{flatListCard}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === "grouped" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{groups.map((group) => {
|
||||
const key = findingGroupKey(group);
|
||||
|
||||
+26
-21
@@ -29,6 +29,7 @@ import type {
|
||||
DeleteTaskResult,
|
||||
Edge,
|
||||
Finding,
|
||||
FindingAssetTree,
|
||||
FindingDeepenResponse,
|
||||
FindingGroupsPage,
|
||||
FindingQuery,
|
||||
@@ -178,6 +179,20 @@ const del = <T>(p: string, body?: unknown) =>
|
||||
const arr = <T>(x: T[] | null | undefined): T[] => x ?? [];
|
||||
const tq = (task?: string, sep: "?" | "&" = "?") => (task ? `${sep}task=${encodeURIComponent(task)}` : "");
|
||||
|
||||
// findingFilterParams 把发现页的筛选条件序列化成 query string。列表 / 分组 /
|
||||
// 资产树 / 导出共用同一份,新增筛选项只改这里(后端也只解析这一份)。
|
||||
function findingFilterParams(q: Omit<FindingQuery, "page" | "pageSize">): 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<TaskNode[]>(`/exploration/frontier${tq(task)}`).then(arr),
|
||||
findings: (task?: string) => get<Finding[]>(`/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<FindingsPage>(`/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<FindingGroupsPage>(`/exploration/findings/groups?${p.toString()}`);
|
||||
},
|
||||
// findingAssetTree 取「按资产」视图的左侧树:只含有发现的资产及其祖先,
|
||||
// 节点带子树聚合计数。不分页——树是导航结构,一次取完。
|
||||
findingAssetTree: (q: Omit<FindingQuery, "page" | "pageSize">) =>
|
||||
get<FindingAssetTree>(`/exploration/findings/asset-tree?${findingFilterParams(q).toString()}`),
|
||||
findingStats: () => get<FindingStats>("/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()}`, {
|
||||
|
||||
@@ -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:<id>(资产)、c:<id>(企业)、
|
||||
// r:<domain>(库里没有资产行的根域名)、__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"
|
||||
|
||||
Reference in new issue
Block a user