From 05f0b6946951c77e44c263d492645cb4c47f1b36 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:46:46 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20finding=20=E5=A2=9E=E5=8A=A0=E5=A4=84?= =?UTF-8?q?=E7=BD=AE=E7=8A=B6=E6=80=81(=E5=BE=85=E5=A4=84=E7=90=86/?= =?UTF-8?q?=E8=AF=AF=E6=8A=A5/=E5=BF=BD=E7=95=A5/=E5=B7=B2=E5=A4=84?= =?UTF-8?q?=E7=90=86)+=20=E5=8F=91=E7=8E=B0=E5=88=97=E8=A1=A8=E9=A1=B5?= =?UTF-8?q?=E6=94=B9=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - findings 表新增 status 列(默认 pending),schema.sql 同步 ALTER TABLE 补旧库 - 新增 PATCH /api/exploration/findings/{id} 更新处置状态,server 侧白名单校验 - FindingDTO 增加 status/finding_id/assets(后端预渲染资产 label) - 任务内发现节点通过 node_id 关联独立 findings 行,复用同一处置状态 - 发现列表页:漏洞名称(类型主 + 摘要小字)、新增资产列与状态列(可下拉修改)、状态筛选与待处理统计 - 任务详情发现 Tab 同步展示/编辑处置状态与绑定资产 --- db/findings.go | 65 ++++++- db/schema.sql | 5 + server/dto.go | 90 ++++++++-- server/server.go | 79 +++++++-- web/src/app/(main)/function/findings/page.tsx | 166 +++++++++++++++--- .../tasks/detail/_tabs/findings-tab.tsx | 127 +++++++++++--- web/src/lib/api.ts | 4 +- web/src/lib/mock/data.ts | 34 ++-- web/src/lib/status.ts | 8 + web/src/lib/types.ts | 13 ++ 10 files changed, 508 insertions(+), 83 deletions(-) diff --git a/db/findings.go b/db/findings.go index 46b2447..14fc1fa 100644 --- a/db/findings.go +++ b/db/findings.go @@ -16,10 +16,28 @@ type DBFinding struct { Evidence string Worker string AssetIDs []int64 + Status string CreatedAt time.Time TaskDescription string // populated via LEFT JOIN on tasks } +// Finding triage states (findings.status). +const ( + FindingPending = "pending" // 待处理 + FindingFalsePositive = "false_positive" // 误报 + FindingIgnored = "ignored" // 忽略 + FindingResolved = "resolved" // 已处理 +) + +// ValidFindingStatus reports whether s is a known triage state. +func ValidFindingStatus(s string) bool { + switch s { + case FindingPending, FindingFalsePositive, FindingIgnored, FindingResolved: + return true + } + return false +} + // AddFinding inserts a finding into the standalone findings table. taskID and // nodeID may be 0 (stored as NULL). Returns the new finding id. func (d *DB) AddFinding(taskID, nodeID int64, vulnclass, severity, summary, evidence, worker string, assetIDs []int64) (int64, error) { @@ -50,7 +68,7 @@ func (d *DB) ListFindings(limit int) ([]*DBFinding, error) { } rows, err := d.Query(` SELECT f.id, f.task_id, f.node_id, f.vulnclass, f.severity, f.summary, - f.evidence, f.worker, f.asset_ids, f.created_at, + f.evidence, f.worker, f.asset_ids, COALESCE(f.status, 'pending'), f.created_at, COALESCE(t.description, '') AS task_description FROM findings f LEFT JOIN tasks t ON f.task_id = t.id @@ -65,7 +83,7 @@ func (d *DB) ListFindings(limit int) ([]*DBFinding, error) { f := &DBFinding{} var aidsJSON string if err := rows.Scan(&f.ID, &f.TaskID, &f.NodeID, &f.VulnClass, &f.Severity, - &f.Summary, &f.Evidence, &f.Worker, &aidsJSON, &f.CreatedAt, &f.TaskDescription); err != nil { + &f.Summary, &f.Evidence, &f.Worker, &aidsJSON, &f.Status, &f.CreatedAt, &f.TaskDescription); err != nil { return nil, err } _ = json.Unmarshal([]byte(aidsJSON), &f.AssetIDs) @@ -73,3 +91,46 @@ func (d *DB) ListFindings(limit int) ([]*DBFinding, error) { } return out, rows.Err() } + +// SetFindingStatus updates one finding's triage state. Returns rows affected. +func (d *DB) SetFindingStatus(id int64, status string) (int64, error) { + res, err := d.Exec(`UPDATE findings SET status=$1 WHERE id=$2`, status, id) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +// FindingStatusByNodeID maps a task's finding node ids to their standalone-row +// id and triage state, so the per-task view (which reads exploration nodes) can +// show and edit the same status as the global 发现 page. +func (d *DB) FindingStatusByNodeID(taskID int64) (map[int64]struct { + ID int64 + Status string +}, error) { + out := map[int64]struct { + ID int64 + Status string + }{} + if taskID <= 0 { + return out, nil + } + rows, err := d.Query(`SELECT node_id, id, COALESCE(status,'pending') FROM findings + WHERE task_id=$1 AND node_id IS NOT NULL`, taskID) + if err != nil { + return out, err + } + defer rows.Close() + for rows.Next() { + var nid, id int64 + var st string + if err := rows.Scan(&nid, &id, &st); err != nil { + return out, err + } + out[nid] = struct { + ID int64 + Status string + }{ID: id, Status: st} + } + return out, rows.Err() +} diff --git a/db/schema.sql b/db/schema.sql index f4d7fba..098c21c 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -545,10 +545,15 @@ CREATE TABLE IF NOT EXISTS findings ( evidence TEXT NOT NULL DEFAULT '', worker TEXT NOT NULL DEFAULT '', asset_ids JSONB NOT NULL DEFAULT '[]', + -- 处置状态:pending 待处理 / false_positive 误报 / ignored 忽略 / resolved 已处理。 + -- 取值不加 CHECK:旧库靠下面的 ALTER 补列,CHECK 无法回填,统一由 server 侧白名单校验。 + status TEXT NOT NULL DEFAULT 'pending', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +ALTER TABLE findings ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'pending'; 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); -- ===================================================================== -- M. 后端日志持久化 diff --git a/server/dto.go b/server/dto.go index b7b8f35..6bab084 100644 --- a/server/dto.go +++ b/server/dto.go @@ -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 diff --git a/server/server.go b/server/server.go index e25f59f..0ffbfc9 100644 --- a/server/server.go +++ b/server/server.go @@ -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) { diff --git a/web/src/app/(main)/function/findings/page.tsx b/web/src/app/(main)/function/findings/page.tsx index 98020f6..cd6cdf6 100644 --- a/web/src/app/(main)/function/findings/page.tsx +++ b/web/src/app/(main)/function/findings/page.tsx @@ -34,10 +34,19 @@ import { import { TablePagination } from "@/components/table-pagination"; import { cn } from "@/lib/utils"; import { api } from "@/lib/api"; -import type { Finding, Severity } from "@/lib/types"; +import { toast } from "sonner"; +import { statusMeta } from "@/lib/status"; +import type { Finding, FindingStatus, Severity } from "@/lib/types"; const SEVERITY_ORDER: Record = { high: 3, medium: 2, low: 1 }; +const FINDING_STATUSES: FindingStatus[] = [ + "pending", + "false_positive", + "ignored", + "resolved", +]; + function fmtTime(ts: string) { return new Date(ts).toLocaleString("zh-CN", { month: "2-digit", @@ -49,6 +58,7 @@ function fmtTime(ts: string) { export default function FindingsPage() { const [severity, setSeverity] = React.useState<"all" | Severity>("all"); + const [status, setStatus] = React.useState<"all" | FindingStatus>("all"); const [vulnclass, setVulnclass] = React.useState("all"); const [sort, setSort] = React.useState<"severity" | "time">("severity"); const [expanded, setExpanded] = React.useState(null); @@ -74,6 +84,27 @@ export default function FindingsPage() { }; }, []); + // updateStatus optimistically flips one finding's triage state, reverting on error. + const updateStatus = React.useCallback( + async (f: Finding, next: FindingStatus) => { + if (!f.finding_id || next === f.status) return; + const prev = f.status; + setFindings((cur) => + cur.map((x) => (x.id === f.id ? { ...x, status: next } : x)), + ); + try { + await api.setFindingStatus(f.finding_id, next); + toast.success(`已标记为「${statusMeta("finding", next).label}」`); + } catch (e) { + setFindings((cur) => + cur.map((x) => (x.id === f.id ? { ...x, status: prev } : x)), + ); + toast.error("更新失败:" + (e as Error).message); + } + }, + [], + ); + const vulnclasses = React.useMemo( () => Array.from(new Set(findings.map((f) => f.vulnclass))).sort(), [findings], @@ -82,13 +113,14 @@ export default function FindingsPage() { const counts = React.useMemo(() => { const c = { high: 0, medium: 0, low: 0 }; for (const f of findings) c[f.severity]++; - const tasks = new Set(findings.map((f) => f.task_id)).size; - return { ...c, total: findings.length, tasks }; + const pending = findings.filter((f) => f.status === "pending").length; + return { ...c, total: findings.length, pending }; }, [findings]); const rows = React.useMemo(() => { let list = findings.slice(); if (severity !== "all") list = list.filter((f) => f.severity === severity); + if (status !== "all") list = list.filter((f) => f.status === status); if (vulnclass !== "all") list = list.filter((f) => f.vulnclass === vulnclass); list.sort((a, b) => { @@ -99,10 +131,12 @@ export default function FindingsPage() { return new Date(b.ts).getTime() - new Date(a.ts).getTime(); }); return list; - }, [findings, severity, vulnclass, sort]); + }, [findings, severity, status, vulnclass, sort]); // reset to page 1 whenever filters change - React.useEffect(() => { setPage(1); }, [severity, vulnclass, sort]); + React.useEffect(() => { + setPage(1); + }, [severity, status, vulnclass, sort]); const paginated = React.useMemo( () => rows.slice((page - 1) * pageSize, page * pageSize), @@ -111,10 +145,10 @@ export default function FindingsPage() { const statCards: { label: string; value: number; tone?: string }[] = [ { label: "发现总数", value: counts.total }, + { label: "待处理", value: counts.pending, tone: "text-amber-500" }, { label: "高危", value: counts.high, tone: "text-red-500" }, { label: "中危", value: counts.medium, tone: "text-amber-500" }, { label: "低危", value: counts.low, tone: "text-slate-500" }, - { label: "涉及任务", value: counts.tasks }, ]; return ( @@ -129,9 +163,7 @@ export default function FindingsPage() { {s.label} - + {s.value} @@ -164,6 +196,23 @@ export default function FindingsPage() { ))} + + + updateStatus(f, v as FindingStatus) + } + > + + + + + {FINDING_STATUSES.map((st) => ( + + {statusMeta("finding", st).label} + + ))} + + + ) : ( + + )} {f.task_id ? ( @@ -245,7 +353,9 @@ export default function FindingsPage() { className="inline-flex max-w-full items-center gap-1 text-primary hover:underline" title={f.task_description} > - {f.task_description} + + {f.task_description} + ) : ( @@ -258,9 +368,9 @@ export default function FindingsPage() { {open && ( - +
-
+
证据 · {f.id} {f.param_id && ( @@ -268,6 +378,20 @@ export default function FindingsPage() { {f.param_id} )} + {f.assets && f.assets.length > 0 && ( + + · 资产: + {f.assets.map((a) => ( + + {a.label} + + ))} + + )}
                                 {f.evidence}
@@ -282,7 +406,7 @@ export default function FindingsPage() {
                 {rows.length === 0 && (
                   
                     
                       没有匹配的发现。
diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx
index 1cbf8af..0a5957a 100644
--- a/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx
+++ b/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx
@@ -5,32 +5,99 @@ import { ChevronRightIcon } from "lucide-react";
 import { cn } from "@/lib/utils";
 import { Card, CardContent } from "@/components/ui/card";
 import { StatusBadge } from "@/components/status-badge";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+} from "@/components/ui/select";
 import { api } from "@/lib/api";
-import type { Finding } from "@/lib/types";
+import { toast } from "sonner";
+import { statusMeta } from "@/lib/status";
+import type { Finding, FindingStatus } from "@/lib/types";
 
-function Row({ f }: { f: Finding }) {
+const FINDING_STATUSES: FindingStatus[] = [
+  "pending",
+  "false_positive",
+  "ignored",
+  "resolved",
+];
+
+function Row({
+  f,
+  onStatus,
+}: {
+  f: Finding;
+  onStatus: (f: Finding, next: FindingStatus) => void;
+}) {
   const [open, setOpen] = React.useState(false);
   return (
     
- + {f.assets && f.assets.length > 0 && ( +
+ {f.assets.slice(0, 2).map((a) => ( + + {a.label} + + ))} + {f.assets.length > 2 && ( + + +{f.assets.length - 2} + + )} +
+ )} + {f.finding_id ? ( + + ) : ( + + )} + {new Date(f.ts).toLocaleString("zh-CN")} - +
{open && (
@@ -66,6 +133,26 @@ export function FindingsTab({ taskId }: { taskId: string }) { }; }, [taskId]); + const onStatus = React.useCallback( + async (f: Finding, next: FindingStatus) => { + if (!f.finding_id || next === f.status) return; + const prev = f.status; + setFindings((cur) => + cur.map((x) => (x.id === f.id ? { ...x, status: next } : x)), + ); + try { + await api.setFindingStatus(f.finding_id, next); + toast.success(`已标记为「${statusMeta("finding", next).label}」`); + } catch (e) { + setFindings((cur) => + cur.map((x) => (x.id === f.id ? { ...x, status: prev } : x)), + ); + toast.error("更新失败:" + (e as Error).message); + } + }, + [], + ); + const items = findings .filter((f) => f.task_id === taskId) .sort((a, b) => { @@ -77,7 +164,7 @@ export function FindingsTab({ taskId }: { taskId: string }) { {items.map((f) => ( - + ))} {items.length === 0 && (

diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 6653e1b..6907938 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -6,7 +6,7 @@ import { MOCK } from "@/lib/mock/enabled"; import { mockHandle } from "@/lib/mock/handler"; import type { - Task, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, Activity, + Task, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, FindingStatus, Activity, Audit, TrafficResp, TrafficDetail, TrafficHost, Settings, LLMProfile, Agent, AgentDetail, PromptVar, PromptVersion, MCPServer, MCPTool, SkillItem, TokenUsage, TokenTotal, DailyTokenBucket, Tool, Conversation, AgentTrigger, ChatAttachment, @@ -232,6 +232,8 @@ export const api = { // ---- exploration (per task) ---- frontier: (task?: string) => get(`/exploration/frontier${tq(task)}`).then(arr), findings: (task?: string) => get(`/exploration/findings${tq(task)}`).then(arr), + setFindingStatus: (id: string, status: FindingStatus) => + patch<{ id: string; status: FindingStatus }>(`/exploration/findings/${id}`, { status }), intents: (task?: string) => get(`/exploration/intents${tq(task)}`).then(arr), tokenStats: (task?: string) => get<{ workers: TokenUsage[]; total: TokenTotal }>(`/exploration/tokens${tq(task)}`).then((r) => ({ diff --git a/web/src/lib/mock/data.ts b/web/src/lib/mock/data.ts index 43525ac..9136e61 100644 --- a/web/src/lib/mock/data.ts +++ b/web/src/lib/mock/data.ts @@ -161,24 +161,24 @@ export const assetCounts: Record = assets.reducealert(document.domain) 原样回显于结果标题。", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T22:10:00Z") }, - { id: "f-5", vulnclass: "Exposed .git", severity: "medium", summary: "www.acme.com 暴露 .git 目录,可还原源码", evidence: "GET /.git/HEAD → 200;git-dumper 还原出后端源码与数据库连接串注释。", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T20:30:00Z") }, - { id: "f-6", vulnclass: "Default Credentials", severity: "high", summary: "admin.acme.com 后台默认口令 admin/admin123", evidence: "登录成功,进入管理后台,可管理用户与订单。", intent_id: "i-3", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-26T03:50:00Z") }, - { id: "f-7", vulnclass: "Open Redirect", severity: "low", summary: "登录后 next 参数任意跳转", evidence: "/login?next=https://evil.example 登录后 302 跳到外站。", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T19:12:00Z") }, - { id: "f-8", vulnclass: "Missing Rate Limit", severity: "medium", summary: "登录接口无速率限制,可暴力破解", evidence: "1000 次/分钟无锁定,无验证码。", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T18:40:00Z") }, - { id: "f-9", vulnclass: "Verbose Error", severity: "low", summary: "API 500 返回堆栈,泄露路径与框架版本", evidence: "触发 500 返回 Node.js 堆栈,泄露绝对路径与依赖版本。", task_id: "t-acme-api", task_description: "api.acme.com 越权与注入测试", ts: T("2026-07-25T23:05:00Z") }, - { id: "f-10", vulnclass: "Outdated Component", severity: "medium", summary: "shop 使用存在已知 RCE 的老版本组件", evidence: "指纹识别到组件 v2.3.1,对应 CVE-2024-xxxx 反序列化 RCE。", task_id: "t-shop-pay", task_description: "shop.acme.com 支付与订单链路", ts: T("2026-07-23T15:00:00Z") }, + { id: "f-1", vulnclass: "SQL Injection", severity: "high", status: "pending", summary: "www.acme.com/search q 参数存在报错型 SQL 注入", evidence: "GET /search?q=1' AND 1=CONVERT(int,@@version)-- → 返回 MSSQL 版本报错,可读库结构。", intent_id: "i-2", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-26T01:20:00Z") }, + { id: "f-2", vulnclass: "IDOR", severity: "high", status: "pending", summary: "api.acme.com/v1/orders?id= 可越权读取他人订单", evidence: "将 id=1001 改为 id=1002 返回他人订单(含收货地址、手机号),无归属校验。", intent_id: "i-5", task_id: "t-acme-api", task_description: "api.acme.com 越权与注入测试", ts: T("2026-07-26T02:44:00Z") }, + { id: "f-3", vulnclass: "Weak JWT", severity: "high", status: "pending", summary: "API JWT 使用弱密钥、可离线爆破伪造", evidence: "HS256,密钥 'secret',john 5 秒破解 → 可伪造任意 sub 越权。", task_id: "t-acme-api", task_description: "api.acme.com 越权与注入测试", ts: T("2026-07-26T03:02:00Z") }, + { id: "f-4", vulnclass: "Reflected XSS", severity: "medium", status: "pending", summary: "搜索页对 q 参数未转义,反射型 XSS", evidence: "q= 原样回显于结果标题。", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T22:10:00Z") }, + { id: "f-5", vulnclass: "Exposed .git", severity: "medium", status: "pending", summary: "www.acme.com 暴露 .git 目录,可还原源码", evidence: "GET /.git/HEAD → 200;git-dumper 还原出后端源码与数据库连接串注释。", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T20:30:00Z") }, + { id: "f-6", vulnclass: "Default Credentials", severity: "high", status: "pending", summary: "admin.acme.com 后台默认口令 admin/admin123", evidence: "登录成功,进入管理后台,可管理用户与订单。", intent_id: "i-3", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-26T03:50:00Z") }, + { id: "f-7", vulnclass: "Open Redirect", severity: "low", status: "pending", summary: "登录后 next 参数任意跳转", evidence: "/login?next=https://evil.example 登录后 302 跳到外站。", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T19:12:00Z") }, + { id: "f-8", vulnclass: "Missing Rate Limit", severity: "medium", status: "pending", summary: "登录接口无速率限制,可暴力破解", evidence: "1000 次/分钟无锁定,无验证码。", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T18:40:00Z") }, + { id: "f-9", vulnclass: "Verbose Error", severity: "low", status: "pending", summary: "API 500 返回堆栈,泄露路径与框架版本", evidence: "触发 500 返回 Node.js 堆栈,泄露绝对路径与依赖版本。", task_id: "t-acme-api", task_description: "api.acme.com 越权与注入测试", ts: T("2026-07-25T23:05:00Z") }, + { id: "f-10", vulnclass: "Outdated Component", severity: "medium", status: "pending", summary: "shop 使用存在已知 RCE 的老版本组件", evidence: "指纹识别到组件 v2.3.1,对应 CVE-2024-xxxx 反序列化 RCE。", task_id: "t-shop-pay", task_description: "shop.acme.com 支付与订单链路", ts: T("2026-07-23T15:00:00Z") }, // ── 外网→内网纵深链路上的高危发现(active task)── - { id: "f-11", vulnclass: "Hardcoded Credentials", severity: "high", summary: "泄露源码中硬编码数据库凭据 sa/Acme@2021", evidence: "git-dumper 还原 www.acme.com/.git 得到 config.php,内含明文 DB 口令,后续被证实可在内网复用。", intent_id: "ig", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T20:36:00Z") }, - { id: "f-12", vulnclass: "Deserialization RCE", severity: "high", summary: "shop.acme.com Fastjson 1.2.24 反序列化远程命令执行", evidence: "POST /api/import 提交 JNDI payload(@type:JdbcRowSetImpl)→ 回连 dnslog 并落地反弹 shell(www-data@dmz-web01)。", intent_id: "i7", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T15:40:00Z") }, - { id: "f-13", vulnclass: "Privilege Escalation", severity: "high", summary: "DMZ 立足点 sudo NOPASSWD 错配可本地提权 root", evidence: "sudo -l 显示 (ALL) NOPASSWD: /usr/bin/python3 → sudo python3 -c 'os.setuid(0);os.system(\"/bin/sh\")' 直接拿 root。", intent_id: "i8", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T16:20:00Z") }, - { id: "f-14", vulnclass: "Network Segmentation", severity: "medium", summary: "DMZ 主机双网卡直连内网,边界隔离失效", evidence: "dmz-web01 第二网卡在 10.10.10.0/24,可从 DMZ 直达域控/文件服务器/Jenkins,越过网络分区。", intent_id: "i9", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T21:00:00Z") }, - { id: "f-15", vulnclass: "Unauthenticated RCE", severity: "high", summary: "内部 Jenkins 未授权 Script Console 远程命令执行", evidence: "GET /script 无需登录即可执行 Groovy:'whoami'.execute().text → SYSTEM;并从凭据库导出域账号 acme\\svc_deploy。", intent_id: "i11", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T21:50:00Z") }, - { id: "f-16", vulnclass: "Kerberoasting", severity: "high", summary: "域服务账号 svc_sql 可被 Kerberoast 且口令弱", evidence: "GetUserSPNs 请求 svc_sql 的 TGS,hashcat -m 13100 离线破出 Sql@2020,该账号属于 SQL 管理组。", intent_id: "i12", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T23:20:00Z") }, - { id: "f-17", vulnclass: "Domain Compromise", severity: "high", summary: "拿下域控 DC01(Domain Admin)—— 内部靶标达成", evidence: "svc_deploy 属于 Domain Admins,psexec.py 以其登录 DC01 得 SYSTEM,secretsdump 导出全域 NTLM 哈希(含 krbtgt),完成靶标控制。", intent_id: "i13", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-26T00:20:00Z") }, + { id: "f-11", vulnclass: "Hardcoded Credentials", severity: "high", status: "pending", summary: "泄露源码中硬编码数据库凭据 sa/Acme@2021", evidence: "git-dumper 还原 www.acme.com/.git 得到 config.php,内含明文 DB 口令,后续被证实可在内网复用。", intent_id: "ig", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T20:36:00Z") }, + { id: "f-12", vulnclass: "Deserialization RCE", severity: "high", status: "pending", summary: "shop.acme.com Fastjson 1.2.24 反序列化远程命令执行", evidence: "POST /api/import 提交 JNDI payload(@type:JdbcRowSetImpl)→ 回连 dnslog 并落地反弹 shell(www-data@dmz-web01)。", intent_id: "i7", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T15:40:00Z") }, + { id: "f-13", vulnclass: "Privilege Escalation", severity: "high", status: "pending", summary: "DMZ 立足点 sudo NOPASSWD 错配可本地提权 root", evidence: "sudo -l 显示 (ALL) NOPASSWD: /usr/bin/python3 → sudo python3 -c 'os.setuid(0);os.system(\"/bin/sh\")' 直接拿 root。", intent_id: "i8", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T16:20:00Z") }, + { id: "f-14", vulnclass: "Network Segmentation", severity: "medium", status: "pending", summary: "DMZ 主机双网卡直连内网,边界隔离失效", evidence: "dmz-web01 第二网卡在 10.10.10.0/24,可从 DMZ 直达域控/文件服务器/Jenkins,越过网络分区。", intent_id: "i9", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T21:00:00Z") }, + { id: "f-15", vulnclass: "Unauthenticated RCE", severity: "high", status: "pending", summary: "内部 Jenkins 未授权 Script Console 远程命令执行", evidence: "GET /script 无需登录即可执行 Groovy:'whoami'.execute().text → SYSTEM;并从凭据库导出域账号 acme\\svc_deploy。", intent_id: "i11", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T21:50:00Z") }, + { id: "f-16", vulnclass: "Kerberoasting", severity: "high", status: "pending", summary: "域服务账号 svc_sql 可被 Kerberoast 且口令弱", evidence: "GetUserSPNs 请求 svc_sql 的 TGS,hashcat -m 13100 离线破出 Sql@2020,该账号属于 SQL 管理组。", intent_id: "i12", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T23:20:00Z") }, + { id: "f-17", vulnclass: "Domain Compromise", severity: "high", status: "pending", summary: "拿下域控 DC01(Domain Admin)—— 内部靶标达成", evidence: "svc_deploy 属于 Domain Admins,psexec.py 以其登录 DC01 得 SYSTEM,secretsdump 导出全域 NTLM 哈希(含 krbtgt),完成靶标控制。", intent_id: "i13", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-26T00:20:00Z") }, ]; // ── Exploration graph (active task) ────────────────────────────────────────── diff --git a/web/src/lib/status.ts b/web/src/lib/status.ts index 65c1cda..daa3742 100644 --- a/web/src/lib/status.ts +++ b/web/src/lib/status.ts @@ -63,6 +63,13 @@ const severity: Record = { low: { label: "低危", tone: "slate" }, }; +const finding: Record = { + pending: { label: "待处理", tone: "amber" }, + false_positive: { label: "误报", tone: "slate" }, + ignored: { label: "忽略", tone: "neutral" }, + resolved: { label: "已处理", tone: "green" }, +}; + const engine: Record = { exploring: { label: "探索中", tone: "blue" }, paused: { label: "已暂停", tone: "amber" }, @@ -91,6 +98,7 @@ const maps = { intent, task, severity, + finding, engine, goal, audit, diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 3aff6d1..4eb37fd 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -239,16 +239,29 @@ export interface TaskNode { // ---- Findings ---- export type Severity = "high" | "medium" | "low"; +// 漏洞处置状态:待处理 / 误报 / 忽略 / 已处理。 +export type FindingStatus = "pending" | "false_positive" | "ignored" | "resolved"; + +// FindingAsset 是一个漏洞绑定的资产(已在后端预渲染 label)。 +export interface FindingAsset { + id: string; + type: string; + label: string; +} + export interface Finding { id: string; + finding_id?: string; // 独立 findings 表的行 id,状态更新的句柄(任务内旧节点可能缺失) vulnclass: string; severity: Severity; + status: FindingStatus; summary: string; evidence: string; intent_id?: string; param_id?: string; task_id?: string; task_description?: string; + assets?: FindingAsset[]; ts: string; }