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

+63 -2
View File
@@ -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()
}
+5
View File
@@ -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. 后端日志持久化
+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) {
+145 -21
View File
@@ -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<Severity, number> = { 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<string>("all");
const [sort, setSort] = React.useState<"severity" | "time">("severity");
const [expanded, setExpanded] = React.useState<string | null>(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() {
<Card key={s.label} className="gap-1 py-4">
<CardHeader className="px-4">
<CardDescription>{s.label}</CardDescription>
<CardTitle
className={cn("text-2xl tabular-nums", s.tone)}
>
<CardTitle className={cn("text-2xl tabular-nums", s.tone)}>
{s.value}
</CardTitle>
</CardHeader>
@@ -164,6 +196,23 @@ export default function FindingsPage() {
))}
</div>
<Select
value={status}
onValueChange={(v) => setStatus(v as "all" | FindingStatus)}
>
<SelectTrigger size="sm" className="w-32">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
{FINDING_STATUSES.map((st) => (
<SelectItem key={st} value={st}>
{statusMeta("finding", st).label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={vulnclass} onValueChange={setVulnclass}>
<SelectTrigger size="sm" className="w-40">
<SelectValue placeholder="漏洞类型" />
@@ -203,8 +252,9 @@ export default function FindingsPage() {
<TableRow>
<TableHead className="w-8" />
<TableHead className="w-20">严重度</TableHead>
<TableHead className="w-28">漏洞类型</TableHead>
<TableHead>摘要</TableHead>
<TableHead>漏洞名称</TableHead>
<TableHead className="w-52">资产</TableHead>
<TableHead className="w-28">状态</TableHead>
<TableHead className="w-36 max-w-[9rem]">所属任务</TableHead>
<TableHead className="w-32">时间</TableHead>
</TableRow>
@@ -229,13 +279,71 @@ export default function FindingsPage() {
<TableCell>
<StatusBadge domain="severity" value={f.severity} dot />
</TableCell>
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
{f.vulnclass}
</code>
</TableCell>
<TableCell className="max-w-md">
<span className="line-clamp-1">{f.summary}</span>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate font-medium">
{f.vulnclass || "未分类"}
</span>
<span className="truncate text-xs text-muted-foreground">
{f.summary}
</span>
</div>
</TableCell>
<TableCell className="w-52">
{f.assets && f.assets.length > 0 ? (
<div className="flex flex-wrap gap-1">
{f.assets.slice(0, 3).map((a) => (
<code
key={a.id}
className="max-w-[12rem] truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs"
title={`${a.type} · ${a.label}`}
>
{a.label}
</code>
))}
{f.assets.length > 3 && (
<span className="text-xs text-muted-foreground">
+{f.assets.length - 3}
</span>
)}
</div>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
{f.finding_id ? (
<Select
value={f.status}
onValueChange={(v) =>
updateStatus(f, v as FindingStatus)
}
>
<SelectTrigger
size="sm"
className="h-7 w-full border-none px-1 shadow-none focus-visible:ring-0"
>
<StatusBadge
domain="finding"
value={f.status}
dot
/>
</SelectTrigger>
<SelectContent>
{FINDING_STATUSES.map((st) => (
<SelectItem key={st} value={st}>
{statusMeta("finding", st).label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<StatusBadge
domain="finding"
value={f.status}
dot
/>
)}
</TableCell>
<TableCell className="w-36 max-w-[9rem]">
{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}
>
<span className="truncate">{f.task_description}</span>
<span className="truncate">
{f.task_description}
</span>
<ArrowUpRightIcon className="size-3 shrink-0" />
</Link>
) : (
@@ -258,9 +368,9 @@ export default function FindingsPage() {
</TableRow>
{open && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={6} className="bg-muted/30">
<TableCell colSpan={7} className="bg-muted/30">
<div className="flex flex-col gap-2 px-2 py-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<ShieldAlertIcon className="size-3.5" />
证据 · {f.id}
{f.param_id && (
@@ -268,6 +378,20 @@ export default function FindingsPage() {
{f.param_id}
</code>
)}
{f.assets && f.assets.length > 0 && (
<span className="flex flex-wrap items-center gap-1">
· 资产:
{f.assets.map((a) => (
<code
key={a.id}
className="rounded bg-muted px-1.5 py-0.5 font-mono"
title={a.type}
>
{a.label}
</code>
))}
</span>
)}
</div>
<pre className="overflow-x-auto rounded-md bg-muted px-3 py-2 font-mono text-xs whitespace-pre-wrap">
{f.evidence}
@@ -282,7 +406,7 @@ export default function FindingsPage() {
{rows.length === 0 && (
<TableRow>
<TableCell
colSpan={6}
colSpan={7}
className="py-12 text-center text-sm text-muted-foreground"
>
没有匹配的发现。
@@ -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 (
<div className="border-b last:border-b-0">
<button
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm hover:bg-accent/40"
>
<ChevronRightIcon
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
open && "rotate-90",
)}
/>
<StatusBadge domain="severity" value={f.severity} dot />
<span className="w-20 shrink-0 font-medium">{f.vulnclass}</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{f.summary}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
<div className="flex w-full items-center gap-3 px-4 py-3 text-sm hover:bg-accent/40">
<button
onClick={() => setOpen((o) => !o)}
className="flex min-w-0 flex-1 items-center gap-3 text-left"
>
<ChevronRightIcon
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
open && "rotate-90",
)}
/>
<StatusBadge domain="severity" value={f.severity} dot />
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium">
{f.vulnclass || "未分类"}
</span>
<span className="truncate text-xs text-muted-foreground">
{f.summary}
</span>
</div>
</button>
{f.assets && f.assets.length > 0 && (
<div className="hidden shrink-0 flex-wrap justify-end gap-1 sm:flex">
{f.assets.slice(0, 2).map((a) => (
<code
key={a.id}
className="max-w-[10rem] truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs"
title={`${a.type} · ${a.label}`}
>
{a.label}
</code>
))}
{f.assets.length > 2 && (
<span className="text-xs text-muted-foreground">
+{f.assets.length - 2}
</span>
)}
</div>
)}
{f.finding_id ? (
<Select
value={f.status}
onValueChange={(v) => onStatus(f, v as FindingStatus)}
>
<SelectTrigger
size="sm"
className="h-7 w-28 shrink-0 border-none px-1 shadow-none focus-visible:ring-0"
>
<StatusBadge domain="finding" value={f.status} dot />
</SelectTrigger>
<SelectContent>
{FINDING_STATUSES.map((st) => (
<SelectItem key={st} value={st}>
{statusMeta("finding", st).label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<StatusBadge domain="finding" value={f.status} dot />
)}
<span className="hidden shrink-0 text-xs text-muted-foreground md:block">
{new Date(f.ts).toLocaleString("zh-CN")}
</span>
</button>
</div>
{open && (
<div className="bg-muted/30 px-4 pb-4 pl-11">
<div className="mb-1 text-xs font-medium text-muted-foreground">
@@ -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 }) {
<Card className="overflow-hidden py-0">
<CardContent className="px-0">
{items.map((f) => (
<Row key={f.id} f={f} />
<Row key={f.id} f={f} onStatus={onStatus} />
))}
{items.length === 0 && (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
+3 -1
View File
@@ -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<TaskNode[]>(`/exploration/frontier${tq(task)}`).then(arr),
findings: (task?: string) => get<Finding[]>(`/exploration/findings${tq(task)}`).then(arr),
setFindingStatus: (id: string, status: FindingStatus) =>
patch<{ id: string; status: FindingStatus }>(`/exploration/findings/${id}`, { status }),
intents: (task?: string) => get<TaskNode[]>(`/exploration/intents${tq(task)}`).then(arr),
tokenStats: (task?: string) =>
get<{ workers: TokenUsage[]; total: TokenTotal }>(`/exploration/tokens${tq(task)}`).then((r) => ({
+17 -17
View File
@@ -161,24 +161,24 @@ export const assetCounts: Record<string, number> = assets.reduce<Record<string,
// ── Findings ─────────────────────────────────────────────────────────────────
export const findings: Finding[] = [
{ id: "f-1", vulnclass: "SQL Injection", severity: "high", 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", 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", 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", summary: "搜索页对 q 参数未转义,反射型 XSS", evidence: "q=<script>alert(document.domain)</script> 原样回显于结果标题。", 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=<script>alert(document.domain)</script> 原样回显于结果标题。", 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) ──────────────────────────────────────────
+8
View File
@@ -63,6 +63,13 @@ const severity: Record<string, StatusMeta> = {
low: { label: "低危", tone: "slate" },
};
const finding: Record<string, StatusMeta> = {
pending: { label: "待处理", tone: "amber" },
false_positive: { label: "误报", tone: "slate" },
ignored: { label: "忽略", tone: "neutral" },
resolved: { label: "已处理", tone: "green" },
};
const engine: Record<string, StatusMeta> = {
exploring: { label: "探索中", tone: "blue" },
paused: { label: "已暂停", tone: "amber" },
@@ -91,6 +98,7 @@ const maps = {
intent,
task,
severity,
finding,
engine,
goal,
audit,
+13
View File
@@ -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;
}