☰
JavaScript渗透测试报告自动化:JSON建模到HTML/PDF导出
2026/10/27 21:33:36 网站建设 项目流程

简介:一套面向渗透测试团队与安全从业者的渗透测试报告工具源码,重点解决测试流程中范围定义、重复扫描与报告整理效率低的问题。工具支持自动初始扫描(nmap、dirb、showmount、nikto 及 EyeWitness 网页截图)、参与项目与测试范围跟踪、按严重性归类记录发现并附加截图,还能通过可自定义模板将报告导出为 .doc 格式;实验性知识模块可借助已知 SSH 凭据自动化部分横向移动,适合有一定实战基础、希望规范交付流程的安全人员参考。压缩包共 109 个文件、约 90.93MB,以 JavaScript 与 Python 源码为主体,辅以 CSS 样式、Shell 脚本、Dockerfile 与 docx 模板,前端、动作模块与数据库三层结构清晰。已有 416 人学习,可作为搭建内部渗透测试管理平台的实践样例。

1. 交付前夜还在拼截图:这套 JavaScript 渗透测试报告工具到底解决什么

交付前夜还在把截图一张张粘进文档,漏洞编号、严重度、修复建议各改一遍?这套渗透测试报告工具走的是另一条路:用 JavaScript 把项目信息、漏洞条目、证据文件和模板渲染串成一条可重复执行的链路,报告从结构化数据生成,不靠手工拼出来。它解决的核心问题是同一份数据反复导出 HTML、PDF 时不漂移,严重度统计和编号不会因为复制粘贴而错位。适合经常要出报告的安全服务从业者、需要统一交付模板的小团队,以及想把报告流程塞进 CI 的开发者。新手能按字段和脚本跑通,熟手更该盯数据模型、证据路径和导出参数,这些地方才是踩坑密集区。

2. 报告数据模型与目录骨架:先把漏洞条目变成可校验的 JSON

2.1 为什么报告工具先定数据模型,而不是先套模板

报告工具最容易翻车的地方,是一上来就找漂亮模板,把内容直接写进 HTML。模板改三次,历史报告就再也对不齐;同一个漏洞在摘要里写“高危”,在详情里写“高风险”,统计脚本立刻对不上。更稳的顺序是先把报告拆成数据:项目信息、测试范围、漏洞条目、证据文件、修复建议、时间线。数据层稳定后,模板只是视图,换封面、换字体、换导出格式都不影响事实。

