Files

211 lines
7.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package report
import (
"bytes"
"encoding/csv"
"fmt"
"path"
"regexp"
"sort"
"strings"
"time"
"github.com/Autumn-27/artex/db"
)
// 发现页「导出」用的渲染:把一批 findings 表行渲染成汇总 Markdown、单条 Markdown、
// 或 CSV。JSON 由 server 层直接用 DTO 序列化,不在此处。
// sortFindingsForExport 按严重等级降序、再按时间倒序排,与汇总报告的分组一致。
func sortFindingsForExport(fs []*db.DBFinding) {
sort.SliceStable(fs, func(i, j int) bool {
ri, rj := sevRank[fs[i].Severity], sevRank[fs[j].Severity]
if ri != rj {
return ri < rj // sevRank 越小越严重
}
return fs[i].CreatedAt.After(fs[j].CreatedAt)
})
}
// findingTitle 取漏洞可读标题:名称 → 类别 → 「未分类」。
func findingTitle(f *db.DBFinding) string {
return nz(f.Name, nz(f.VulnClass, "未分类"))
}
// FindingsMarkdown 把一批 findings 整合成一份汇总报告(摘要 + 按严重等级分组,
// 每条含类别/状态/所属任务/证据/详细报告)。
func FindingsMarkdown(fs []*db.DBFinding, generatedAt time.Time) string {
items := append([]*db.DBFinding(nil), fs...)
sortFindingsForExport(items)
var b strings.Builder
b.WriteString("# 漏洞发现汇总报告\n\n")
fmt.Fprintf(&b, "- **生成时间**:%s\n", generatedAt.Format("2006-01-02 15:04:05"))
fmt.Fprintf(&b, "- **发现总数**:%d 个\n\n", len(items))
// 摘要:各严重等级计数。
counts := map[string]int{}
for _, f := range items {
counts[f.Severity]++
}
b.WriteString("## 摘要\n\n")
b.WriteString("| 严重等级 | 数量 |\n| --- | --- |\n")
for _, s := range []struct{ key, label string }{
{"critical", "严重"}, {"high", "高危"}, {"medium", "中危"}, {"low", "低危"},
} {
fmt.Fprintf(&b, "| %s | %d |\n", s.label, counts[s.key])
}
if counts["unassessed"] > 0 {
fmt.Fprintf(&b, "| 待评估 | %d |\n", counts["unassessed"])
}
b.WriteString("\n")
if len(items) == 0 {
b.WriteString("_无匹配的漏洞。_\n")
return b.String()
}
b.WriteString("## 漏洞明细\n\n")
for i, f := range items {
fmt.Fprintf(&b, "### %d. [%s] %s\n\n", i+1, strings.ToUpper(nz(f.Severity, "info")), findingTitle(f))
if f.VulnClass != "" {
fmt.Fprintf(&b, "- **类别**:%s\n", f.VulnClass)
}
fmt.Fprintf(&b, "- **状态**:%s\n", nz(f.Status, "pending"))
if desc := strings.TrimSpace(f.TaskDescription); desc != "" {
fmt.Fprintf(&b, "- **所属任务**:%s\n", desc)
}
fmt.Fprintf(&b, "- **发现时间**:%s\n\n", f.CreatedAt.Format("2006-01-02 15:04:05"))
if s := strings.TrimSpace(f.Summary); s != "" {
fmt.Fprintf(&b, "%s\n\n", s)
}
if e := strings.TrimSpace(f.Evidence); e != "" {
fmt.Fprintf(&b, "**证据:**\n\n```\n%s\n```\n\n", e)
}
if rep := strings.TrimSpace(f.Report); rep != "" {
b.WriteString("**详细报告:**\n\n")
b.WriteString(rep)
b.WriteString("\n\n")
}
b.WriteString(findingTrafficMarkdown(f, false))
b.WriteString("---\n\n")
}
return b.String()
}
// SingleFindingMarkdown 渲染单条漏洞为一份独立 Markdown(用于「一漏洞一文件」打包)。
func SingleFindingMarkdown(f *db.DBFinding, generatedAt time.Time) string {
var b strings.Builder
fmt.Fprintf(&b, "# [%s] %s\n\n", strings.ToUpper(nz(f.Severity, "info")), findingTitle(f))
if f.VulnClass != "" {
fmt.Fprintf(&b, "- **类别**:%s\n", f.VulnClass)
}
fmt.Fprintf(&b, "- **严重等级**:%s\n", nz(f.Severity, "info"))
fmt.Fprintf(&b, "- **状态**:%s\n", nz(f.Status, "pending"))
if desc := strings.TrimSpace(f.TaskDescription); desc != "" {
fmt.Fprintf(&b, "- **所属任务**:%s\n", desc)
}
fmt.Fprintf(&b, "- **发现时间**:%s\n", f.CreatedAt.Format("2006-01-02 15:04:05"))
fmt.Fprintf(&b, "- **生成时间**:%s\n\n", generatedAt.Format("2006-01-02 15:04:05"))
if s := strings.TrimSpace(f.Summary); s != "" {
fmt.Fprintf(&b, "## 概述\n\n%s\n\n", s)
}
if e := strings.TrimSpace(f.Evidence); e != "" {
fmt.Fprintf(&b, "## 证据\n\n```\n%s\n```\n\n", e)
}
if rep := strings.TrimSpace(f.Report); rep != "" {
b.WriteString("## 详细报告\n\n")
b.WriteString(rep)
b.WriteString("\n")
}
b.WriteString(findingTrafficMarkdown(f, true))
return b.String()
}
var unsafeFilenameChars = regexp.MustCompile(`[^\p{Han}\p{L}\p{N}._-]+`)
// FindingFilename 为「一漏洞一文件」生成安全的 .md 文件名,形如
// `critical_SQL注入_#123.md`。去掉路径分隔符与控制字符,避免 zip 内非法路径。
func FindingFilename(f *db.DBFinding) string {
sev := nz(f.Severity, "info")
title := findingTitle(f)
name := fmt.Sprintf("%s_%s_#%d", sev, title, f.ID)
if f.ExportCaseID > 0 {
name = fmt.Sprintf("case_%d_%s_%s", f.ExportCaseID, sev, title)
}
name = unsafeFilenameChars.ReplaceAllString(name, "_")
name = strings.Trim(name, "._")
if name == "" {
name = fmt.Sprintf("finding_%d", f.ID)
}
// 防御性:再剥一层路径,杜绝 zip slip。
name = path.Base(name)
if len(name) > 120 {
name = name[:120]
}
return name + ".md"
}
// FindingsCSV 把一批 findings 渲染成 CSV(带 UTF-8 BOM,便于 Excel 正确识别中文)。
// 不含大段 report/evidence 全文,只放摘要类字段;需要全文用 Markdown/JSON 导出。
func FindingsCSV(fs []*db.DBFinding) []byte {
items := append([]*db.DBFinding(nil), fs...)
sortFindingsForExport(items)
var buf bytes.Buffer
buf.WriteString("\xEF\xBB\xBF") // UTF-8 BOM
w := csv.NewWriter(&buf)
_ = w.Write([]string{"ID", "名称", "类别", "严重等级", "状态", "所属任务", "发现时间", "概述", "流量证据数量", "流量证据ID"})
for _, f := range items {
id := fmt.Sprintf("%d", f.ID)
if f.ExportCaseID > 0 {
id = fmt.Sprintf("case:%d", f.ExportCaseID)
}
_ = w.Write([]string{
id,
findingTitle(f),
f.VulnClass,
nz(f.Severity, "info"),
nz(f.Status, "pending"),
f.TaskDescription,
f.CreatedAt.Format("2006-01-02 15:04:05"),
strings.TrimSpace(f.Summary),
fmt.Sprint(len(f.TrafficBindings)), findingTrafficIDs(f),
})
}
w.Flush()
return buf.Bytes()
}
func findingTrafficIDs(f *db.DBFinding) string {
ids := make([]string, 0, len(f.TrafficBindings))
for _, b := range f.TrafficBindings {
ids = append(ids, fmt.Sprint(b.ID))
}
return strings.Join(ids, ",")
}
func findingTrafficMarkdown(f *db.DBFinding, attachments bool) string {
stale := f.Report != "" && f.EvidenceVersion != f.ReportEvidenceVersion
if len(f.TrafficBindings) == 0 && !stale {
return ""
}
var out strings.Builder
out.WriteString("\n## 关联流量证据\n\n")
fmt.Fprintf(&out, "证据版本:%d;绑定数量:%d。\n\n", f.EvidenceVersion, len(f.TrafficBindings))
if stale {
out.WriteString("证据已变更,详细报告待更新。\n\n")
}
for i, b := range f.TrafficBindings {
fmt.Fprintf(&out, "%d. **证据 #%d · %s** — `%s %s`,状态码 %d\n", i+1, b.ID, b.Role, b.Snapshot.Method, strings.ReplaceAll(b.Snapshot.URL, "`", "%60"), b.Snapshot.Status)
if b.Note != "" {
fmt.Fprintf(&out, " %s\n", strings.ReplaceAll(b.Note, "\n", "\n "))
}
if attachments {
fmt.Fprintf(&out, " [请求报文](evidence/%d/%d/request.http) · [响应报文](evidence/%d/%d/response.http)\n", f.ID, b.ID, f.ID, b.ID)
}
}
out.WriteString("\n")
return out.String()
}