feat: 漏洞新增 severity=critical(严重) 与 name(漏洞名称)字段

severity 严重级:
- report_finding 工具 severity 说明改为 critical|high|medium|low
- DB 排序 CASE、report 报告 sevRank、FindingStats 增加 critical
- 前端 Severity 类型、status.ts 新增 rose 色调(实心高强调,视觉高于高危)、
  发现页严重度筛选/统计卡、任务发现 Tab 排序、dashboard 汇总均加入严重

name 漏洞名称:
- findings 表加 name 列(schema + ALTER TABLE ADD COLUMN IF NOT EXISTS 补旧库)
- report_finding 工具新增 name 入参(非必填),写入 node payload 与独立 findings 行
- DBFinding/AddFinding/AddStandaloneFinding、FindingDTO、findingPayload、report 全链路透传
- 发现列表页与任务发现 Tab 展示 name,为空时回退 vulnclass;vulnclass 移到展开详情「类型」
- report 报告标题优先 name,回退 vulnclass

无 CHECK 约束,旧库仅需补 name 列;critical/name 均向后兼容。
db 测试补充 critical 排序/统计与 name 往返断言。
This commit is contained in:
Autumn-27 committed 2026-08-18 11:21:08 -04:00
1 parent 7b3eec2166
commit ff3d41ea35
14 files changed
+96 -56

No files matched your search

