From baa1d02cd428c3dd541009452b6f2adb7700b0be Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:29:44 +0800 Subject: [PATCH] feat(vulnerabilities): offer Markdown and Word report downloads --- CHANGELOG.md | 2 + docs/en-US/word-reports.md | 11 ++ docs/zh-CN/word-reports.md | 11 ++ internal/app/app.go | 1 + internal/handler/vulnerability_download.go | 83 ++++++++ .../handler/vulnerability_download_test.go | 108 +++++++++++ internal/report/docx.go | 181 ++++++++++++++++++ internal/report/docx_test.go | 91 +++++++++ web/static/css/vulnerability-download.css | 18 ++ web/static/i18n/en-US.json | 9 + web/static/i18n/ru-RU.json | 9 + web/static/i18n/zh-CN.json | 9 + web/static/js/vulnerability-download.js | 61 ++++++ web/static/js/vulnerability.js | 8 +- web/templates/index.html | 17 +- 15 files changed, 615 insertions(+), 4 deletions(-) create mode 100644 docs/en-US/word-reports.md create mode 100644 docs/zh-CN/word-reports.md create mode 100644 internal/handler/vulnerability_download.go create mode 100644 internal/handler/vulnerability_download_test.go create mode 100644 internal/report/docx.go create mode 100644 internal/report/docx_test.go create mode 100644 web/static/css/vulnerability-download.css create mode 100644 web/static/js/vulnerability-download.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 83e31674..6b6da7b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- 漏洞列表的单条下载按钮新增格式选择弹窗,支持 Markdown 与 Word(.docx),Word 下载复用漏洞读取权限并仅包含所选漏洞(#365)。 + - 新增单代理与多代理异步对话 API:接收后返回任务回执,客户端断线不停止执行;支持按用户隔离的幂等提交、持久状态与结果查询、显式取消以及服务重启后的中断状态。同步接口保持兼容。 - 启动时以统一最新 SQLite 定义自动检查全部项目管理表,缺表建表、缺字段补字段,无需维护逐字段迁移列表;保留已有数据和额外表/字段,结构补齐失败回滚并阻止启动。HITL 与通知表纳入统一初始化,历史漏洞外键迁移保留额外列、索引、触发器及依赖记录。 diff --git a/docs/en-US/word-reports.md b/docs/en-US/word-reports.md new file mode 100644 index 00000000..5271cc0b --- /dev/null +++ b/docs/en-US/word-reports.md @@ -0,0 +1,11 @@ +# Vulnerability report download + +Click the download icon beside a vulnerability. A dialog offers **Markdown (.md)** and **Word (.docx)**. Choosing a format downloads only that finding. Cancel or Escape dismisses the dialog without downloading. A failed download leaves the dialog available for retry. + +Markdown retains the existing content, UI language and filename. Word includes the selected finding's metadata, description, prerequisites, reproduction steps, evidence, impact, remediation and retest records. Original statuses remain intact. Evidence is plain text; embedded HTML, Markdown formatting and screenshots are not rendered. + +The feature creates no automatic tasks, database schema changes or stored report artifacts. No Office, Python or Pandoc installation is required on the server. The Word viewer needs Chinese font support. + +Word uses `GET /api/vulnerabilities/:id/download?format=docx`, authorized with `vulnerability:read` and the finding's resource scope. No file-management permission is needed. The endpoint also accepts `format=md`. Unsupported formats return 400, missing records 404 and unauthorized access 403. Word body input is limited to 8 MiB. + +Existing page-level Markdown batch export is unchanged. diff --git a/docs/zh-CN/word-reports.md b/docs/zh-CN/word-reports.md new file mode 100644 index 00000000..93b6655f --- /dev/null +++ b/docs/zh-CN/word-reports.md @@ -0,0 +1,11 @@ +# 漏洞报告下载 + +在漏洞管理列表中,点击每条漏洞右侧的下载图标,弹窗选择 **Markdown(.md)** 或 **Word(.docx)**。选择后下载该条漏洞,取消或按 Esc 不会下载。下载失败时保留弹窗,可重新选择。 + +Markdown 沿用现有内容、界面语言与文件名。Word 包含所选漏洞的基本信息、描述、前置条件、复现步骤、证据、影响、修复建议和复测记录;保留原始状态,待确认或误报不会改写为已确认漏洞。证据作为纯文本导出,不解析其中的 HTML 或 Markdown,也不自动嵌入截图。 + +此功能不配置自动生成任务,不修改数据库结构,也不保存额外的报告产物。服务器无需安装 Office、Python 或 Pandoc;查看 Word 文件的软件需要中文字体。 + +Word 下载使用 `GET /api/vulnerabilities/:id/download?format=docx`,要求 `vulnerability:read` 及对应漏洞的资源访问范围,无需额外的文件管理权限。接口也支持 `format=md`,格式参数不合法返回 400,不存在的记录返回 404,越权请求返回 403。Word 正文限制为 8 MiB,超出时返回生成失败。 + +原有页面级 Markdown 批量导出保持不变。 diff --git a/internal/app/app.go b/internal/app/app.go index b7b2d8ff..891c22c1 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1333,6 +1333,7 @@ func setupRoutes( protected.GET("/vulnerability-alerts/subscription", vulnerabilityHandler.GetMyAlertSubscription) protected.PUT("/vulnerability-alerts/subscription", vulnerabilityHandler.UpdateMyAlertSubscription) protected.GET("/vulnerabilities/:id", vulnerabilityHandler.GetVulnerability) + protected.GET("/vulnerabilities/:id/download", vulnerabilityHandler.DownloadVulnerability) protected.POST("/vulnerabilities", vulnerabilityHandler.CreateVulnerability) protected.POST("/vulnerabilities/:id/retest", vulnerabilityHandler.CreateRetest) protected.PUT("/vulnerabilities/:id", vulnerabilityHandler.UpdateVulnerability) diff --git a/internal/handler/vulnerability_download.go b/internal/handler/vulnerability_download.go new file mode 100644 index 00000000..e1ddb685 --- /dev/null +++ b/internal/handler/vulnerability_download.go @@ -0,0 +1,83 @@ +package handler + +import ( + "mime" + "net/http" + "strconv" + "strings" + "time" + + "cyberstrike-ai/internal/database" + "cyberstrike-ai/internal/report" + "cyberstrike-ai/internal/security" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// DownloadVulnerability exports exactly one record under the same RBAC boundary +// as its detail view. It does not create artifacts or require files permissions. +func (h *VulnerabilityHandler) DownloadVulnerability(c *gin.Context) { + format := c.DefaultQuery("format", "docx") + if format != "docx" && format != "md" { + c.JSON(400, gin.H{"error": "format must be md or docx"}) + return + } + session, ok := security.CurrentSession(c) + if !ok || session.UserID == "" { + c.JSON(401, gin.H{"error": "unauthorized"}) + return + } + if !security.SessionHasPermission(c, "vulnerability:read") || !h.db.UserCanAccessResource(session.UserID, session.ScopeFor("vulnerability:read"), "vulnerability", c.Param("id")) { + c.JSON(403, gin.H{"error": "无权访问该漏洞"}) + return + } + var exists bool + if err := h.db.QueryRow("SELECT EXISTS(SELECT 1 FROM vulnerabilities WHERE id=?)", c.Param("id")).Scan(&exists); err != nil { + c.JSON(500, gin.H{"error": "读取漏洞失败"}) + return + } + if !exists { + c.JSON(404, gin.H{"error": "漏洞不存在"}) + return + } + v, err := h.db.GetVulnerability(c.Param("id")) + if err != nil { + h.logger.Warn("读取漏洞下载记录失败", zap.Error(err)) + c.JSON(500, gin.H{"error": "读取漏洞失败"}) + return + } + var data []byte + contentType := "text/markdown; charset=utf-8" + if format == "md" { + var b strings.Builder + appendVulnerabilityMarkdown(&b, v, "#") + data = []byte(b.String()) + } else { + template := report.DefaultTemplate() + // A long vulnerability title remains intact in the detail; the cover is bounded. + title := []rune(v.Title) + if len(title) > 180 { + title = title[:180] + } + template.Title = string(title) + " 漏洞报告" + template.Scope = v.Target + template.Introduction = "本报告记录该漏洞的描述、证据与修复建议。" + template.IncludeSummary = false + data, err = report.Render(template, report.Snapshot{ConversationID: v.ConversationID, ConversationTitle: v.ConversationTag, GeneratedAt: time.Now(), Findings: []*database.Vulnerability{v}}) + if err != nil { + h.logger.Warn("生成漏洞 Word 报告失败", zap.Error(err)) + c.JSON(500, gin.H{"error": "生成 Word 报告失败"}) + return + } + contentType = report.MIME + } + title := []rune(sanitizeExportName(v.Title)) + if len(title) > 50 { + title = title[:50] + } + filename := string(title) + "_" + v.ID + "." + format + c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename})) + c.Header("Content-Length", strconv.Itoa(len(data))) + c.Header("Cache-Control", "no-store") + c.Data(http.StatusOK, contentType, data) +} diff --git a/internal/handler/vulnerability_download_test.go b/internal/handler/vulnerability_download_test.go new file mode 100644 index 00000000..328b83c8 --- /dev/null +++ b/internal/handler/vulnerability_download_test.go @@ -0,0 +1,108 @@ +package handler + +import ( + "archive/zip" + "bytes" + "io" + "mime" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "cyberstrike-ai/internal/database" + "cyberstrike-ai/internal/report" + "cyberstrike-ai/internal/security" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +func TestSingleVulnerabilityDownloadFormatsAndIsolation(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := database.NewDB(filepath.Join(t.TempDir(), "download.db"), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + user, err := db.CreateRBACUser("report-reader", "Reader", "hash", true, nil) + if err != nil { + t.Fatal(err) + } + selected, err := db.CreateVulnerability(&database.Vulnerability{Title: "中文漏洞 <测试>", Severity: "high", Status: "open", Evidence: "sample & value", Recommendation: "修复权限验证"}) + if err != nil { + t.Fatal(err) + } + other, err := db.CreateVulnerability(&database.Vulnerability{Title: "other-secret-finding", Severity: "low", Status: "confirmed"}) + if err != nil { + t.Fatal(err) + } + if err = db.SetResourceOwner("vulnerability", selected.ID, user.ID); err != nil { + t.Fatal(err) + } + h := NewVulnerabilityHandler(db, zap.NewNop()) + session := security.Session{UserID: user.ID, Scope: database.RBACScopeAll, Permissions: map[string]bool{"vulnerability:read": true}, PermissionScopes: map[string]string{"vulnerability:read": database.RBACScopeOwn}} + request := func(id, format string, s security.Session) *httptest.ResponseRecorder { + r := gin.New() + r.Use(func(c *gin.Context) { c.Set(security.ContextSessionKey, s) }, security.RBACMiddleware(db)) + r.GET("/api/vulnerabilities/:id/download", h.DownloadVulnerability) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/api/vulnerabilities/"+id+"/download?format="+format, nil)) + return w + } + for _, format := range []string{"md", "docx"} { + w := request(selected.ID, format, session) + if w.Code != 200 { + t.Fatalf("%s: %d %s", format, w.Code, w.Body) + } + _, params, err := mime.ParseMediaType(w.Header().Get("Content-Disposition")) + if err != nil || !strings.Contains(params["filename"], "中文漏洞") || !strings.HasSuffix(params["filename"], "."+format) { + t.Fatalf("filename %v %v", params, err) + } + content := w.Body.String() + if format == "docx" { + if w.Header().Get("Content-Type") != report.MIME { + t.Fatal("incorrect DOCX MIME") + } + z, err := zip.NewReader(bytes.NewReader(w.Body.Bytes()), int64(w.Body.Len())) + if err != nil { + t.Fatal(err) + } + for _, f := range z.File { + if f.Name == "word/document.xml" { + r, err := f.Open() + if err != nil { + t.Fatal(err) + } + b, err := io.ReadAll(r) + r.Close() + if err != nil { + t.Fatal(err) + } + content = string(b) + } + } + if !strings.Contains(content, "<evidence> & value") { + t.Fatal("evidence not escaped") + } + } + if !strings.Contains(content, selected.ID) || !strings.Contains(content, "修复权限验证") || strings.Contains(content, "other-secret-finding") { + t.Fatal("wrong finding exported") + } + } + if w := request(other.ID, "docx", session); w.Code != 403 { + t.Fatalf("unrelated global scope widened download: %d", w.Code) + } + denied := session + denied.Permissions = map[string]bool{} + if w := request(selected.ID, "docx", denied); w.Code != 403 { + t.Fatalf("missing permission: %d", w.Code) + } + if w := request(selected.ID, "pdf", session); w.Code != 400 { + t.Fatalf("invalid format: %d", w.Code) + } + all := session + all.PermissionScopes = nil + if w := request("missing", "docx", all); w.Code != 404 { + t.Fatalf("missing record: %d", w.Code) + } +} diff --git a/internal/report/docx.go b/internal/report/docx.go new file mode 100644 index 00000000..19ef34c3 --- /dev/null +++ b/internal/report/docx.go @@ -0,0 +1,181 @@ +// Package report renders evidence already collected by a conversation into Word. +// It never runs tools, calls an LLM, or interprets template content as code. +package report + +import ( + "archive/zip" + "bytes" + "encoding/xml" + "fmt" + "strings" + "time" + "unicode/utf8" + + "cyberstrike-ai/internal/database" +) + +const MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + +// Template is intentionally structured: text is literal, not executable markup. +type Template struct { + Title string `json:"title"` + Organization string `json:"organization"` + Scope string `json:"scope"` + Introduction string `json:"introduction"` + IncludeSummary bool `json:"include_summary"` + IncludeEvidence bool `json:"include_evidence"` + IncludeRemediation bool `json:"include_remediation"` +} + +func DefaultTemplate() Template { + return Template{Title: "安全检查报告", Introduction: "本报告汇总当前会话已记录的检查结果,供人工复核与修复跟踪。", IncludeSummary: true, IncludeEvidence: true, IncludeRemediation: true} +} +func (t Template) Validate() error { + if strings.TrimSpace(t.Title) == "" { + return fmt.Errorf("报告标题不能为空") + } + for _, f := range []struct { + name, value string + limit int + }{{"标题", t.Title, 200}, {"单位", t.Organization, 500}, {"检查范围", t.Scope, 10000}, {"章节说明", t.Introduction, 10000}} { + if !utf8.ValidString(f.value) || utf8.RuneCountInString(f.value) > f.limit { + return fmt.Errorf("%s超出长度限制(%d 字)", f.name, f.limit) + } + } + return nil +} + +type Snapshot struct { + ConversationID, ConversationTitle, RunID, Summary string + GeneratedAt time.Time + Findings []*database.Vulnerability +} + +func escaped(s string) string { + // XML 1.0 excludes control characters sometimes found in tool output. + s = strings.Map(func(r rune) rune { + if r == 9 || r == 10 || r == 13 || r >= 0x20 && r <= 0xD7FF || r >= 0xE000 && r <= 0xFFFD || r >= 0x10000 && r <= 0x10FFFF { + return r + } + return -1 + }, s) + var b bytes.Buffer + _ = xml.EscapeText(&b, []byte(s)) + return b.String() +} + +// Render creates a standalone OOXML document with A4 pages and Chinese fonts. +func Render(t Template, s Snapshot) ([]byte, error) { + if err := t.Validate(); err != nil { + return nil, err + } + if len(s.Findings) > 2000 { + return nil, fmt.Errorf("报告超过 2000 条发现限制") + } + size := len(s.Summary) + for _, v := range s.Findings { + if v == nil { + return nil, fmt.Errorf("发现记录不能为空") + } + for _, text := range []string{v.ID, v.Title, v.Description, v.Severity, v.Status, v.Type, v.Target, v.Preconditions, v.ReproSteps, v.Evidence, v.Impact, v.Recommendation, v.RetestNotes, v.RetestLog} { + size += len(text) + } + } + if size > 8*1024*1024 { + return nil, fmt.Errorf("报告正文超过 8 MiB 限制,请缩小范围") + } + var body strings.Builder + p := func(style, text string) { + for _, line := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") { + body.WriteString(`` + escaped(line) + ``) + } + } + field := func(label, value string) { + if strings.TrimSpace(value) == "" { + value = "未记录" + } + p("Normal", label+":"+value) + } + p("Title", t.Title) + if t.Organization != "" { + field("单位", t.Organization) + } + field("生成时间", s.GeneratedAt.Format("2006-01-02 15:04:05 -07:00")) + if s.ConversationID != "" { + field("会话", s.ConversationTitle+"("+s.ConversationID+")") + } + if s.RunID != "" { + field("运行 ID", s.RunID) + } + p("Heading1", "检查范围与说明") + field("检查范围", t.Scope) + p("Normal", t.Introduction) + if len(s.Findings) != 1 { + p("Normal", "本报告保留生成时的记录状态。未记录漏洞不代表目标安全,记录状态、证据充分性及覆盖范围需人工复核。") + p("Heading1", "风险统计") + counts := map[string]int{} + for _, v := range s.Findings { + counts[v.Severity]++ + } + field("发现总数", fmt.Sprint(len(s.Findings))) + for _, severity := range []string{"critical", "high", "medium", "low", "info"} { + field(severity, fmt.Sprint(counts[severity])) + } + p("Normal", "统计包含当前筛选范围内的全部记录;误报、已忽略与待确认记录请结合各项状态判断,不应全部视为已验证漏洞。") + } + if t.IncludeSummary { + p("Heading1", "最终回复(原文,待人工复核)") + field("会话最终回复", s.Summary) + } + if len(s.Findings) == 1 { + p("Heading1", "漏洞详情") + } else { + p("Heading1", "发现详情") + } + if len(s.Findings) == 0 { + p("Normal", "当前范围内没有已记录的漏洞;这不构成安全保证。") + } + for i, v := range s.Findings { + if len(s.Findings) != 1 { + p("Heading2", fmt.Sprintf("%d. %s", i+1, v.Title)) + } else { + p("Heading2", v.Title) + } + for _, f := range [][2]string{{"记录 ID", v.ID}, {"严重程度", v.Severity}, {"记录状态", v.Status}, {"类型", v.Type}, {"目标", v.Target}, {"描述", v.Description}, {"前置条件", v.Preconditions}, {"影响", v.Impact}} { + field(f[0], f[1]) + } + if t.IncludeEvidence { + field("复现步骤", v.ReproSteps) + field("证据 / POC", v.Evidence) + } + if t.IncludeRemediation { + field("修复建议", v.Recommendation) + field("复测方式", v.RetestNotes) + field("复测记录", v.RetestLog) + } + } + body.WriteString(``) + doc := `` + body.String() + `` + files := []struct{ name, content string }{ + {"[Content_Types].xml", ``}, + {"_rels/.rels", ``}, + {"word/_rels/document.xml.rels", ``}, + {"word/document.xml", doc}, + {"word/styles.xml", ``}, + } + var out bytes.Buffer + z := zip.NewWriter(&out) + for _, f := range files { + w, err := z.Create(f.name) + if err != nil { + return nil, err + } + if _, err = w.Write([]byte(f.content)); err != nil { + return nil, err + } + } + if err := z.Close(); err != nil { + return nil, err + } + return out.Bytes(), nil +} diff --git a/internal/report/docx_test.go b/internal/report/docx_test.go new file mode 100644 index 00000000..659bb628 --- /dev/null +++ b/internal/report/docx_test.go @@ -0,0 +1,91 @@ +package report + +import ( + "archive/zip" + "bytes" + "encoding/xml" + "io" + "strings" + "testing" + "time" + + "cyberstrike-ai/internal/database" +) + +func TestWordReportEscapesEvidenceAndHonorsSections(t *testing.T) { + template := DefaultTemplate() + template.Title = "中文报告 & <客户>" + template.IncludeRemediation = false + v := &database.Vulnerability{Title: "待确认发现", Severity: "high", Status: "false_positive", Evidence: " + @@ -7018,6 +7019,19 @@ + +
+

下载漏洞报告

+

请选择下载格式

+
+ + +
+

+ +
+
+ @@ -7043,7 +7057,8 @@ - + +