简介一套面向渗透测试团队与安全从业者的渗透测试报告工具源码重点解决测试流程中范围定义、重复扫描与报告整理效率低的问题。工具支持自动初始扫描nmap、dirb、showmount、nikto 及 EyeWitness 网页截图、参与项目与测试范围跟踪、按严重性归类记录发现并附加截图还能通过可自定义模板将报告导出为 .doc 格式实验性知识模块可借助已知 SSH 凭据自动化部分横向移动适合有一定实战基础、希望规范交付流程的安全人员参考。压缩包共 109 个文件、约 90.93MB以 JavaScript 与 Python 源码为主体辅以 CSS 样式、Shell 脚本、Dockerfile 与 docx 模板前端、动作模块与数据库三层结构清晰。已有 416 人学习可作为搭建内部渗透测试管理平台的实践样例。1. 交付前夜还在拼截图这套 JavaScript 渗透测试报告工具到底解决什么交付前夜还在把截图一张张粘进文档漏洞编号、严重度、修复建议各改一遍这套渗透测试报告工具走的是另一条路用 JavaScript 把项目信息、漏洞条目、证据文件和模板渲染串成一条可重复执行的链路报告从结构化数据生成不靠手工拼出来。它解决的核心问题是同一份数据反复导出 HTML、PDF 时不漂移严重度统计和编号不会因为复制粘贴而错位。适合经常要出报告的安全服务从业者、需要统一交付模板的小团队以及想把报告流程塞进 CI 的开发者。新手能按字段和脚本跑通熟手更该盯数据模型、证据路径和导出参数这些地方才是踩坑密集区。2. 报告数据模型与目录骨架先把漏洞条目变成可校验的 JSON2.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 classevidence img srcassets/${escapeHtml(rel.replace(/^evidence\//, ))} alt${escapeHtml(fileName)} figcaption${escapeHtml(fileName)}/figcaption /figure; }).join(); return section classfinding severity-${escapeHtml(item.severity)} h3${escapeHtml(item.displayNo)} ${escapeHtml(item.title)}/h3 table classmeta trth严重度/thtd${escapeHtml(item.severity)}/td th状态/thtd${escapeHtml(item.status)}/td/tr trth受影响资产/thtd colspan3${assets}/td/tr /table h4描述/h4p${escapeHtml(item.description)}/p h4影响/h4p${escapeHtml(item.impact)}/p h4证据/h4${evidence || p classmuted无截图证据见附录说明。/p} h4修复建议/h4p${escapeHtml(item.remediation)}/p /section; } const summaryRows [critical, high, medium, low, info] .map(level trtd${level}/tdtd${counts[level] || 0}/td/tr) .join(); const html !doctype html html langzh-CN head meta charsetutf-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 classcover 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 tabletrth等级/thth数量/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加idF-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 charsetutf-8或者 Node.js 写文件时用了非 UTF-8 编码都会让渲染器按错误编码解释。PDF 导出阶段如果没有可用中文字体也会出现方块。解决顺序是先确认fs.writeFileSync(..., utf8)明确写了编码再确认 HTML 头部有meta charsetutf-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差异有明确解释从那以后我每次导出前都强制走一遍校验、证据扫描和快照比对哪怕只改了一个修复建议。报告工具的可靠性不靠一次写对靠每次交付都留下可核对的痕迹。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?