Files
ARTEX/server/findings_groups.go
Autumn-27 23cfb9ed4e 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 与面包屑,搜索仍按完整值匹配
- 不轮询:进入视图 / 改筛选 / 页内改动发现 / 点刷新时才查
- 节点超上限时整层丢弃接口/服务并提示,计数已在上层不会丢
2026-08-31 10:58:32 -04:00

187 lines
5.9 KiB
Go

package server
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"unicode/utf8"
"github.com/Autumn-27/artex/db"
)
const maxFindingFollowUpRunes = 4000
func findingPaginationParam(raw string, fallback, upperBound int) int {
value := atoiDefault(raw, fallback)
if value <= 0 {
value = fallback
}
if upperBound > 0 && value > upperBound {
value = upperBound
}
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
// independent page cursor.
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)
groups, total, findingTotal, err := s.m.pg.ListFindingGroups(findingFilterFromQuery(q), page, limit)
if err != nil {
writeErr(w, 500, err.Error())
return
}
// PostgreSQL owns paused/queued persistence. The live engine adds the same
// derived running/idle view used by task DTOs, so finding groups do not lag
// behind the task list while an Agent is actively executing.
if s.engine != nil {
for i := range groups {
if groups[i].TaskID == nil {
continue
}
if task, ok := s.m.Task(i64s(*groups[i].TaskID)); ok {
groups[i].TaskStatus = s.resolvedTaskStatus(task)
}
}
}
writeJSON(w, 200, map[string]any{
"items": groups,
"total": total,
"finding_total": findingTotal,
"page": page,
"page_size": limit,
})
}
// deepenFinding creates a high-priority human Worker intent under the finding's
// original task. The DB write validates and links the live finding node; task
// admission then revives or queues the task through the shared concurrency path.
func (s *Server) deepenFinding(w http.ResponseWriter, r *http.Request) {
id := int64(atoiDefault(r.PathValue("id"), 0))
if id <= 0 {
writeErr(w, 400, "bad finding id")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 32<<10)
var req struct {
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
writeErr(w, http.StatusRequestEntityTooLarge, "请求正文过大")
} else {
writeErr(w, http.StatusBadRequest, "bad json: "+err.Error())
}
return
}
description := strings.TrimSpace(req.Description)
switch {
case description == "":
writeErr(w, 400, "description is required")
return
case utf8.RuneCountInString(description) > maxFindingFollowUpRunes:
writeErr(w, 400, fmt.Sprintf("description must be at most %d characters", maxFindingFollowUpRunes))
return
}
finding, err := s.m.pg.GetFinding(id)
if err != nil {
writeErr(w, 500, err.Error())
return
}
if finding == nil {
writeErr(w, 404, "finding not found")
return
}
if finding.TaskID == nil || finding.NodeID == nil {
writeErr(w, 409, "finding origin task or node is no longer available")
return
}
t, ok := s.m.Task(i64s(*finding.TaskID))
if !ok || t == nil {
writeErr(w, 409, "finding origin task is no longer available")
return
}
if !s.engine.beginTaskOperation(t.ID) {
writeErr(w, 409, "task is being deleted")
return
}
defer s.engine.decInflight(t.ID)
audit := db.Activity{
Worker: "system",
Kind: "text",
Summary: "人工提交漏洞深入利用意图",
Detail: description,
}
intentID, audit, err := t.Store.AddFindingFollowUpIntent(id, *finding.NodeID, description, audit)
if errors.Is(err, db.ErrFindingOriginUnavailable) {
writeErr(w, 409, "finding origin task or node is no longer available")
return
}
if err != nil {
writeErr(w, 500, err.Error())
return
}
queued, err := s.admitTask(t, "resume")
if err != nil {
if rollbackErr := t.Store.DiscardOpenIntent(intentID); rollbackErr != nil {
err = errors.Join(err, fmt.Errorf("discard follow-up intent %d: %w", intentID, rollbackErr))
}
writeErr(w, 500, err.Error())
return
}
// The audit row committed atomically with the intent. Publish that exact row;
// emitActivity would insert a second copy and break DB/SSE correspondence.
s.engine.Broadcaster().Publish(t.ID, audit)
s.engine.touch(t.ID)
// Admission can wake a Worker immediately. Report the persisted intent state
// observed at response time instead of always claiming it is still open. The
// task-level queued flag remains separate because a queued task intentionally
// keeps its Worker intent open until it receives a concurrency slot.
state := "open"
if current, stateErr := t.Store.GetNode(intentID); stateErr == nil && current != nil {
state = current.State
}
writeJSON(w, 200, map[string]any{
"task_id": t.ID,
"intent_id": i64s(intentID),
"state": state,
"queued": queued,
})
}