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
+63
-2
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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) ──────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user