mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-11-13 15:57:22 +01:00
Merge pull request #378 from Sycun/codex/issue-365-word-reports
feat(vulnerabilities): 单条漏洞下载支持选择 Markdown 或 Word
This commit is contained in:
15 files changed
+615
-4
No files matched your search
@@ -6,6 +6,8 @@
|
||||
|
||||
- 支持通过 `agent.task_timeout_minutes` 配置单代理、多代理、工作流及异步任务执行超时:未配置默认 600 分钟,`0` 表示不限时,负数拒绝加载;续跑保留重新计时行为,单次工具超时仍独立生效。
|
||||
|
||||
- 漏洞列表的单条下载按钮新增格式选择弹窗,支持 Markdown 与 Word(.docx),Word 下载复用漏洞读取权限并仅包含所选漏洞(#365)。
|
||||
|
||||
- 新增单代理与多代理异步对话 API:接收后返回任务回执,客户端断线不停止执行;支持按用户隔离的幂等提交、持久状态与结果查询、显式取消以及服务重启后的中断状态。同步接口保持兼容。
|
||||
|
||||
- 启动时以统一最新 SQLite 定义自动检查全部项目管理表,缺表建表、缺字段补字段,无需维护逐字段迁移列表;保留已有数据和额外表/字段,结构补齐失败回滚并阻止启动。HITL 与通知表纳入统一初始化,历史漏洞外键迁移保留额外列、索引、触发器及依赖记录。
|
||||
|
||||
@@ -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.
|
||||
@@ -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 批量导出保持不变。
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 <evidence> & 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)
|
||||
}
|
||||
}
|
||||
@@ -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(`<w:p><w:pPr><w:pStyle w:val="` + style + `"/></w:pPr><w:r><w:t xml:space="preserve">` + escaped(line) + `</w:t></w:r></w:p>`)
|
||||
}
|
||||
}
|
||||
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(`<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr>`)
|
||||
doc := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>` + body.String() + `</w:body></w:document>`
|
||||
files := []struct{ name, content string }{
|
||||
{"[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/></Types>`},
|
||||
{"_rels/.rels", `<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`},
|
||||
{"word/_rels/document.xml.rels", `<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>`},
|
||||
{"word/document.xml", doc},
|
||||
{"word/styles.xml", `<?xml version="1.0" encoding="UTF-8"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:eastAsia="Microsoft YaHei"/><w:sz w:val="22"/></w:rPr></w:rPrDefault><w:pPrDefault><w:pPr><w:spacing w:after="140" w:line="320" w:lineRule="auto"/></w:pPr></w:pPrDefault></w:docDefaults><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style><w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:pPr><w:keepNext/><w:spacing w:after="360"/></w:pPr><w:rPr><w:b/><w:sz w:val="40"/><w:color w:val="000000"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:pPr><w:keepNext/><w:spacing w:before="300" w:after="160"/><w:outlineLvl w:val="0"/></w:pPr><w:rPr><w:b/><w:sz w:val="30"/><w:color w:val="17365D"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:pPr><w:keepNext/><w:spacing w:before="220"/><w:outlineLvl w:val="1"/></w:pPr><w:rPr><w:b/><w:sz w:val="25"/></w:rPr></w:style></w:styles>`},
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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: "<script>&\x00\nGET /?x=1&y=2", Recommendation: "must-be-omitted"}
|
||||
data, err := Render(template, Snapshot{Summary: "原始回复", GeneratedAt: time.Now(), Findings: []*database.Vulnerability{v}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
z, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts := map[string]string{}
|
||||
for _, f := range z.File {
|
||||
r, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := io.ReadAll(r)
|
||||
r.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoder := xml.NewDecoder(bytes.NewReader(b))
|
||||
for {
|
||||
_, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("invalid XML in %s: %v", f.Name, err)
|
||||
}
|
||||
}
|
||||
parts[f.Name] = string(b)
|
||||
}
|
||||
for _, name := range []string{"[Content_Types].xml", "_rels/.rels", "word/document.xml", "word/styles.xml", "word/_rels/document.xml.rels"} {
|
||||
if parts[name] == "" {
|
||||
t.Fatalf("missing part %s", name)
|
||||
}
|
||||
}
|
||||
doc := parts["word/document.xml"]
|
||||
for _, text := range []string{"中文报告 & <客户>", "<script>&", "false_positive", "原始回复"} {
|
||||
if !strings.Contains(doc, text) {
|
||||
t.Errorf("missing %q", text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(doc, "must-be-omitted") || strings.ContainsRune(doc, 0) {
|
||||
t.Fatal("excluded content or invalid control character retained")
|
||||
}
|
||||
template.IncludeEvidence = false
|
||||
data, err = Render(template, Snapshot{Findings: []*database.Vulnerability{v}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
z, _ = zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
for _, f := range z.File {
|
||||
if f.Name == "word/document.xml" {
|
||||
r, _ := f.Open()
|
||||
b, _ := io.ReadAll(r)
|
||||
r.Close()
|
||||
if bytes.Contains(b, []byte("script")) {
|
||||
t.Fatal("evidence not excluded")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestWordReportRejectsOversizedInput(t *testing.T) {
|
||||
if _, err := Render(DefaultTemplate(), Snapshot{Summary: strings.Repeat("x", 8*1024*1024+1)}); err == nil {
|
||||
t.Fatal("oversized report accepted")
|
||||
}
|
||||
template := DefaultTemplate()
|
||||
template.Title = ""
|
||||
if _, err := Render(template, Snapshot{}); err == nil {
|
||||
t.Fatal("empty title accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
#vulnerability-download-dialog {
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
max-height: 90vh;
|
||||
padding: 0;
|
||||
margin: auto;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
#vulnerability-download-dialog::backdrop { background: rgba(0, 0, 0, .45); }
|
||||
.vulnerability-download-content { padding: 24px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.vulnerability-download-content h3, .vulnerability-download-content p { margin: 0; }
|
||||
.vulnerability-download-content p { color: var(--text-secondary); overflow-wrap: anywhere; }
|
||||
.vulnerability-download-formats { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.vulnerability-download-formats button { flex: 1 1 140px; }
|
||||
#vulnerability-download-message:empty { display: none; }
|
||||
@@ -5134,5 +5134,14 @@
|
||||
"promptRequired": "Enter a retest prompt.",
|
||||
"navigationChanged": "You switched conversations. Return to the retest branch to send the prompt.",
|
||||
"branchCreated": "The retest branch was created. Continue verification in that branch."
|
||||
},
|
||||
"vulnerabilityDownload": {
|
||||
"title": "Download vulnerability report",
|
||||
"hint": "Choose a file format",
|
||||
"markdown": "Markdown (.md)",
|
||||
"word": "Word (.docx)",
|
||||
"cancel": "Cancel",
|
||||
"download": "Download vulnerability report",
|
||||
"busy": "Preparing download…"
|
||||
}
|
||||
}
|
||||
@@ -5147,5 +5147,14 @@
|
||||
"promptRequired": "Введите инструкцию для проверки.",
|
||||
"navigationChanged": "Вы переключились на другой диалог. Вернитесь в ветку проверки для отправки инструкции.",
|
||||
"branchCreated": "Ветка проверки создана. Продолжите проверку в этой ветке."
|
||||
},
|
||||
"vulnerabilityDownload": {
|
||||
"title": "Скачать отчёт об уязвимости",
|
||||
"hint": "Выберите формат файла",
|
||||
"markdown": "Markdown (.md)",
|
||||
"word": "Word (.docx)",
|
||||
"cancel": "Отмена",
|
||||
"download": "Скачать отчёт об уязвимости",
|
||||
"busy": "Подготовка файла…"
|
||||
}
|
||||
}
|
||||
@@ -5122,5 +5122,14 @@
|
||||
"promptRequired": "请输入复测提示词。",
|
||||
"navigationChanged": "已切换到其他对话,请返回复测分支继续发送。",
|
||||
"branchCreated": "复测分支已创建,请在该分支继续验证。"
|
||||
},
|
||||
"vulnerabilityDownload": {
|
||||
"title": "下载漏洞报告",
|
||||
"hint": "请选择下载格式",
|
||||
"markdown": "Markdown(.md)",
|
||||
"word": "Word(.docx)",
|
||||
"cancel": "取消",
|
||||
"download": "下载漏洞报告",
|
||||
"busy": "正在准备下载…"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
let vulnerabilityDownloadID = null;
|
||||
let vulnerabilityDownloadBusy = false;
|
||||
let vulnerabilityDownloadButton = null;
|
||||
function openVulnerabilityDownload(id, event) {
|
||||
if (event) event.stopPropagation();
|
||||
if (vulnerabilityDownloadBusy) return;
|
||||
vulnerabilityDownloadID = id;
|
||||
vulnerabilityDownloadButton = event?.target?.closest('button') || null;
|
||||
document.getElementById('vulnerability-download-message').textContent = '';
|
||||
document.getElementById('vulnerability-download-dialog').showModal();
|
||||
}
|
||||
function closeVulnerabilityDownload() {
|
||||
if (vulnerabilityDownloadBusy) return;
|
||||
document.getElementById('vulnerability-download-dialog').close();
|
||||
vulnerabilityDownloadID = null;
|
||||
}
|
||||
async function downloadSelectedVulnerability(format) {
|
||||
if (!vulnerabilityDownloadID || vulnerabilityDownloadBusy || !['md', 'docx'].includes(format)) return;
|
||||
vulnerabilityDownloadBusy = true;
|
||||
const dialog = document.getElementById('vulnerability-download-dialog');
|
||||
const message = document.getElementById('vulnerability-download-message');
|
||||
dialog.querySelectorAll('button').forEach(button => { button.disabled = true; });
|
||||
message.textContent = vulnT('vulnerabilityDownload.busy');
|
||||
try {
|
||||
if (format === 'md') {
|
||||
// Preserve the existing localized Markdown content and filename.
|
||||
const ok = await downloadVulnerabilityAsMarkdown(vulnerabilityDownloadID, { target: vulnerabilityDownloadButton });
|
||||
if (!ok) { message.textContent = ''; return; }
|
||||
} else {
|
||||
const response = await apiFetch(`/api/vulnerabilities/${encodeURIComponent(vulnerabilityDownloadID)}/download?format=docx`);
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(body.error || vulnT('vulnerabilityPage.downloadFailed'));
|
||||
}
|
||||
const disposition = response.headers.get('Content-Disposition') || '';
|
||||
const encodedName = disposition.match(/filename\*=utf-8''([^;]+)/i);
|
||||
let filename = `vulnerability-${vulnerabilityDownloadID}.docx`;
|
||||
if (encodedName) filename = decodeURIComponent(encodedName[1]);
|
||||
else {
|
||||
const plainName = disposition.match(/filename="([^"]+)"|filename=([^;]+)/i);
|
||||
if (plainName) filename = plainName[1] || plainName[2];
|
||||
}
|
||||
const url = URL.createObjectURL(await response.blob());
|
||||
const link = document.createElement('a');
|
||||
link.href = url; link.download = filename;
|
||||
document.body.appendChild(link); link.click(); link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
dialog.close();
|
||||
vulnerabilityDownloadID = null;
|
||||
} catch (error) {
|
||||
message.textContent = vulnT('vulnerabilityPage.downloadFailed') + ': ' + error.message;
|
||||
} finally {
|
||||
vulnerabilityDownloadBusy = false;
|
||||
dialog.querySelectorAll('button').forEach(button => { button.disabled = false; });
|
||||
}
|
||||
}
|
||||
document.getElementById('vulnerability-download-dialog').addEventListener('cancel', event => {
|
||||
if (vulnerabilityDownloadBusy) event.preventDefault();
|
||||
else vulnerabilityDownloadID = null;
|
||||
});
|
||||
@@ -1387,7 +1387,7 @@ function renderVulnerabilities(vulnerabilities, renderOptions) {
|
||||
const projectBadge = vuln.project_id
|
||||
? `<span class="vulnerability-project-badge" title="${escapeAttr(vuln.project_id)}">${escapeHtml(vulnT('vulnerabilityPage.detailProject'))}: ${projectLabel}</span>`
|
||||
: `<span class="vulnerability-project-badge vulnerability-project-badge--unbound">${escapeHtml(vulnT('vulnerabilityPage.projectUnbound'))}</span>`;
|
||||
const dlTitle = escapeHtml(vulnT('vulnerabilityPage.downloadMarkdownTitle'));
|
||||
const dlTitle = escapeHtml(vulnT('vulnerabilityDownload.download'));
|
||||
const editTitle = escapeHtml(vulnT('common.edit'));
|
||||
const deleteTitle = escapeHtml(vulnT('common.delete'));
|
||||
const vulnIdJs = escapeJsStringAttr(vuln.id);
|
||||
@@ -1411,7 +1411,7 @@ function renderVulnerabilities(vulnerabilities, renderOptions) {
|
||||
</div>
|
||||
<div class="vulnerability-actions" onclick="event.stopPropagation();">
|
||||
<button type="button" class="btn-ghost" data-require-permission="vulnerability:write" onclick="openVulnerabilityRetest(${vulnIdJs})" ${vuln.conversation_id ? '' : 'disabled'}>${escapeHtml(vulnT('vulnerabilityRetest.button'))}</button>
|
||||
<button class="btn-ghost" onclick="downloadVulnerabilityAsMarkdown(${vulnIdJs}, event)" title="${dlTitle}">
|
||||
<button class="btn-ghost" type="button" onclick="openVulnerabilityDownload(${vulnIdJs}, event)" title="${dlTitle}" aria-label="${dlTitle}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="7 10 12 15 17 10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
@@ -2381,16 +2381,18 @@ async function downloadVulnerabilityAsMarkdown(id, event) {
|
||||
if (event && event.target) {
|
||||
const button = event.target.closest('button');
|
||||
if (button) {
|
||||
const originalTitle = button.title || vulnT('vulnerabilityPage.downloadMarkdownTitle');
|
||||
const originalTitle = button.title || vulnT('vulnerabilityDownload.download');
|
||||
button.title = vulnT('vulnerabilityPage.downloadOkTitle');
|
||||
setTimeout(() => {
|
||||
button.title = originalTitle;
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('下载失败:', error);
|
||||
alert(vulnT('vulnerabilityPage.downloadFailed') + ': ' + error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=20261027-pagination-custom-select">
|
||||
<link rel="stylesheet" href="/static/css/vulnerability-download.css?v=20261030-2">
|
||||
<link rel="stylesheet" href="/static/css/tool-guard.css?v=20260907-7">
|
||||
<link rel="stylesheet" href="/static/css/chat-plan-progress.css?v=20260813-4">
|
||||
<link rel="stylesheet" href="/static/css/c2.css">
|
||||
@@ -7019,6 +7020,19 @@
|
||||
<script src="/static/js/builtin-tools.js"></script>
|
||||
<script src="/static/js/auth.js?v=20261008-password-eye-1"></script>
|
||||
<script src="/static/js/modal.js"></script>
|
||||
<dialog id="vulnerability-download-dialog" aria-labelledby="vulnerability-download-heading" aria-describedby="vulnerability-download-hint">
|
||||
<div class="vulnerability-download-content">
|
||||
<h3 id="vulnerability-download-heading" data-i18n="vulnerabilityDownload.title">下载漏洞报告</h3>
|
||||
<p id="vulnerability-download-hint" data-i18n="vulnerabilityDownload.hint">请选择下载格式</p>
|
||||
<div class="vulnerability-download-formats">
|
||||
<button type="button" class="btn-secondary" data-download-format="md" onclick="downloadSelectedVulnerability('md')" data-i18n="vulnerabilityDownload.markdown">Markdown(.md)</button>
|
||||
<button type="button" class="btn-primary" data-download-format="docx" onclick="downloadSelectedVulnerability('docx')" data-i18n="vulnerabilityDownload.word">Word(.docx)</button>
|
||||
</div>
|
||||
<p id="vulnerability-download-message" role="status" aria-live="polite"></p>
|
||||
<button type="button" class="btn-secondary" onclick="closeVulnerabilityDownload()" data-i18n="vulnerabilityDownload.cancel">取消</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script src="/static/js/notifications.js"></script>
|
||||
<script src="/static/js/info-collect.js?v=20260717-1"></script>
|
||||
<script src="/static/js/assets.js?v=20260717-7"></script>
|
||||
@@ -7044,7 +7058,8 @@
|
||||
<script src="/static/js/skills.js?v=20261027-pagination-custom-select"></script>
|
||||
<script src="/static/js/fact-graph.js"></script>
|
||||
<script src="/static/js/projects.js?v=20260819-1-pagination-20261027"></script>
|
||||
<script src="/static/js/vulnerability.js?v=20261010-retest-2-pagination-20261027"></script>
|
||||
<script src="/static/js/vulnerability.js?v=20261030-format-download"></script>
|
||||
<script src="/static/js/vulnerability-download.js?v=20261030-2"></script>
|
||||
<script src="/static/js/webshell.js?v=20261011-token-usage"></script>
|
||||
<script src="/static/js/chat-files.js?v=20261027-pagination-custom-select"></script>
|
||||
<script src="/static/js/tasks.js?v=20261027-pagination-custom-select"></script>
|
||||
|
||||
Reference in new issue
Block a user