+9 -8
View File
@@ -642,21 +642,22 @@ func (t *ToolSet) goalMet() actool.CoreTool {
func (t *ToolSet) addFinding() actool.CoreTool { func (t *ToolSet) addFinding() actool.CoreTool {
return writeTool("report_finding", "[重要]发现漏洞时必须调用该工具进行记录!记录一个确认的漏洞发现。在任务上下文中 intent_id 必填(当前正在执行的意图 id);在会话上下文中 intent_id 可不填。", return writeTool("report_finding", "[重要]发现漏洞时必须调用该工具进行记录!记录一个确认的漏洞发现。在任务上下文中 intent_id 必填(当前正在执行的意图 id);在会话上下文中 intent_id 可不填。",
obj(map[string]any{ obj(map[string]any{
"vulnclass": str("漏洞类"), "vulnclass": str("漏洞类(分类,如 SQL Injection / IDOR / XSS)"),
"severity": str("high|medium|low"), "name": str("漏洞名称(具体可读的标题,如『用户中心订单接口存在越权访问』;建议填写,留空时前端回退展示 vulnclass)"),
"severity": str("critical|high|medium|low(严重/高/中/低)"),
"summary": str("发现摘要"), "summary": str("发现摘要"),
"intent_id": idp("产生本发现的意图 id(任务上下文必填;会话上下文可不填)"), "intent_id": idp("产生本发现的意图 id(任务上下文必填;会话上下文可不填)"),
"asset_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, "description": "受影响资产 id(可选,0/1/多个):参数/端点/站点等。一个漏洞影响多处可全填,纯观察可不填。"}, "asset_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, "description": "【存在时尽量填写,否则在摘要中必须要写清楚漏洞位置】受影响资产 id(可选,0/1/多个):参数/端点/站点等。一个漏洞影响多处可全填,纯观察可不填。"},
"evidence": str("证据/PoC 文本"), "evidence": str("证据/PoC 文本"),
}, "vulnclass", "severity", "summary"), }, "vulnclass", "severity", "summary"),
func(_ context.Context, in json.RawMessage) (actool.Result, error) { func(_ context.Context, in json.RawMessage) (actool.Result, error) {
var a struct { var a struct {
VulnClass, Severity, Summary, Evidence string VulnClass, Name, Severity, Summary, Evidence string
IntentID json.RawMessage `json:"intent_id"` IntentID json.RawMessage `json:"intent_id"`
AssetIDs []json.RawMessage `json:"asset_ids"` AssetIDs []json.RawMessage `json:"asset_ids"`
} }
_ = json.Unmarshal(in, &a) _ = json.Unmarshal(in, &a)
payload := map[string]any{"vulnclass": a.VulnClass, "severity": a.Severity, "summary": a.Summary, payload := map[string]any{"vulnclass": a.VulnClass, "name": a.Name, "severity": a.Severity, "summary": a.Summary,
"evidence": map[string]any{"by": t.worker, "poc": a.Evidence}} "evidence": map[string]any{"by": t.worker, "poc": a.Evidence}}
var anchors []int64 var anchors []int64
for _, raw := range a.AssetIDs { for _, raw := range a.AssetIDs {
@@ -674,7 +675,7 @@ func (t *ToolSet) addFinding() actool.CoreTool {
if intent := pid(a.IntentID); intent > 0 { if intent := pid(a.IntentID); intent > 0 {
_ = t.ts.Link(intent, db.RelYields, id) // chain: intent -> finding _ = t.ts.Link(intent, db.RelYields, id) // chain: intent -> finding
} }
_, _ = t.ts.AddStandaloneFinding(t.taskID, id, a.VulnClass, a.Severity, a.Summary, a.Evidence, t.worker, anchors) _, _ = t.ts.AddStandaloneFinding(t.taskID, id, a.VulnClass, a.Name, a.Severity, a.Summary, a.Evidence, t.worker, anchors)
// 确证漏洞落库 → 当场唤醒本任务 planner(不等 worker 收工,debounce 合并)。 // 确证漏洞落库 → 当场唤醒本任务 planner(不等 worker 收工,debounce 合并)。
// 优先带上下文(哪个意图+finding摘要);intent 用工具参数,缺省回退到 owner 意图。 // 优先带上下文(哪个意图+finding摘要);intent 用工具参数,缺省回退到 owner 意图。
if t.notifyFinding != nil { if t.notifyFinding != nil {
+3 -2
View File
@@ -632,6 +632,7 @@ FROM activity WHERE exploration_id=$1 AND id>$2 ORDER BY id LIMIT $3`, s.expID,
// - Worker != "" → filter by worker name (Main = "mainagent", Plan = "planner"). // - Worker != "" → filter by worker name (Main = "mainagent", Plan = "planner").
// Goal Agent 的第 0 轮拆解也以 worker="planner" 落库,故 Plan 会话完整覆盖 Goal+Planner。 // Goal Agent 的第 0 轮拆解也以 worker="planner" 落库,故 Plan 会话完整覆盖 Goal+Planner。
// - NodeID != nil → a Worker session, filtered by node_id (= intent id). // - NodeID != nil → a Worker session, filtered by node_id (= intent id).
//
// A zero value (both empty) matches the whole task (no session filter). // A zero value (both empty) matches the whole task (no session filter).
type ActivitySessionFilter struct { type ActivitySessionFilter struct {
Worker string Worker string
@@ -885,6 +886,6 @@ FROM activity WHERE exploration_id=$1 AND kind<>'thinking' AND id IN (`+strings.
// AddStandaloneFinding writes a finding to the standalone findings table, which // AddStandaloneFinding writes a finding to the standalone findings table, which
// persists across task deletion (task_id / node_id become NULL when the task or // persists across task deletion (task_id / node_id become NULL when the task or
// exploration node is deleted). taskID and nodeID may be 0 (stored as NULL). // exploration node is deleted). taskID and nodeID may be 0 (stored as NULL).
func (s *ExplorationStore) AddStandaloneFinding(taskID, nodeID int64, vulnclass, severity, summary, evidence, worker string, assetIDs []int64) (int64, error) { func (s *ExplorationStore) AddStandaloneFinding(taskID, nodeID int64, vulnclass, name, severity, summary, evidence, worker string, assetIDs []int64) (int64, error) {
return s.db.AddFinding(taskID, nodeID, vulnclass, severity, summary, evidence, worker, assetIDs) return s.db.AddFinding(taskID, nodeID, vulnclass, name, severity, summary, evidence, worker, assetIDs)
} }
+14 -10
View File
@@ -13,6 +13,7 @@ type DBFinding struct {
TaskID *int64 TaskID *int64
NodeID *int64 NodeID *int64
VulnClass string VulnClass string
Name string // 漏洞名称(可读标题);为空时前端回退展示 VulnClass
Severity string Severity string
Summary string Summary string
Evidence string Evidence string
@@ -46,8 +47,9 @@ func ValidFindingStatus(s string) bool {
} }
// AddFinding inserts a finding into the standalone findings table. taskID and // AddFinding inserts a finding into the standalone findings table. taskID and
// nodeID may be 0 (stored as NULL). Returns the new finding id. // nodeID may be 0 (stored as NULL). name may be "" (frontend falls back to
func (d *DB) AddFinding(taskID, nodeID int64, vulnclass, severity, summary, evidence, worker string, assetIDs []int64) (int64, error) { // vulnclass). Returns the new finding id.
func (d *DB) AddFinding(taskID, nodeID int64, vulnclass, name, severity, summary, evidence, worker string, assetIDs []int64) (int64, error) {
aidsJSON, _ := json.Marshal(assetIDs) aidsJSON, _ := json.Marshal(assetIDs)
if assetIDs == nil { if assetIDs == nil {
aidsJSON = []byte("[]") aidsJSON = []byte("[]")
@@ -61,16 +63,16 @@ func (d *DB) AddFinding(taskID, nodeID int64, vulnclass, severity, summary, evid
} }
var id int64 var id int64
err := d.QueryRow( err := d.QueryRow(
`INSERT INTO findings (task_id, node_id, vulnclass, severity, summary, evidence, worker, asset_ids) `INSERT INTO findings (task_id, node_id, vulnclass, name, severity, summary, evidence, worker, asset_ids)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`,
tid, nid, vulnclass, severity, summary, evidence, worker, string(aidsJSON), tid, nid, vulnclass, name, severity, summary, evidence, worker, string(aidsJSON),
).Scan(&id) ).Scan(&id)
return id, err return id, err
} }
// findingSelectCols is the column list (with task_description join) every finding // findingSelectCols is the column list (with task_description join) every finding
// list query selects, so scanFinding stays in sync across callers. // list query selects, so scanFinding stays in sync across callers.
const findingSelectCols = `f.id, f.task_id, f.node_id, f.vulnclass, f.severity, f.summary, const findingSelectCols = `f.id, f.task_id, f.node_id, f.vulnclass, COALESCE(f.name, ''), f.severity, f.summary,
f.evidence, f.worker, f.asset_ids, COALESCE(f.status, 'pending'), f.created_at, f.evidence, f.worker, f.asset_ids, COALESCE(f.status, 'pending'), f.created_at,
COALESCE(t.description, '') AS task_description` COALESCE(t.description, '') AS task_description`
@@ -84,7 +86,7 @@ func scanFindings(rows interface {
for rows.Next() { for rows.Next() {
f := &DBFinding{} f := &DBFinding{}
var aidsJSON string var aidsJSON string
if err := rows.Scan(&f.ID, &f.TaskID, &f.NodeID, &f.VulnClass, &f.Severity, if err := rows.Scan(&f.ID, &f.TaskID, &f.NodeID, &f.VulnClass, &f.Name, &f.Severity,
&f.Summary, &f.Evidence, &f.Worker, &aidsJSON, &f.Status, &f.CreatedAt, &f.TaskDescription); err != nil { &f.Summary, &f.Evidence, &f.Worker, &aidsJSON, &f.Status, &f.CreatedAt, &f.TaskDescription); err != nil {
return nil, err return nil, err
} }
@@ -162,8 +164,8 @@ func (d *DB) ListFindingsPage(f FindingFilter, page, pageSize int) ([]*DBFinding
order := "f.created_at DESC" order := "f.created_at DESC"
if f.Sort == "severity" { if f.Sort == "severity" {
// high > medium > low > 其它, then newest first. // critical > high > medium > low > 其它, then newest first.
order = `CASE f.severity WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END DESC, f.created_at DESC` order = `CASE f.severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END DESC, f.created_at DESC`
} }
pageArgs := append(append([]any{}, args...), pageSize, (page-1)*pageSize) pageArgs := append(append([]any{}, args...), pageSize, (page-1)*pageSize)
q := fmt.Sprintf(` q := fmt.Sprintf(`
@@ -187,6 +189,7 @@ func (d *DB) ListFindingsPage(f FindingFilter, page, pageSize int) ([]*DBFinding
type FindingStats struct { type FindingStats struct {
Total int `json:"total"` Total int `json:"total"`
Pending int `json:"pending"` Pending int `json:"pending"`
Critical int `json:"critical"`
High int `json:"high"` High int `json:"high"`
Medium int `json:"medium"` Medium int `json:"medium"`
Low int `json:"low"` Low int `json:"low"`
@@ -200,10 +203,11 @@ func (d *DB) FindingStats() (*FindingStats, error) {
err := d.QueryRow(`SELECT err := d.QueryRow(`SELECT
COUNT(*), COUNT(*),
COUNT(*) FILTER (WHERE status = 'pending'), COUNT(*) FILTER (WHERE status = 'pending'),
COUNT(*) FILTER (WHERE severity = 'critical'),
COUNT(*) FILTER (WHERE severity = 'high'), COUNT(*) FILTER (WHERE severity = 'high'),
COUNT(*) FILTER (WHERE severity = 'medium'), COUNT(*) FILTER (WHERE severity = 'medium'),
COUNT(*) FILTER (WHERE severity = 'low') COUNT(*) FILTER (WHERE severity = 'low')
FROM findings`).Scan(&st.Total, &st.Pending, &st.High, &st.Medium, &st.Low) FROM findings`).Scan(&st.Total, &st.Pending, &st.Critical, &st.High, &st.Medium, &st.Low)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+24 -20
View File
@@ -21,19 +21,21 @@ func TestFindingsPageAndStats(t *testing.T) {
cleanup() cleanup()
defer cleanup() defer cleanup()
// Seed 5 findings under the marker vulnclass: 3 high, 2 low; 2 pending, 3 resolved. // Seed 6 findings under the marker vulnclass: 1 critical, 3 high, 2 low; 2 pending.
// The critical row carries a name to verify round-trip.
seed := []struct { seed := []struct {
sev, status string sev, status, name string
}{ }{
{"high", "pending"}, {"critical", "pending", "严重漏洞标题"},
{"high", "resolved"}, {"high", "resolved", ""},
{"high", "resolved"}, {"high", "resolved", ""},
{"low", "pending"}, {"high", "pending", ""},
{"low", "resolved"}, {"low", "resolved", ""},
{"low", "resolved", ""},
} }
var ids []int64 var ids []int64
for i, s := range seed { for i, s := range seed {
id, err := d.AddFinding(0, 0, vc, s.sev, "summary", "poc", "tester", nil) id, err := d.AddFinding(0, 0, vc, s.name, s.sev, "summary", "poc", "tester", nil)
if err != nil { if err != nil {
t.Fatalf("AddFinding[%d]: %v", i, err) t.Fatalf("AddFinding[%d]: %v", i, err)
} }
@@ -43,27 +45,26 @@ func TestFindingsPageAndStats(t *testing.T) {
ids = append(ids, id) ids = append(ids, id)
} }
// Filter by our vulnclass → exactly the 5 seeded rows, paged 2 per page. // Filter by our vulnclass → exactly the 6 seeded rows, paged 2 per page.
p1, total, err := d.ListFindingsPage(FindingFilter{VulnClass: vc, Sort: "severity"}, 1, 2) p1, total, err := d.ListFindingsPage(FindingFilter{VulnClass: vc, Sort: "severity"}, 1, 2)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if total != 5 { if total != 6 {
t.Fatalf("total: want 5, got %d", total) t.Fatalf("total: want 6, got %d", total)
} }
if len(p1) != 2 { if len(p1) != 2 {
t.Fatalf("page1 size: want 2, got %d", len(p1)) t.Fatalf("page1 size: want 2, got %d", len(p1))
} }
// severity sort → highs first // severity sort → critical first (with its name round-tripped), then high.
if p1[0].Severity != "high" || p1[1].Severity != "high" { if p1[0].Severity != "critical" {
t.Fatalf("severity sort broken: %q, %q", p1[0].Severity, p1[1].Severity) t.Fatalf("severity sort: want critical first, got %q", p1[0].Severity)
} }
p3, _, err := d.ListFindingsPage(FindingFilter{VulnClass: vc, Sort: "severity"}, 3, 2) if p1[0].Name != "严重漏洞标题" {
if err != nil { t.Fatalf("name round-trip: want 严重漏洞标题, got %q", p1[0].Name)
t.Fatal(err)
} }
if len(p3) != 1 { if p1[1].Severity != "high" {
t.Fatalf("page3 size: want 1 (5 rows / 2), got %d", len(p3)) t.Fatalf("severity sort: want high second, got %q", p1[1].Severity)
} }
// Combined filter: vulnclass + status=pending → 2 rows. // Combined filter: vulnclass + status=pending → 2 rows.
@@ -90,7 +91,10 @@ func TestFindingsPageAndStats(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if st.Total < 5 || st.High < 3 || st.Low < 2 || st.Pending < 2 { if st.Critical < 1 {
t.Fatalf("stats critical undercount: %+v", st)
}
if st.Total < 6 || st.High < 3 || st.Low < 2 || st.Pending < 2 {
t.Fatalf("stats undercount: %+v", st) t.Fatalf("stats undercount: %+v", st)
} }
if !slices.Contains(st.VulnClasses, vc) { if !slices.Contains(st.VulnClasses, vc) {
+4
View File
@@ -540,6 +540,9 @@ CREATE TABLE IF NOT EXISTS findings (
task_id BIGINT REFERENCES tasks(id) ON DELETE SET NULL, task_id BIGINT REFERENCES tasks(id) ON DELETE SET NULL,
node_id BIGINT REFERENCES exploration_nodes(id) ON DELETE SET NULL, node_id BIGINT REFERENCES exploration_nodes(id) ON DELETE SET NULL,
vulnclass TEXT NOT NULL DEFAULT '', vulnclass TEXT NOT NULL DEFAULT '',
-- 漏洞名称(可读标题);为空时前端回退展示 vulnclass。severity 取值:
-- critical 严重 / high 高 / medium 中 / low 低(不加 CHECK,与 status 一致由 server 白名单校验)。
name TEXT NOT NULL DEFAULT '',
severity TEXT NOT NULL DEFAULT '', severity TEXT NOT NULL DEFAULT '',
summary TEXT NOT NULL DEFAULT '', summary TEXT NOT NULL DEFAULT '',
evidence TEXT NOT NULL DEFAULT '', evidence TEXT NOT NULL DEFAULT '',
@@ -552,6 +555,7 @@ CREATE TABLE IF NOT EXISTS findings (
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
ALTER TABLE findings ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'pending'; ALTER TABLE findings ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'pending';
ALTER TABLE findings ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_findings_task ON findings(task_id, created_at DESC); 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_time ON findings(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_findings_status ON findings(status, created_at DESC); CREATE INDEX IF NOT EXISTS idx_findings_status ON findings(status, created_at DESC);
+5 -3
View File
@@ -23,6 +23,7 @@ type Input struct {
type findingView struct { type findingView struct {
VulnClass string VulnClass string
Name string
Severity string Severity string
Summary string Summary string
PoC string PoC string
@@ -31,6 +32,7 @@ type findingView struct {
func parseFinding(n *db.Node) findingView { func parseFinding(n *db.Node) findingView {
var p struct { var p struct {
VulnClass string `json:"vulnclass"` VulnClass string `json:"vulnclass"`
Name string `json:"name"`
Severity string `json:"severity"` Severity string `json:"severity"`
Summary string `json:"summary"` Summary string `json:"summary"`
Evidence struct { Evidence struct {
@@ -38,10 +40,10 @@ func parseFinding(n *db.Node) findingView {
} `json:"evidence"` } `json:"evidence"`
} }
_ = json.Unmarshal(n.Payload, &p) _ = json.Unmarshal(n.Payload, &p)
return findingView{p.VulnClass, p.Severity, p.Summary, p.Evidence.PoC} return findingView{p.VulnClass, p.Name, p.Severity, p.Summary, p.Evidence.PoC}
} }
var sevRank = map[string]int{"high": 0, "medium": 1, "low": 2, "": 3} var sevRank = map[string]int{"critical": 0, "high": 1, "medium": 2, "low": 3, "": 4}
// Markdown renders the report. // Markdown renders the report.
func Markdown(in Input) string { func Markdown(in Input) string {
@@ -78,7 +80,7 @@ func Markdown(in Input) string {
} }
sort.SliceStable(fs, func(i, j int) bool { return sevRank[fs[i].Severity] < sevRank[fs[j].Severity] }) sort.SliceStable(fs, func(i, j int) bool { return sevRank[fs[i].Severity] < sevRank[fs[j].Severity] })
for i, f := range fs { for i, f := range fs {
fmt.Fprintf(&b, "### %d. [%s] %s\n\n", i+1, strings.ToUpper(nz(f.Severity, "info")), nz(f.VulnClass, "未分类")) fmt.Fprintf(&b, "### %d. [%s] %s\n\n", i+1, strings.ToUpper(nz(f.Severity, "info")), nz(f.Name, nz(f.VulnClass, "未分类")))
fmt.Fprintf(&b, "%s\n\n", nz(f.Summary, "")) fmt.Fprintf(&b, "%s\n\n", nz(f.Summary, ""))
if f.PoC != "" { if f.PoC != "" {
fmt.Fprintf(&b, "**PoC / 证据:**\n\n```\n%s\n```\n\n", f.PoC) fmt.Fprintf(&b, "**PoC / 证据:**\n\n```\n%s\n```\n\n", f.PoC)
+6 -2
View File
@@ -175,8 +175,9 @@ type FindingDTO struct {
ID string `json:"id"` ID string `json:"id"`
FindingID string `json:"finding_id,omitempty"` // standalone findings-table id — the handle for status updates FindingID string `json:"finding_id,omitempty"` // standalone findings-table id — the handle for status updates
VulnClass string `json:"vulnclass"` VulnClass string `json:"vulnclass"`
Severity string `json:"severity"` Name string `json:"name,omitempty"` // 漏洞名称;为空时前端回退展示 vulnclass
Status string `json:"status"` // pending | false_positive | ignored | resolved Severity string `json:"severity"` // critical | high | medium | low
Status string `json:"status"` // pending | in_progress | confirmed | resolved | false_positive | ignored | duplicate | risk_accepted
Summary string `json:"summary"` Summary string `json:"summary"`
Evidence string `json:"evidence"` Evidence string `json:"evidence"`
IntentID string `json:"intent_id,omitempty"` IntentID string `json:"intent_id,omitempty"`
@@ -229,6 +230,7 @@ func assetLabel(a *db.Asset) string {
// (agent/tools.go addFinding): {vulnclass, severity, summary, evidence:{by,poc}}. // (agent/tools.go addFinding): {vulnclass, severity, summary, evidence:{by,poc}}.
type findingPayload struct { type findingPayload struct {
VulnClass string `json:"vulnclass"` VulnClass string `json:"vulnclass"`
Name string `json:"name"`
Severity string `json:"severity"` Severity string `json:"severity"`
Summary string `json:"summary"` Summary string `json:"summary"`
Evidence json.RawMessage `json:"evidence"` Evidence json.RawMessage `json:"evidence"`
@@ -240,6 +242,7 @@ func findingDTO(n *db.Node) FindingDTO {
return FindingDTO{ return FindingDTO{
ID: i64s(n.ID), ID: i64s(n.ID),
VulnClass: p.VulnClass, VulnClass: p.VulnClass,
Name: p.Name,
Severity: p.Severity, Severity: p.Severity,
Status: db.FindingPending, Status: db.FindingPending,
Summary: p.Summary, Summary: p.Summary,
@@ -293,6 +296,7 @@ func findingFromDB(f *db.DBFinding, assets map[int64]*db.Asset) FindingDTO {
ID: i64s(f.ID), ID: i64s(f.ID),
FindingID: i64s(f.ID), FindingID: i64s(f.ID),
VulnClass: f.VulnClass, VulnClass: f.VulnClass,
Name: f.Name,
Severity: f.Severity, Severity: f.Severity,
Status: status, Status: status,
Summary: f.Summary, Summary: f.Summary,
+3 -2
View File
@@ -239,7 +239,7 @@ export default function DashboardPage() {
}, [tasks]); }, [tasks]);
const findingsBySev = React.useMemo(() => { const findingsBySev = React.useMemo(() => {
const m = { high: 0, medium: 0, low: 0 }; const m = { critical: 0, high: 0, medium: 0, low: 0 };
for (const f of findings) { for (const f of findings) {
if (f.severity in m) m[f.severity as keyof typeof m]++; if (f.severity in m) m[f.severity as keyof typeof m]++;
} }
@@ -491,7 +491,8 @@ export default function DashboardPage() {
{findings.length} {findings.length}
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex gap-2.5 text-[10px]"> <CardContent className="flex flex-wrap gap-2.5 text-[10px]">
<span className="text-rose-500">严重 {findingsBySev.critical}</span>
<span className="text-red-400">高危 {findingsBySev.high}</span> <span className="text-red-400">高危 {findingsBySev.high}</span>
<span className="text-amber-400">中危 {findingsBySev.medium}</span> <span className="text-amber-400">中危 {findingsBySev.medium}</span>
<span className="text-slate-400">低危 {findingsBySev.low}</span> <span className="text-slate-400">低危 {findingsBySev.low}</span>
+13 -2
View File
@@ -52,6 +52,7 @@ const FINDING_STATUSES: FindingStatus[] = [
const EMPTY_STATS: FindingStats = { const EMPTY_STATS: FindingStats = {
total: 0, total: 0,
pending: 0, pending: 0,
critical: 0,
high: 0, high: 0,
medium: 0, medium: 0,
low: 0, low: 0,
@@ -156,6 +157,7 @@ export default function FindingsPage() {
const statCards: { label: string; value: number; tone?: string }[] = [ const statCards: { label: string; value: number; tone?: string }[] = [
{ label: "发现总数", value: stats.total }, { label: "发现总数", value: stats.total },
{ label: "待处理", value: stats.pending, tone: "text-amber-500" }, { label: "待处理", value: stats.pending, tone: "text-amber-500" },
{ label: "严重", value: stats.critical, tone: "text-rose-600" },
{ label: "高危", value: stats.high, tone: "text-red-500" }, { label: "高危", value: stats.high, tone: "text-red-500" },
{ label: "中危", value: stats.medium, tone: "text-amber-500" }, { label: "中危", value: stats.medium, tone: "text-amber-500" },
{ label: "低危", value: stats.low, tone: "text-slate-500" }, { label: "低危", value: stats.low, tone: "text-slate-500" },
@@ -168,7 +170,7 @@ export default function FindingsPage() {
<p className="text-muted-foreground text-sm">跨任务漏洞汇总</p> <p className="text-muted-foreground text-sm">跨任务漏洞汇总</p>
</div> </div>
<div className="flex flex-1 flex-col gap-4 md:gap-6"> <div className="flex flex-1 flex-col gap-4 md:gap-6">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
{statCards.map((s) => ( {statCards.map((s) => (
<Card key={s.label} className="gap-1 py-4"> <Card key={s.label} className="gap-1 py-4">
<CardHeader className="px-4"> <CardHeader className="px-4">
@@ -186,6 +188,7 @@ export default function FindingsPage() {
{( {(
[ [
["all", "全部"], ["all", "全部"],
["critical", "严重"],
["high", "高危"], ["high", "高危"],
["medium", "中危"], ["medium", "中危"],
["low", "低危"], ["low", "低危"],
@@ -293,7 +296,7 @@ export default function FindingsPage() {
<TableCell className="max-w-md"> <TableCell className="max-w-md">
<div className="flex min-w-0 flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate font-medium"> <span className="truncate font-medium">
{f.vulnclass || "未分类"} {f.name || f.vulnclass || "未分类"}
</span> </span>
<span className="truncate text-xs text-muted-foreground"> <span className="truncate text-xs text-muted-foreground">
{f.summary} {f.summary}
@@ -380,6 +383,14 @@ export default function FindingsPage() {
<div className="flex flex-wrap 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" /> <ShieldAlertIcon className="size-3.5" />
证据 · {f.id} 证据 · {f.id}
{f.vulnclass && (
<span>
· 类型:
<code className="rounded bg-muted px-1.5 py-0.5 font-mono">
{f.vulnclass}
</code>
</span>
)}
{f.param_id && ( {f.param_id && (
<code className="rounded bg-muted px-1.5 py-0.5 font-mono"> <code className="rounded bg-muted px-1.5 py-0.5 font-mono">
{f.param_id} {f.param_id}
@@ -52,7 +52,7 @@ function Row({
<StatusBadge domain="severity" value={f.severity} dot /> <StatusBadge domain="severity" value={f.severity} dot />
<div className="flex min-w-0 flex-col"> <div className="flex min-w-0 flex-col">
<span className="truncate font-medium"> <span className="truncate font-medium">
{f.vulnclass || "未分类"} {f.name || f.vulnclass || "未分类"}
</span> </span>
<span className="truncate text-xs text-muted-foreground"> <span className="truncate text-xs text-muted-foreground">
{f.summary} {f.summary}
@@ -161,7 +161,7 @@ export function FindingsTab({ taskId }: { taskId: string }) {
const items = findings const items = findings
.filter((f) => f.task_id === taskId) .filter((f) => f.task_id === taskId)
.sort((a, b) => { .sort((a, b) => {
const order = { high: 0, medium: 1, low: 2 }; const order = { critical: 0, high: 1, medium: 2, low: 3 };
return order[a.severity] - order[b.severity]; return order[a.severity] - order[b.severity];
}); });
+3 -3
View File
@@ -161,7 +161,7 @@ export const assetCounts: Record<string, number> = assets.reduce<Record<string,
// ── Findings ───────────────────────────────────────────────────────────────── // ── Findings ─────────────────────────────────────────────────────────────────
export const findings: Finding[] = [ export const findings: Finding[] = [
{ 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-1", vulnclass: "SQL Injection", name: "官网搜索接口报错型 SQL 注入", 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-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-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-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") },
@@ -173,12 +173,12 @@ export const findings: Finding[] = [
{ 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") }, { 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)── // ── 外网→内网纵深链路上的高危发现(active task)──
{ 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-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-12", vulnclass: "Deserialization RCE", name: "shop 商城 Fastjson 反序列化远程命令执行", severity: "critical", status: "confirmed", 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-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-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-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-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") }, { id: "f-17", vulnclass: "Domain Compromise", name: "内网域控 DC01 完全控制(Domain Admin)", severity: "critical", status: "confirmed", 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) ────────────────────────────────────────── // ── Exploration graph (active task) ──────────────────────────────────────────
+2 -1
View File
@@ -78,6 +78,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
return { return {
total: D.findings.length, total: D.findings.length,
pending: D.findings.filter((f) => f.status === "pending").length, pending: D.findings.filter((f) => f.status === "pending").length,
critical: D.findings.filter((f) => f.severity === "critical").length,
high: D.findings.filter((f) => f.severity === "high").length, high: D.findings.filter((f) => f.severity === "high").length,
medium: D.findings.filter((f) => f.severity === "medium").length, medium: D.findings.filter((f) => f.severity === "medium").length,
low: D.findings.filter((f) => f.severity === "low").length, low: D.findings.filter((f) => f.severity === "low").length,
@@ -88,7 +89,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (task) return D.findings.filter((f) => f.task_id === task); if (task) return D.findings.filter((f) => f.task_id === task);
// 全局:带 page/limit → 分页对象;否则裸数组(dashboard)。 // 全局:带 page/limit → 分页对象;否则裸数组(dashboard)。
if (!q.has("page") && !q.has("limit")) return D.findings; if (!q.has("page") && !q.has("limit")) return D.findings;
const sev = { high: 3, medium: 2, low: 1 } as const; const sev = { critical: 4, high: 3, medium: 2, low: 1 } as const;
let list = D.findings.slice(); let list = D.findings.slice();
const fSev = q.get("severity"); const fSev = q.get("severity");
const fStatus = q.get("status"); const fStatus = q.get("status");
+5
View File
@@ -7,6 +7,7 @@ export type Tone =
| "green" | "green"
| "amber" | "amber"
| "red" | "red"
| "rose"
| "violet" | "violet"
| "slate"; | "slate";
@@ -19,6 +20,8 @@ export const toneClasses: Record<Tone, string> = {
amber: amber:
"bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/20", "bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/20",
red: "bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/20", red: "bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/20",
// rose 用作「严重」——实心高强调,视觉上明显高于「高危」的软红描边。
rose: "bg-rose-600 text-white border-rose-600 dark:bg-rose-600 dark:text-white",
violet: violet:
"bg-violet-500/15 text-violet-600 dark:text-violet-400 border-violet-500/20", "bg-violet-500/15 text-violet-600 dark:text-violet-400 border-violet-500/20",
slate: slate:
@@ -31,6 +34,7 @@ export const toneDot: Record<Tone, string> = {
green: "bg-emerald-500", green: "bg-emerald-500",
amber: "bg-amber-500", amber: "bg-amber-500",
red: "bg-red-500", red: "bg-red-500",
rose: "bg-white",
violet: "bg-violet-500", violet: "bg-violet-500",
slate: "bg-slate-500", slate: "bg-slate-500",
}; };
@@ -58,6 +62,7 @@ const task: Record<string, StatusMeta> = {
}; };
const severity: Record<string, StatusMeta> = { const severity: Record<string, StatusMeta> = {
critical: { label: "严重", tone: "rose" },
high: { label: "高危", tone: "red" }, high: { label: "高危", tone: "red" },
medium: { label: "中危", tone: "amber" }, medium: { label: "中危", tone: "amber" },
low: { label: "低危", tone: "slate" }, low: { label: "低危", tone: "slate" },
+3 -1
View File
@@ -237,7 +237,7 @@ export interface TaskNode {
} }
// ---- Findings ---- // ---- Findings ----
export type Severity = "high" | "medium" | "low"; export type Severity = "critical" | "high" | "medium" | "low";
// 漏洞处置状态:待处理 / 处理中 / 已确认 / 已处理 / 误报 / 忽略 / 重复 / 风险接受。 // 漏洞处置状态:待处理 / 处理中 / 已确认 / 已处理 / 误报 / 忽略 / 重复 / 风险接受。
export type FindingStatus = export type FindingStatus =
@@ -261,6 +261,7 @@ export interface Finding {
id: string; id: string;
finding_id?: string; // 独立 findings 表的行 id,状态更新的句柄(任务内旧节点可能缺失) finding_id?: string; // 独立 findings 表的行 id,状态更新的句柄(任务内旧节点可能缺失)
vulnclass: string; vulnclass: string;
name?: string; // 漏洞名称;为空时展示回退到 vulnclass
severity: Severity; severity: Severity;
status: FindingStatus; status: FindingStatus;
summary: string; summary: string;
@@ -285,6 +286,7 @@ export interface FindingsPage {
export interface FindingStats { export interface FindingStats {
total: number; total: number;
pending: number; pending: number;
critical: number;
high: number; high: number;
medium: number; medium: number;
low: number; low: number;