我一般会把一份报告拆成project.json加多个findings/*.json。项目级字段管客户代称、测试窗口、范围、报告编号;漏洞级字段管标题、严重度、状态、受影响资产、描述、影响、证据、修复建议、复测结果。字段名一旦定下,后面所有脚本都围绕它跑,模板里不再出现“临时补一个字段”的情况。这样做还有一个好处:报告可以进版本库,diff 能看出这次交付到底改了什么,而不是只看到一坨二进制文档。

严重度不要用自由文本。常见做法是固定枚举:critical、high、medium、low、info。状态同样固定:open、fixed、accepted、retest。枚举带来的约束看起来死板,但它让统计、排序、颜色映射、导出过滤都有唯一依据。报告工具不是写作软件,它首先要保证同一份数据在不同视图里说同一件事。

2.2 初始化目录与字段约束

目录骨架要让人一眼看出“数据、证据、模板、产物”四类东西。下面这段 bash 只创建空目录和占位文件,不改系统配置,也不依赖任何外部服务。路径按报告编号分层,避免多项目并行时互相覆盖。

# 在资源包根目录执行,建立报告工具的最小骨架 mkdir -p data/findings \ evidence \ templates \ scripts \ dist/assets \ reports # 项目级数据:客户名一律用代称,避免真实信息进入版本库 cat > data/project.json <<'JSON' { "schema_version": "1", "report_id": "RPT-2024-001", "project_name": "模拟项目X", "client_alias": "某高校实验室", "test_window": { "start": "2024-05-06", "end": "2024-05-10" }, "scope": ["asset-01", "service-a", "api-gateway"], "out_of_scope": ["asset-09"], "report_status": "draft" } JSON # 单条漏洞:证据只存相对 evidence/ 的路径,导出时再转绝对路径 cat > data/findings/F-001.json <<'JSON' { "id": "F-001", "title": "示例条目:登录接口缺少频率限制", "severity": "high", "status": "open", "category": "authentication", "affected_assets": ["api-gateway"], "description": "在测试窗口内对登录接口进行低频验证,发现连续失败尝试未触发锁定或延迟。", "impact": "可能增加账号被反复尝试的风险,具体影响需结合业务风控策略评估。", "evidence": ["evidence/F-001/01-response.png"], "remediation": "增加基于账号与来源的失败计数,设置递增延迟,并记录审计日志。", "references": ["内部安全基线-认证章节"], "owner": "A同学", "due_date": "2024-05-20" } JSON

代码块里的schema_version是数据格式版本,不是软件版本;report_id用来关联导出产物;client_alias强制使用代称,减少真实名称进入仓库;scope和out_of_scope分开存,报告摘要才能准确表达边界。漏洞条目里的evidence只写相对路径,后面导出时统一复制到dist/assets,这样 HTML 和 PDF 在任何机器上打开都不会因为绝对路径不同而丢图。

severity和status必须落在枚举里,id必须唯一。owner和due_date可以留空,但字段存在,模板里就不会出现“有时有、有时没有”的分支。报告工具最怕字段半存在,模板作者只能写一堆兼容判断,最后没人敢改。

提示:证据文件名不要带空格、中文括号和井号。导出链路里一旦出现需要转义的字符,HTML、PDF、压缩包三处表现可能不一致。

2.3 用一段校验脚本拦住脏数据

模板报错通常很晚,而且错误信息指向 HTML 行号,定位成本高。更划算的做法是在生成报告前跑一遍数据校验。下面这段 JavaScript 只使用 Node.js 内置模块,读取data/project.json和data/findings/*.json,检查必填字段、枚举、ID 唯一性、证据文件是否存在。退出码非 0 就直接中断导出。

// scripts/validate-data.js // 用法:node scripts/validate-data.js // 退出码 0 表示通过,1 表示存在错误,方便接入 CI 或 pre-commit const fs = require('fs'); const path = require('path'); const ROOT = process.cwd(); const DATA_DIR = path.join(ROOT, 'data'); const FINDING_DIR = path.join(DATA_DIR, 'findings'); const SEVERITIES = new Set(['critical', 'high', 'medium', 'low', 'info']); const STATUSES = new Set(['open', 'fixed', 'accepted', 'retest']); const REQUIRED_FINDING_FIELDS = [ 'id', 'title', 'severity', 'status', 'affected_assets', 'description', 'impact', 'remediation' ]; const errors = []; function readJson(file) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (err) { errors.push(`${path.relative(ROOT, file)} 不是合法 JSON:${err.message}`); return null; } } const project = readJson(path.join(DATA_DIR, 'project.json')); if (!project) process.exit(1); for (const field of ['report_id', 'project_name', 'client_alias', 'scope']) { if (!project[field]) errors.push(`project.json 缺少字段:${field}`); } const files = fs.readdirSync(FINDING_DIR) .filter(name => name.endsWith('.json')) .sort(); const seenIds = new Set(); for (const file of files) { const fullPath = path.join(FINDING_DIR, file); const finding = readJson(fullPath); if (!finding) continue; for (const field of REQUIRED_FINDING_FIELDS) { if (finding[field] === undefined || finding[field] === '') { errors.push(`${file} 缺少字段:${field}`); } } if (seenIds.has(finding.id)) { errors.push(`${file} 的 id 重复:${finding.id}`); } seenIds.add(finding.id); if (!SEVERITIES.has(finding.severity)) { errors.push(`${file} 的 severity 不在枚举内:${finding.severity}`); } if (!STATUSES.has(finding.status)) { errors.push(`${file} 的 status 不在枚举内:${finding.status}`); } for (const rel of finding.evidence || []) { const abs = path.join(ROOT, rel); if (!fs.existsSync(abs)) { errors.push(`${file} 引用的证据不存在:${rel}`); } } } if (errors.length) { console.error('数据校验失败:'); for (const item of errors) console.error(`- ${item}`); process.exit(1); } console.log(`数据校验通过:${files.length} 条漏洞,${project.report_id}`);

这段脚本的逻辑很简单:先读项目级数据,再按文件名排序读漏洞数据;所有错误收集完再统一输出,避免只报第一条。REQUIRED_FINDING_FIELDS是硬约束,SEVERITIES和STATUSES是枚举约束,evidence做存在性检查。参数方面,脚本默认以当前工作目录为根,所以要在资源包根目录执行;如果放进 CI,把node scripts/validate-data.js放在构建命令最前面即可。

校验脚本不要做得太“聪明”。它只回答“数据能不能进入模板”,不负责评分、不负责排序、不负责改字段。把归一化留给导出脚本,校验层保持确定性,排错时才知道是谁改了数据。血泪经验是:只要校验层开始偷偷修数据,后面统计对不上时就没有后悔药。

3. 模板渲染与 HTML 生成:把 JSON 变成能评审的报告页

3.1 模板分层:封面、摘要、漏洞详情、附录

报告 HTML 不适合写成一个巨大字符串。更稳的分层是:封面区、摘要区、漏洞详情区、附录区。封面放报告编号、项目代称、测试窗口;摘要放范围、严重度统计、关键结论;详情按严重度排序,每条固定结构;附录放证据清单、复测记录、术语说明。分层之后,模板函数只接收对应数据,不会在渲染详情时还去读项目配置。

我一般会让渲染脚本先做一次“视图数据”整理:把漏洞按严重度排序,给每条生成稳定编号,统计各严重度数量,过滤掉status为accepted的条目是否进入主表由配置决定。这样模板只负责排版,不负责业务判断。报告工具换模板时,视图数据不变,风险小很多。

严重度排序不要依赖字符串排序。critical、high、medium、low、info的字母序和风险序不一致。用权重表映射成数字再排,编号也按排序后的顺序生成,比如F-001、F-002。编号稳定,评审时引用才不容易错。如果后续插入新条目,编号会变化,所以交付前要冻结一次,冻结后只改内容不重排。

3.2 自建轻量渲染函数,避免模板语法绑架

下面这段 JavaScript 用一个很小的渲染函数生成 HTML。它没有引入模板引擎,核心是escapeHtml加数组拼接。报告内容里可能出现<、>、&,不转义就会破坏页面结构,甚至把报告变成可执行脚本的载体。转义函数放在最前面,所有文本字段都必须经过它。

// scripts/build-html.js // 用法:node scripts/build-html.js // 产物:dist/report.html const fs = require('fs'); const path = require('path'); const ROOT = process.cwd(); const DIST = path.join(ROOT, 'dist'); function escapeHtml(value) { return String(value ?? '') .replaceAll('&', '&amp;') .replaceAll('<', '&lt;') .replaceAll('>', '&gt;') .replaceAll('"', '&quot;') .replaceAll("'", '&#39;'); } function severityWeight(severity) { const map = { critical: 0, high: 1, medium: 2, low: 3, info: 4 }; return map[severity] ?? 99; } function readJson(file) { return JSON.parse(fs.readFileSync(file, 'utf8')); } const project = readJson(path.join(ROOT, 'data/project.json')); const findingDir = path.join(ROOT, 'data/findings'); const findings = fs.readdirSync(findingDir) .filter(name => name.endsWith('.json')) .map(name => readJson(path.join(findingDir, name))) .sort((a, b) => severityWeight(a.severity) - severityWeight(b.severity) || a.id.localeCompare(b.id)); findings.forEach((item, index) => { item.displayNo = `F-${String(index + 1).padStart(3, '0')}`; }); const counts = findings.reduce((acc, item) => { acc[item.severity] = (acc[item.severity] || 0) + 1; return acc; }, {}); function renderFinding(item) { const assets = item.affected_assets.map(escapeHtml).join('、'); const evidence = (item.evidence || []).map(rel => { const fileName = path.basename(rel); return `<figure class="evidence"> <img src="assets/${escapeHtml(rel.replace(/^evidence\//, ''))}" alt="${escapeHtml(fileName)}"> <figcaption>${escapeHtml(fileName)}</figcaption> </figure>`; }).join(''); return `<section class="finding severity-${escapeHtml(item.severity)}"> <h3>${escapeHtml(item.displayNo)} ${escapeHtml(item.title)}</h3> <table class="meta"> <tr><th>严重度</th><td>${escapeHtml(item.severity)}</td> <th>状态</th><td>${escapeHtml(item.status)}</td></tr> <tr><th>受影响资产</th><td colspan="3">${assets}</td></tr> </table> <h4>描述</h4><p>${escapeHtml(item.description)}</p> <h4>影响</h4><p>${escapeHtml(item.impact)}</p> <h4>证据</h4>${evidence || '<p class="muted">无截图证据,见附录说明。</p>'} <h4>修复建议</h4><p>${escapeHtml(item.remediation)}</p> </section>`; } const summaryRows = ['critical', 'high', 'medium', 'low', 'info'] .map(level => `<tr><td>${level}</td><td>${counts[level] || 0}</td></tr>`) .join(''); const html = `<!doctype html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>${escapeHtml(project.report_id)} 报告</title> <style> body { font-family: system-ui, sans-serif; line-height: 1.7; margin: 32px; color: #222; } .cover { border-bottom: 2px solid #333; padding-bottom: 16px; } .finding { page-break-inside: avoid; margin-top: 32px; } .finding h3 { border-left: 6px solid #555; padding-left: 10px; } table { border-collapse: collapse; width: 100%; } th, td { border: 1px solid #ccc; padding: 6px 8px; text-align: left; } .evidence img { max-width: 100%; border: 1px solid #ddd; } .muted { color: #777; } </style> </head> <body> <header class="cover"> <h1>${escapeHtml(project.project_name)} 安全测试报告</h1> <p>报告编号:${escapeHtml(project.report_id)}</p> <p>客户代称:${escapeHtml(project.client_alias)}</p> <p>测试窗口:${escapeHtml(project.test_window.start)} 至 ${escapeHtml(project.test_window.end)}</p> </header> <section> <h2>严重度统计</h2> <table><tr><th>等级</th><th>数量</th></tr>${summaryRows}</table> </section> <section> <h2>漏洞详情</h2> ${findings.map(renderFinding).join('\n')} </section> </body> </html>`; fs.mkdirSync(DIST, { recursive: true }); fs.writeFileSync(path.join(DIST, 'report.html'), html, 'utf8'); console.log(`已生成 dist/report.html,漏洞 ${findings.length} 条`);

代码先读项目数据和漏洞数据,按严重度权重排序,再生成稳定展示编号。escapeHtml覆盖五个字符,severityWeight把风险序固定下来,counts只统计当前进入报告的条目。CSS 里page-break-inside: avoid是为了后面 PDF 导出时尽量不把一条漏洞切成两页。参数上,DIST固定为dist,证据图片按assets/相对路径引用;导出脚本后面会负责把evidence/复制到dist/assets/。

这段渲染函数故意保持“笨”:不执行用户内容,不拼接未转义的 HTML,不在模板里做网络请求。报告内容可能来自外部输入,模板层必须假设所有字段都不可信。熟手可以换成模板引擎,但转义策略、排序权重、编号规则这三件事不能交给模板随手处理。

3.3 生成目录、编号与严重度统计

报告页一长,评审人找不到重点。摘要区除了统计表,还应该有一段“关键结论”,内容由数据生成,而不是手写。比如按严重度取前三条,拼接标题和受影响资产;如果critical为零,就明确写“未发现 critical 条目”。这类结论句能减少误读,也能让报告在无人讲解时自解释。

目录可以按漏洞编号生成锚点。每条漏洞的<section>加id="F-001",摘要区列出编号和标题,点击跳到详情。编号不要用文件名的原始 ID,而用排序后的displayNo,这样摘要和详情的顺序一致。原始id仍留在数据里,用于追踪和复测对照。两套编号并行容易混,所以模板里只展示displayNo,原始id放到数据附录。

严重度统计要说明口径:统计的是“当前报告内条目”,还是“去重后的资产数”。这两个数经常被混用。报告工具里最好把口径写进表头,比如“按条目统计”。如果某个漏洞影响三个资产,条目数仍是一条,资产数另算。口径写清楚,评审时才不会追问“为什么高危只有两条但资产有三台”。

生成 HTML 后不要急着导 PDF。先在浏览器里检查三件事:中文是否正常、证据图片是否加载、长表格是否溢出。HTML 是 PDF 的上游,上游有问题,下游只会更难看。我一般会把dist/report.html当成中间产物,允许它带一点调试样式;PDF 才是交付物,样式在打印媒体里再收紧。

4. 证据链与 PDF 导出:截图、附件、哈希和分页怎么落地

4.1 证据目录规范与文件命名

证据管理是报告工具里最容易被低估的部分。截图散落在桌面,导出时靠手工拖拽,最后 PDF 里图片顺序和漏洞条目对不上。更稳的做法是每条漏洞一个证据目录:evidence/F-001/01-response.png、evidence/F-001/02-policy.png。编号前缀决定展示顺序,目录名对应漏洞原始 ID。导出脚本只认这个结构,不认“最新截图”“最终版”这种文件名。

文件命名还要避免三类字符:空格、中文标点、需要 URL 转义的符号。空格在 HTML 里会被编码成%20,在 PDF 渲染器里可能变成断行点;中文括号在不同文件系统上编码不一致;井号会被当成锚点。统一用小写英文、数字、短横线,扩展名固定。截图格式优先 PNG,少用带透明通道的格式,打印时背景容易发灰。

证据目录里可以放一个manifest.json,记录文件名、SHA-256、来源说明、脱敏状态。这个清单不直接进报告正文,但导出前要校验。报告工具的价值不只是排版,它还要能回答“这张图对应哪条漏洞、有没有被替换过”。哈希清单就是证据链的锚点。

4.2 导出前的脱敏与完整性校验

脱敏要在导出前做,不要等 PDF 生成后再涂。截图里可能带主机名、账号、内部路径、令牌片段;文本字段里可能带真实域名或内网地址。报告工具应该有一份脱敏规则清单,至少覆盖:真实主机名替换为asset-01这类代称,账号替换为user-a,令牌只保留前后各四位,内部路径替换为[internal-path]。规则不追求全自动,但要有固定扫描项。

下面这段 JavaScript 做两件事:计算证据文件哈希,扫描报告数据里的高风险字符串模式。它只输出告警,不自动修改内容,避免误伤。扫描规则用占位模式,实际使用时按团队基线补充。

// scripts/check-evidence.js // 用法:node scripts/check-evidence.js // 输出:evidence/manifest.json,并在发现疑似敏感串时以非 0 退出 const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const ROOT = process.cwd(); const EVIDENCE_DIR = path.join(ROOT, 'evidence'); const DATA_DIR = path.join(ROOT, 'data'); function walk(dir) { return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { const full = path.join(dir, entry.name); return entry.isDirectory() ? walk(full) : [full]; }); } const files = walk(EVIDENCE_DIR).filter(file => !file.endsWith('manifest.json')); const manifest = files.map(file => { const buf = fs.readFileSync(file); return { path: path.relative(ROOT, file).replaceAll(path.sep, '/'), sha256: crypto.createHash('sha256').update(buf).digest('hex'), bytes: buf.length }; }); fs.writeFileSync( path.join(EVIDENCE_DIR, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8' ); const suspicious = [ /(?:\d{1,3}\.){3}\d{1,3}/, // 疑似 IPv4,需确认是否为真实地址 /(?:token|secret|password)\s*[:=]\s*\S+/i, /[A-Za-z]:\\Users\\[^\\]+/i ]; const textFiles = walk(DATA_DIR).filter(file => file.endsWith('.json')); const hits = []; for (const file of textFiles) { const text = fs.readFileSync(file, 'utf8'); for (const pattern of suspicious) { if (pattern.test(text)) { hits.push(`${path.relative(ROOT, file)} 命中模式:${pattern}`); } } } if (hits.length) { console.error('疑似敏感内容,请人工确认后再导出:'); hits.forEach(item => console.error(`- ${item}`)); process.exit(1); } console.log(`证据清单已生成:${manifest.length} 个文件;数据扫描通过`);

哈希用crypto.createHash('sha256')逐文件计算,清单路径统一成/分隔,方便跨平台比对。扫描规则只做提示,不替代人工复核;IPv4 模式可能命中虚构示例,令牌模式可能命中字段说明,所以退出码非 0 时先看告警上下文。参数上,EVIDENCE_DIR和DATA_DIR都相对当前工作目录,执行位置仍在资源包根目录。证据清单不放进报告正文,但和交付包一起归档,后续复测可以比对文件是否被替换。

脱敏不是一次性动作。报告改版、补充截图、复测更新都可能重新引入敏感信息。所以扫描脚本要放在导出命令前面,和校验脚本一样成为固定步骤。没有这道闸,PDF 发出去之后才发现截图角落有账号,处理成本会高很多。

4.3 调用本机无头浏览器导出 PDF 的参数与回退

HTML 生成后,PDF 导出常见做法是调用本机已安装的无头浏览器命令行,把本地 HTML 打印成 PDF。下面代码用环境变量BROWSER_BIN指定命令,不写死具体路径;参数包括无头模式、禁用 GPU、打印背景、纸张尺寸和输出路径。不同环境的命令行参数可能有差异,先把BROWSER_BIN指到团队统一的可执行文件,再按实际帮助信息微调。

// scripts/export-pdf.js // 用法:BROWSER_BIN=/path/to/headless-browser node scripts/export-pdf.js const { execFileSync } = require('child_process'); const fs = require('fs'); const path = require('path'); const ROOT = process.cwd(); const HTML = path.join(ROOT, 'dist', 'report.html'); const PDF = path.join(ROOT, 'reports', 'report.pdf'); const BROWSER_BIN = process.env.BROWSER_BIN; if (!BROWSER_BIN) { console.error('缺少 BROWSER_BIN,请指定本机无头浏览器命令'); process.exit(1); } if (!fs.existsSync(HTML)) { console.error('dist/report.html 不存在,先执行 node scripts/build-html.js'); process.exit(1); } // 把证据复制到 dist/assets,保证 HTML 中 assets/ 相对路径可解析 const evidenceDir = path.join(ROOT, 'evidence'); const assetDir = path.join(ROOT, 'dist', 'assets'); fs.mkdirSync(assetDir, { recursive: true }); for (const entry of fs.readdirSync(evidenceDir, { withFileTypes: true })) { if (entry.isDirectory()) { fs.cpSync( path.join(evidenceDir, entry.name), path.join(assetDir, entry.name), { recursive: true } ); } } fs.mkdirSync(path.dirname(PDF), { recursive: true }); try { execFileSync(BROWSER_BIN, [ '--headless', '--disable-gpu', '--print-to-pdf=' + PDF, '--no-pdf-header-footer', '--print-to-pdf-no-header', 'file://' + HTML ], { stdio: 'inherit', timeout: 120000 }); console.log(`PDF 已生成:${path.relative(ROOT, PDF)}`); } catch (err) { console.error('PDF 导出失败,保留 dist/report.html 供人工排查'); console.error(err.message); process.exit(1); }

代码先检查环境变量和 HTML 是否存在,再把evidence/下的漏洞目录复制到dist/assets/,这样 HTML 里的assets/F-001/01-response.png才能加载。--print-to-pdf指定输出文件,--no-pdf-header-footer去掉默认页眉页脚,timeout防止渲染卡死。不同无头浏览器对页眉页脚参数的支持不一致,如果输出了多余页眉,先查该命令的帮助信息,别在代码里猜参数。

回退策略要明确:PDF 失败时保留 HTML,不要删中间产物。HTML 能直接在浏览器打开,评审和排错都靠它。报告工具链里最怕“导出失败后清理一切”,结果连失败现场都没了。导出命令也不要覆盖历史 PDF,按报告编号和日期分目录,避免复测时把上一版覆盖掉。

注意:PDF 里出现空白页,优先检查 HTML 是否有空<section>、证据图片是否加载失败、打印样式是否给容器加了固定高度。先看 HTML,再看 PDF。

5. 避坑与排查:报告生成链路上最容易翻车的五件事

5.1 现象:HTML 和 PDF 中文变方块或乱码

现象很直接:浏览器里中文正常,导出 PDF 后标题变方块,或者部分标点变成问号。原因通常不在数据,而在编码声明和字体链。HTML 缺<meta charset="utf-8">,或者 Node.js 写文件时用了非 UTF-8 编码,都会让渲染器按错误编码解释。PDF 导出阶段如果没有可用中文字体,也会出现方块。

解决顺序是:先确认fs.writeFileSync(..., 'utf8')明确写了编码;再确认 HTML 头部有<meta charset="utf-8">;最后检查导出环境是否有中文字体。CSS 里不要只写一个具体字体名,写字体族回退,例如font-family: system-ui, "Noto Sans CJK SC", sans-serif;。如果导出机器没有中文字体,HTML 正常但 PDF 仍会翻车,这时要在导出环境补字体,而不是改报告内容。我一般会在导出前用一条命令检查 HTML 前 200 字节里有没有 charset 声明,省得反复试。

5.2 现象:证据图片在 HTML 里能看,PDF 里全是破图

现象是 HTML 打开时图片正常,导出 PDF 后图片位置出现空白或破图图标。原因通常是相对路径基准不一致:HTML 在dist/report.html,图片却还在evidence/,浏览器按dist/assets/找当然找不到。另一个常见原因是导出时工作目录变化,脚本用process.cwd()拼路径,CI 里执行位置不同就失效。

解决方式是把证据复制到dist/assets/,HTML 只引用assets/...相对路径;导出脚本里用path.join(ROOT, ...)固定根目录,不依赖调用者所在目录。复制时保留漏洞子目录,避免不同条目的01-response.png互相覆盖。文件名里的空格和特殊字符也要在复制前改掉,不要等到 PDF 阶段再处理。图片尺寸过大同样会导致导出超时,截图前先压缩到报告可读的宽度,通常 1200 到 1600 像素足够。

5.3 现象:PDF 分页把一条漏洞切成两半,表格跨页没表头

现象是漏洞标题在上一页底部,描述跑到下一页;或者严重度统计表跨页后第二页没有表头,评审人看不懂列含义。原因是打印样式没有处理分页,长表格和图片容器默认允许被切开。HTML 预览是连续滚动,看不出这个问题,只有 PDF 才暴露。

解决方式是在打印样式里给漏洞 section 加page-break-inside: avoid,给标题加page-break-after: avoid,给表格加thead { display: table-header-group; },让表头跨页重复。长 URL 和长单词加word-break: break-all,避免撑破页面。图片设置max-width: 100%和height: auto,防止超大图把分页推乱。分页规则不要写得太碎,一条漏洞尽量保持完整;如果一条漏洞超过一页,允许切开,但标题要和正文同页。

5.4 现象:严重度统计和详情数量对不上

现象是摘要写高危 3 条,详情里数出来 4 条;或者统计表里没有info,详情里却有。原因通常是大小写不一致、别名混用、过滤条件不一致。数据里出现High、high、高风险三种写法,统计脚本只认一种,就会漏算。另一个原因是统计在排序前做,详情在过滤后展示,两边口径不同。

解决方式是把枚举校验放在最前面,渲染前再做一次归一化,但归一化只做映射,不改原数据。统计和详情共用同一个findings数组,不要一个读原始文件、一个读过滤结果。状态为accepted的条目是否计入统计,要在配置里写清楚,并在表头注明口径。复测后状态变化,统计要重新生成,不能手改摘要数字。报告工具里所有数字都应该来自同一次计算。

5.5 现象:PDF 发出去后发现截图角落有账号或内部地址

现象是交付后对方指出截图里有账号、内部主机名或令牌片段。原因通常是脱敏只做了正文,没做截图;或者扫描规则只覆盖 JSON,没覆盖图片 OCR 和 HTML 产物。报告工具很容易把“数据脱敏”当成全部,忽略证据文件本身。

解决方式是把脱敏拆成三道:数据字段扫描、截图人工复核、导出产物抽查。数据字段扫描用前面的脚本,截图在放入evidence/前就完成打码或裁剪,不要等到导出后。导出后抽查 PDF 的前几页和证据页,重点看浏览器地址栏、终端提示符、账号信息区域。团队里可以固定一张脱敏检查表,逐项打勾。没有这道流程,工具再顺也可能在最后一公里翻车。

6. 进阶验证:用快照比对和清单把报告质量卡在交付前

报告工具跑通一次不难,难的是每次交付都稳定。我一般会在导出后加一道快照比对:对dist/report.html、evidence/manifest.json和最终 PDF 计算哈希,写入reports/snapshot.json。下次生成时先比对,如果 HTML 哈希变了但数据没变,说明模板或依赖被改动;如果证据清单变了,说明截图被替换。下面这段脚本只做比对和提示,不自动覆盖历史快照。

// scripts/snapshot.js // 用法:node scripts/snapshot.js check // 首次使用:node scripts/snapshot.js write const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const ROOT = process.cwd(); const TARGETS = ['dist/report.html', 'evidence/manifest.json', 'reports/report.pdf']; const SNAPSHOT = path.join(ROOT, 'reports', 'snapshot.json'); const mode = process.argv[2] || 'check'; function hashFile(rel) { const abs = path.join(ROOT, rel); if (!fs.existsSync(abs)) return null; return crypto.createHash('sha256').update(fs.readFileSync(abs)).digest('hex'); } const current = Object.fromEntries(TARGETS.map(rel => [rel, hashFile(rel)])); if (mode === 'write') { fs.mkdirSync(path.dirname(SNAPSHOT), { recursive: true }); fs.writeFileSync(SNAPSHOT, JSON.stringify(current, null, 2), 'utf8'); console.log('快照已写入 reports/snapshot.json'); process.exit(0); } if (!fs.existsSync(SNAPSHOT)) { console.error('快照不存在,先执行 node scripts/snapshot.js write'); process.exit(1); } const previous = JSON.parse(fs.readFileSync(SNAPSHOT, 'utf8')); const changed = TARGETS.filter(rel => previous[rel] !== current[rel]); if (changed.length) { console.error('以下产物与快照不一致:'); changed.forEach(rel => console.error(`- ${rel}`)); process.exit(1); } console.log('快照比对通过');

write模式在确认交付版本后执行,check模式放在导出命令之后。参数只有模式和固定目标列表,目标路径按项目实际产物调整。哈希不一致不一定代表错误,但必须有人解释差异:是数据更新、模板改版,还是证据替换。解释不了就回退,不要带着未知差异交付。

交付前检查清单可以固定成表格,逐项过一遍:

检查项动作通过标准
数据校验node scripts/validate-data.js退出码 0,无重复 ID
证据完整性node scripts/check-evidence.js清单生成,无敏感告警
HTML 生成node scripts/build-html.js中文正常,图片可加载
PDF 导出BROWSER_BIN=... node scripts/export-pdf.js无空白页,分页合理
快照比对node scripts/snapshot.js check差异有明确解释

从那以后我每次导出前都强制走一遍校验、证据扫描和快照比对,哪怕只改了一个修复建议。报告工具的可靠性不靠一次写对,靠每次交付都留下可核对的痕迹。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询