如何设计一个专业的安全扫描报告系统?从数据模型到多格式输出
📋 目录
背景介绍
漏洞扫描器的最终产物不是控制台日志,而是可交付、可追溯、可复核的扫描报告。
对 xray 这类工具来说,报告输出至少要满足三类人:
安全工程师
├── 需要漏洞详情
├── 需要请求响应证据
└── 需要复核误报
开发团队
├── 需要影响路径
├── 需要修复建议
└── 需要优先级
自动化平台
├── 需要JSON结构
├── 需要稳定字段
└── 需要可导入CI/CD或工单系统
xray README 中提供了典型输出参数:
xray webscan --url http://example.com/?a=b \
--text-output result.txt \
--json-output result.json \
--html-output report.html
这说明报告系统不是单一模板渲染,而是“同一份漏洞数据,多种输出视图”。
核心挑战
挑战1:漏洞结果要结构化
如果扫描过程中只拼接字符串,后续很难输出多种格式。
不推荐
发现SQL注入,URL是xxx,等级是高危,参数是id
推荐
{
"plugin": "sqldet",
"target": "https://example.com/item?id=1",
"param": "id",
"severity": "high",
"evidence": ["boolean condition changed response"],
"recommendation": "Use parameterized queries"
}
结构化数据是报告系统的基础。
挑战2:HTML报告要兼顾可读性和安全性
HTML报告最适合人工阅读,但也最容易出问题。
扫描结果中包含的内容可能来自目标站点:
- URL
- 参数值
- 响应片段
- Header
- 回调内容
- 错误信息
这些内容都不可信。如果直接写入 HTML,报告本身可能出现 XSS 风险。
挑战3:多格式输出不能重复实现
常见输出格式:
HTML
├── 面向人阅读
├── 需要排序、筛选、样式
└── 适合交付
JSON
├── 面向机器消费
├── 字段稳定
└── 适合集成平台
TXT
├── 面向终端快速查看
├── 简洁
└── 适合日志归档
如果每种格式各自拼接一套数据,很快就会出现字段不一致。
挑战4:报告生成要支持流式和增量
大型扫描任务可能产生很多结果。
如果所有结果都放内存,最后一次性渲染,会有三个问题:
- 扫描中途崩溃,结果丢失
- 大报告占用大量内存
- 用户无法提前查看阶段性结果
因此输出系统要考虑:
- JSON Lines 增量写入
- HTML 最终汇总
- TXT 实时追加
- 结束时生成索引和统计信息
解决方案
架构设计
┌───────────────────────────────┐
│ Detection Engine │
└───────────────┬───────────────┘
│ VulnResult
▼
┌───────────────────────────────┐
│ Result Normalizer │
│ severity / evidence / tags │
└───────────────┬───────────────┘
│
▼
┌───────────────────────────────┐
│ Report Store │
│ memory / jsonl / sqlite │
└───────────────┬───────────────┘
│
┌───────┼────────┐
▼ ▼ ▼
HTML Writer JSON Writer TXT Writer
输出设计原则
- 统一数据模型:所有输出格式都从
VulnResult派生 - 格式层只负责呈现:不要在 HTML Writer 中重新计算漏洞等级
- 证据可追溯:每条漏洞都保留关键请求、响应、检测插件
- 报告可安全打开:所有不可信内容都要转义
- 字段兼容性优先:JSON字段新增可以,删除和改名要谨慎
完整实现
步骤1:漏洞结果模型
pkg/report/model.go
package report
import "time"
type Severity string
const (
SeverityCritical Severity = "critical"
SeverityHigh Severity = "high"
SeverityMedium Severity = "medium"
SeverityLow Severity = "low"
SeverityInfo Severity = "info"
)
type VulnResult struct {
ID string `json:"id"`
Target string `json:"target"`
URL string `json:"url"`
Plugin string `json:"plugin"`
VulnType string `json:"vuln_type"`
Severity Severity `json:"severity"`
Title string `json:"title"`
Description string `json:"description"`
Evidence []Evidence `json:"evidence"`
Recommendation string `json:"recommendation"`
Tags []string `json:"tags"`
Extra map[string]string `json:"extra,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type Evidence struct {
Type string `json:"type"`
Summary string `json:"summary"`
Request string `json:"request,omitempty"`
Response string `json:"response,omitempty"`
}
type ScanReport struct {
TaskID string `json:"task_id"`
StartedAt time.Time `json:"started_at"`
EndedAt time.Time `json:"ended_at"`
Summary Summary `json:"summary"`
Results []VulnResult `json:"results"`
}
type Summary struct {
Total int `json:"total"`
ByLevel map[Severity]int `json:"by_level"`
ByPlugin map[string]int `json:"by_plugin"`
}
步骤2:结果归一化
pkg/report/normalize.go
package report
import (
"crypto/sha1"
"encoding/hex"
"strings"
"time"
)
func Normalize(result VulnResult) VulnResult {
result.URL = strings.TrimSpace(result.URL)
result.Target = strings.TrimSpace(result.Target)
result.Plugin = strings.TrimSpace(result.Plugin)
result.VulnType = strings.TrimSpace(result.VulnType)
if result.CreatedAt.IsZero() {
result.CreatedAt = time.Now()
}
if result.ID == "" {
result.ID = buildResultID(result)
}
if result.Extra == nil {
result.Extra = map[string]string{}
}
return result
}
func buildResultID(result VulnResult) string {
raw := strings.Join([]string{
result.Target,
result.URL,
result.Plugin,
result.VulnType,
string(result.Severity),
}, "|")
sum := sha1.Sum([]byte(raw))
return hex.EncodeToString(sum[:])
}
ID 的作用是去重、引用和平台集成,不应该依赖数组下标。
步骤3:报告收集器
pkg/report/collector.go
package report
import "sync"
type Collector struct {
mu sync.Mutex
seen map[string]struct{}
results []VulnResult
}
func NewCollector() *Collector {
return &Collector{
seen: map[string]struct{}{},
}
}
func (c *Collector) Add(result VulnResult) bool {
normalized := Normalize(result)
c.mu.Lock()
defer c.mu.Unlock()
if _, ok := c.seen[normalized.ID]; ok {
return false
}
c.seen[normalized.ID] = struct{}{}
c.results = append(c.results, normalized)
return true
}
func (c *Collector) Results() []VulnResult {
c.mu.Lock()
defer c.mu.Unlock()
out := make([]VulnResult, len(c.results))
copy(out, c.results)
return out
}
步骤4:JSON输出
pkg/report/json_writer.go
package report
import (
"encoding/json"
"io"
)
type JSONWriter struct {
Pretty bool
}
func (w JSONWriter) Write(out io.Writer, report ScanReport) error {
enc := json.NewEncoder(out)
enc.SetEscapeHTML(true)
if w.Pretty {
enc.SetIndent("", " ")
}
return enc.Encode(report)
}
JSON 也建议开启 HTML 字符转义,因为这些 JSON 很可能会被嵌入到 HTML 报告模板中。
步骤5:TXT输出
pkg/report/text_writer.go
package report
import (
"fmt"
"io"
)
type TextWriter struct{}
func (w TextWriter) Write(out io.Writer, report ScanReport) error {
_, err := fmt.Fprintf(out, "Task: %s\nTotal: %d\n\n", report.TaskID, report.Summary.Total)
if err != nil {
return err
}
for _, item := range report.Results {
if _, err := fmt.Fprintf(out, "[%s] %s\n", item.Severity, item.Title); err != nil {
return err
}
if _, err := fmt.Fprintf(out, "URL: %s\nPlugin: %s\n", item.URL, item.Plugin); err != nil {
return err
}
for _, evidence := range item.Evidence {
if _, err := fmt.Fprintf(out, "- %s: %s\n", evidence.Type, evidence.Summary); err != nil {
return err
}
}
if _, err := fmt.Fprintln(out); err != nil {
return err
}
}
return nil
}
TXT 不追求样式,只追求快速定位关键信息。
步骤6:HTML模板渲染
pkg/report/html_writer.go
package report
import (
"html/template"
"io"
)
type HTMLWriter struct {
tmpl *template.Template
}
func NewHTMLWriter() (*HTMLWriter, error) {
tmpl, err := template.New("report").Funcs(template.FuncMap{
"severityClass": severityClass,
}).Parse(reportTemplate)
if err != nil {
return nil, err
}
return &HTMLWriter{tmpl: tmpl}, nil
}
func (w *HTMLWriter) Write(out io.Writer, report ScanReport) error {
return w.tmpl.Execute(out, report)
}
func severityClass(s Severity) string {
switch s {
case SeverityCritical:
return "critical"
case SeverityHigh:
return "high"
case SeverityMedium:
return "medium"
case SeverityLow:
return "low"
default:
return "info"
}
}
pkg/report/template.go
package report
const reportTemplate = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>Scan Report</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 32px; color: #222; }
table { width: 100%; border-collapse: collapse; }
th, td { border-bottom: 1px solid #eee; padding: 10px; text-align: left; vertical-align: top; }
.critical { color: #9f1239; font-weight: 700; }
.high { color: #b91c1c; font-weight: 700; }
.medium { color: #b45309; font-weight: 700; }
.low { color: #2563eb; font-weight: 700; }
.info { color: #475569; }
pre { white-space: pre-wrap; background: #f8fafc; padding: 12px; overflow: auto; }
</style>
</head>
<body>
<h1>Scan Report</h1>
<p>Task: {{ .TaskID }}</p>
<p>Total: {{ .Summary.Total }}</p>
<table>
<thead>
<tr>
<th>Severity</th>
<th>Title</th>
<th>URL</th>
<th>Plugin</th>
<th>Evidence</th>
</tr>
</thead>
<tbody>
{{ range .Results }}
<tr>
<td class="{{ severityClass .Severity }}">{{ .Severity }}</td>
<td>{{ .Title }}</td>
<td>{{ .URL }}</td>
<td>{{ .Plugin }}</td>
<td>
{{ range .Evidence }}
<strong>{{ .Type }}</strong>
<pre>{{ .Summary }}</pre>
{{ end }}
</td>
</tr>
{{ end }}
</tbody>
</table>
</body>
</html>`
这里使用 html/template 而不是 text/template,因为前者会自动做上下文感知转义。
步骤7:统一输出入口
pkg/report/exporter.go
package report
import (
"os"
"time"
)
type ExportOptions struct {
HTMLPath string
JSONPath string
TextPath string
}
func Export(taskID string, results []VulnResult, options ExportOptions) error {
report := BuildReport(taskID, results)
if options.JSONPath != "" {
if err := writeFile(options.JSONPath, func(file *os.File) error {
return JSONWriter{Pretty: true}.Write(file, report)
}); err != nil {
return err
}
}
if options.TextPath != "" {
if err := writeFile(options.TextPath, func(file *os.File) error {
return TextWriter{}.Write(file, report)
}); err != nil {
return err
}
}
if options.HTMLPath != "" {
writer, err := NewHTMLWriter()
if err != nil {
return err
}
if err := writeFile(options.HTMLPath, func(file *os.File) error {
return writer.Write(file, report)
}); err != nil {
return err
}
}
return nil
}
func BuildReport(taskID string, results []VulnResult) ScanReport {
summary := Summary{
ByLevel: map[Severity]int{},
ByPlugin: map[string]int{},
}
for i := range results {
results[i] = Normalize(results[i])
summary.Total++
summary.ByLevel[results[i].Severity]++
summary.ByPlugin[results[i].Plugin]++
}
return ScanReport{
TaskID: taskID,
StartedAt: time.Now(),
EndedAt: time.Now(),
Summary: summary,
Results: results,
}
}
func writeFile(path string, fn func(*os.File) error) error {
file, err := os.Create(path)
if err != nil {
return err
}
defer file.Close()
return fn(file)
}
测试策略
JSON结构测试
func TestJSONWriter(t *testing.T) {
report := BuildReport("task-1", []VulnResult{
{URL: "https://example.com", Plugin: "baseline", Severity: SeverityLow, Title: "Missing Header"},
})
var buf bytes.Buffer
err := JSONWriter{Pretty: true}.Write(&buf, report)
require.NoError(t, err)
assert.Contains(t, buf.String(), `"task_id": "task-1"`)
}
HTML转义测试
func TestHTMLWriterEscapesContent(t *testing.T) {
report := BuildReport("task-1", []VulnResult{
{
URL: "<script>alert(1)</script>",
Plugin: "test",
Severity: SeverityInfo,
Title: "escape test",
},
})
writer, _ := NewHTMLWriter()
var buf bytes.Buffer
require.NoError(t, writer.Write(&buf, report))
assert.NotContains(t, buf.String(), "<script>alert(1)</script>")
assert.Contains(t, buf.String(), "<script>")
}
去重测试
func TestCollectorDeduplicate(t *testing.T) {
collector := NewCollector()
result := VulnResult{
URL: "https://example.com/a",
Plugin: "sqldet",
VulnType: "sqli",
Severity: SeverityHigh,
}
assert.True(t, collector.Add(result))
assert.False(t, collector.Add(result))
assert.Len(t, collector.Results(), 1)
}
最容易踩的5个坑
坑1:直接拼接HTML
错误示例
html := "<td>" + result.URL + "</td>"
正确做法
tmpl.Execute(writer, report)
使用 html/template 可以显著降低报告 XSS 风险。
坑2:JSON字段不稳定
错误示例
type Result struct {
Msg string `json:"msg"`
}
正确做法
type Result struct {
Title string `json:"title"`
Evidence []Evidence `json:"evidence"`
}
JSON 是给平台集成使用的,字段命名要清晰且长期稳定。
坑3:报告只记录结论不记录证据
错误示例
{"title":"存在漏洞","severity":"high"}
正确做法
{
"title": "存在漏洞",
"severity": "high",
"evidence": [{"type": "response_diff", "summary": "响应差异明显"}]
}
没有证据的报告无法复核,也无法用于整改闭环。
坑4:内存中无限累积结果
错误示例
results = append(results, result)
正确做法
collector.Add(result)
jsonlWriter.Append(result)
大型任务要考虑增量落盘和最终汇总。
坑5:严重等级没有统一标准
错误示例
Severity: "危险"
Severity: "high"
Severity: "严重"
正确做法
const SeverityHigh Severity = "high"
等级字段必须枚举化,否则排序、筛选和统计都会混乱。
面试高频考点
考点1:如何设计一个支持多格式输出的报告系统?
回答要点:
- 定义统一的漏洞结果模型
- 所有格式从同一份数据渲染
- HTML、JSON、TXT 各自只负责呈现
- 输出入口统一管理文件写入和错误处理
- 测试不同格式的关键字段一致性
考点2:HTML报告如何防止XSS?
回答要点:
- 使用
html/template做上下文转义 - 不可信字段进入报告前做限长
- 不直接拼接 HTML 字符串
- JSON 嵌入 HTML 时注意转义
- 报告中的请求响应片段只作为文本展示
考点3:报告里的漏洞ID如何生成?
回答要点:
- 不能用数组下标
- 可由目标、URL、插件、漏洞类型、参数等字段计算
- ID 用于去重、引用、工单同步
- 需要在相同漏洞重复扫描时保持相对稳定
总结与扩展
核心经验总结
-
报告系统从数据模型开始
- 先结构化漏洞结果
- 再考虑输出格式
- 不要在格式层补业务字段
-
多格式输出要复用数据
- HTML 面向人工阅读
- JSON 面向平台集成
- TXT 面向快速查看
-
HTML报告必须安全渲染
- 目标响应不可信
- 证据内容要转义
- 请求响应片段要限长
-
证据链决定报告质量
- 结论要能复核
- 插件、URL、时间、证据都要保留
- 误报排查依赖证据
-
大型任务要支持增量输出
- 避免结果丢失
- 控制内存占用
- 便于长任务观察进度
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)