feat: finding 增加处置状态(待处理/误报/忽略/已处理)+ 发现列表页改版

- findings 表新增 status 列(默认 pending),schema.sql 同步 ALTER TABLE 补旧库
- 新增 PATCH /api/exploration/findings/{id} 更新处置状态,server 侧白名单校验
- FindingDTO 增加 status/finding_id/assets(后端预渲染资产 label)
- 任务内发现节点通过 node_id 关联独立 findings 行,复用同一处置状态
- 发现列表页:漏洞名称(类型主 + 摘要小字)、新增资产列与状态列(可下拉修改)、状态筛选与待处理统计
- 任务详情发现 Tab 同步展示/编辑处置状态与绑定资产
This commit is contained in:
Autumn-27 committed 2026-08-18 09:46:46 -04:00
1 parent c588dd67ae
commit 05f0b69469
10 files changed
+508 -83

No files matched your search

+78 -12
View File
@@ -2,6 +2,7 @@ package server
import (
"encoding/json"
"fmt"
"strconv"
"time"
@@ -171,16 +172,57 @@ func edgeDTOs(in []db.Edge) []EdgeDTO {
// ---- Finding (frontend "Finding") ----
type FindingDTO struct {
ID string `json:"id"`
VulnClass string `json:"vulnclass"`
Severity string `json:"severity"`
Summary string `json:"summary"`
Evidence string `json:"evidence"`
IntentID string `json:"intent_id,omitempty"`
ParamID string `json:"param_id,omitempty"`
TaskID string `json:"task_id,omitempty"`
TaskDescription string `json:"task_description,omitempty"`
TS string `json:"ts"`
ID string `json:"id"`
FindingID string `json:"finding_id,omitempty"` // standalone findings-table id — the handle for status updates
VulnClass string `json:"vulnclass"`
Severity string `json:"severity"`
Status string `json:"status"` // pending | false_positive | ignored | resolved
Summary string `json:"summary"`
Evidence string `json:"evidence"`
IntentID string `json:"intent_id,omitempty"`
ParamID string `json:"param_id,omitempty"`
TaskID string `json:"task_id,omitempty"`
TaskDescription string `json:"task_description,omitempty"`
Assets []FindingAssetDTO `json:"assets,omitempty"`
TS string `json:"ts"`
}
// FindingAssetDTO is one asset a finding is anchored to, pre-labelled for display.
type FindingAssetDTO struct {
ID string `json:"id"`
Type string `json:"type"`
Label string `json:"label"`
}
// assetLabel renders an asset's most identifying field for compact display.
func assetLabel(a *db.Asset) string {
switch {
case a.URL != "":
if a.Method != "" {
return a.Method + " " + a.URL
}
return a.URL
case a.Domain != "":
if a.Port != nil && *a.Port > 0 {
return fmt.Sprintf("%s:%d", a.Domain, *a.Port)
}
return a.Domain
case a.IP != "":
if a.Port != nil && *a.Port > 0 {
return fmt.Sprintf("%s:%d", a.IP, *a.Port)
}
return a.IP
case a.AppName != "":
return a.AppName
case a.BundleID != "":
return a.BundleID
case a.ServiceName != "":
return a.ServiceName
case a.RootDomain != "":
return a.RootDomain
default:
return "#" + i64s(a.ID)
}
}
// findingPayload mirrors the JSON written by the worker's report_finding tool
@@ -199,6 +241,7 @@ func findingDTO(n *db.Node) FindingDTO {
ID: i64s(n.ID),
VulnClass: p.VulnClass,
Severity: p.Severity,
Status: db.FindingPending,
Summary: p.Summary,
Evidence: rawString(p.Evidence),
TS: rfc3339(n.CreatedAt),
@@ -207,12 +250,22 @@ func findingDTO(n *db.Node) FindingDTO {
// findingDTOsForTask converts a task's finding nodes to DTOs, stamping each with
// the owning task's id/description so the global 发现 page can group across tasks.
func findingDTOsForTask(t *Task, in []*db.Node) []FindingDTO {
// triage maps node id → the standalone findings row (id + status), so the per-task
// view shows the same triage state as the global page; nodes with no row keep the
// 'pending' default and no finding_id (status is then not editable).
func findingDTOsForTask(t *Task, in []*db.Node, triage map[int64]struct {
ID int64
Status string
}) []FindingDTO {
out := make([]FindingDTO, 0, len(in))
for _, n := range in {
d := findingDTO(n)
d.TaskID = t.ID
d.TaskDescription = t.Description
if r, ok := triage[n.ID]; ok {
d.FindingID = i64s(r.ID)
d.Status = r.Status
}
out = append(out, d)
}
return out
@@ -220,15 +273,28 @@ func findingDTOsForTask(t *Task, in []*db.Node) []FindingDTO {
// findingFromDB converts a standalone DBFinding row to a FindingDTO. task_id and
// task_description are empty when the originating task has been deleted (NULL).
func findingFromDB(f *db.DBFinding) FindingDTO {
func findingFromDB(f *db.DBFinding, assets map[int64]*db.Asset) FindingDTO {
status := f.Status
if status == "" {
status = db.FindingPending
}
d := FindingDTO{
ID: i64s(f.ID),
FindingID: i64s(f.ID),
VulnClass: f.VulnClass,
Severity: f.Severity,
Status: status,
Summary: f.Summary,
Evidence: f.Evidence,
TS: rfc3339(f.CreatedAt),
}
for _, aid := range f.AssetIDs {
a := assets[aid]
if a == nil {
continue
}
d.Assets = append(d.Assets, FindingAssetDTO{ID: i64s(a.ID), Type: a.Type, Label: assetLabel(a)})
}
if f.TaskID != nil {
d.TaskID = i64s(*f.TaskID)
d.TaskDescription = f.TaskDescription
+69 -10
View File
@@ -577,8 +577,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/workspace/upload", s.wsUpload)
mux.HandleFunc("GET /api/tasks/{id}/scope", s.taskScopeList)
mux.HandleFunc("POST /api/tasks/{id}/control", s.control)
mux.HandleFunc("POST /api/tasks/{id}/intents/{iid}/rerun", s.rerunIntent) // 重跑单条 blocked/exhausted/stopped 意图
mux.HandleFunc("POST /api/tasks/{id}/intents/rerun-blocked", s.rerunBlocked) // 批量重跑本任务全部 blocked 意图
mux.HandleFunc("POST /api/tasks/{id}/intents/{iid}/rerun", s.rerunIntent) // 重跑单条 blocked/exhausted/stopped 意图
mux.HandleFunc("POST /api/tasks/{id}/intents/rerun-blocked", s.rerunBlocked) // 批量重跑本任务全部 blocked 意图
mux.HandleFunc("POST /api/active", s.setActive)
mux.HandleFunc("GET /api/llm", s.getLLM)
@@ -601,6 +601,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("PATCH /api/exploration/findings/{id}", s.setFindingStatus)
mux.HandleFunc("GET /api/exploration/intents", s.intents)
mux.HandleFunc("GET /api/exploration/graph", s.explorationGraph)
mux.HandleFunc("GET /api/exploration/activity", s.activity)
@@ -1064,12 +1065,12 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
}
type createTaskReq struct {
Description string `json:"description"`
Goal string `json:"goal"`
LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // 指定运行本任务的 LLM 配置;省略/null=用激活配置
TimeoutSeconds int `json:"timeout_seconds"` // 任务级超时(秒);0/省略=不限时
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒);0/省略=默认600(10min);下限=默认=600,低于自动抬到600
SeedFirstIntent *bool `json:"seed_first_intent,omitempty"` // 创建时直接下发一条种子意图(内容=描述+目标),让 worker 免等首轮 planner 直接开跑;省略/null=默认关闭,走标准先规划再执行。显式传 true 才开(CTF 常一 work 解决时可省掉开跑前的 planner 轮)。
Description string `json:"description"`
Goal string `json:"goal"`
LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // 指定运行本任务的 LLM 配置;省略/null=用激活配置
TimeoutSeconds int `json:"timeout_seconds"` // 任务级超时(秒);0/省略=不限时
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒);0/省略=默认600(10min);下限=默认=600,低于自动抬到600
SeedFirstIntent *bool `json:"seed_first_intent,omitempty"` // 创建时直接下发一条种子意图(内容=描述+目标),让 worker 免等首轮 planner 直接开跑;省略/null=默认关闭,走标准先规划再执行。显式传 true 才开(CTF 常一 work 解决时可省掉开跑前的 planner 轮)。
}
func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
@@ -1351,9 +1352,31 @@ func (s *Server) findings(w http.ResponseWriter, r *http.Request) {
taskParam := r.URL.Query().Get("task")
if taskParam == "" {
fs, _ := s.m.pg.ListFindings(500)
// Resolve every anchored asset in one query so each finding can carry a
// display label for its assets.
seen := map[int64]bool{}
var ids []int64
for _, f := range fs {
for _, aid := range f.AssetIDs {
if aid > 0 && !seen[aid] {
seen[aid] = true
ids = append(ids, aid)
}
}
}
assets := map[int64]*db.Asset{}
if len(ids) > 0 {
list, err := s.m.pg.Assets().GetByIDs(ids)
if err != nil {
log.Printf("[findings] resolve assets: %v", err)
}
for _, a := range list {
assets[a.ID] = a
}
}
out := make([]FindingDTO, 0, len(fs))
for _, f := range fs {
out = append(out, findingFromDB(f))
out = append(out, findingFromDB(f, assets))
}
writeJSON(w, 200, out)
return
@@ -1364,7 +1387,43 @@ func (s *Server) findings(w http.ResponseWriter, r *http.Request) {
return
}
f, _ := t.Store.ListByKind(db.KindFinding, 200)
writeJSON(w, 200, findingDTOsForTask(t, f))
tid, _ := strconv.ParseInt(t.ID, 10, 64)
triage, err := s.m.pg.FindingStatusByNodeID(tid)
if err != nil {
log.Printf("[findings] task=%s triage: %v", t.ID, err)
}
writeJSON(w, 200, findingDTOsForTask(t, f, triage))
}
// setFindingStatus updates one finding's triage state (pending / false_positive /
// ignored / resolved). The id is the standalone findings-table id (DTO finding_id).
func (s *Server) setFindingStatus(w http.ResponseWriter, r *http.Request) {
id := int64(atoiDefault(r.PathValue("id"), 0))
if id <= 0 {
writeErr(w, 400, "bad finding id")
return
}
var body struct {
Status string `json:"status"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, 400, "bad json: "+err.Error())
return
}
if !db.ValidFindingStatus(body.Status) {
writeErr(w, 400, "bad status: "+body.Status)
return
}
n, err := s.m.pg.SetFindingStatus(id, body.Status)
if err != nil {
writeErr(w, 500, err.Error())
return
}
if n == 0 {
writeErr(w, 404, "finding not found")
return
}
writeJSON(w, 200, map[string]any{"id": i64s(id), "status": body.Status})
}
func (s *Server) intents(w http.ResponseWriter, r *http.Request) {