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:
1 parent
c588dd67ae
commit
05f0b69469
10 files changed
+508
-83
No files matched your search
+78
-12
@@ -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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user