Day 164 — 阶段项目(三):安全审计工具箱 · 代理中间人 + 流量分析
Phase 10 — 网络安全开发(Day 146–165) · 主题:安全审计工具箱(Day 162–165 四天项目的第 3 天)
使用边界(重要,读完再动手):
- 只对你自己拥有、或已获书面授权的目标做审计。
- 本课所有实验都跑在127.0.0.1上的自建靶场(code/mitm_core.py里的
LabOrigin实验网站 +LabOpaqueTCP假 TLS 服务)。
- 中间人代理只监听回环地址。代码里对0.0.0.0是硬拒绝:
一个"监听全网卡的开放 HTTP 代理"会在几分钟内被扫到并被滥用,
那是真实的法律责任,不是一个"配置选项"。
- 不伪造证书、不降级 TLS、不破解隧道内容。CONNECT隧道一律如实记为
"不可见",并计入覆盖缺口。
- 不改包、不重放、不注入响应。只做观测与记录。
- 流量档案默认脱敏:口令 / 令牌 / Cookie 的值不落盘,
只留"字段名 + 值指纹(sha256 前 8 位)+ 长度"。报告能证明"明文传输了",
不能被当成二次泄密的渠道。
- 教材中的地址一律为回环或文档网段(RFC 5737 的192.0.2.0/24)。
1. 学习目标
完成本课后,你应该能够:
- 说清阶段项目四天的分工,以及为什么"流量面"补上了前两天结构性缺失的那一块
- 解释正向代理与反向代理的区别,以及为什么审计要用正向代理
- 说清"绝对 URI 形式"与"源站形式"两种请求行的区别,以及为什么代理两种都要支持
- 解释
CONNECT之后的字节流为什么看不见,以及"看不见"和"没问题"的区别 - 说清逐跳头(hop-by-hop)为什么必须在代理里剔除,不剔除会发生什么
- 解释代理主动去掉
Accept-Encoding的收益与代价,以及生产环境为什么该换做法 - 说清流模型(flow)要记哪些字段,以及"记什么"背后的取舍
- 解释本课最重要的设计:先脱敏、再检测——检测逻辑为什么看不到明文也能工作
- 说清 PII 擦除 与 PII 检测 为什么必须分开,以及"擦掉就检测不到"这个坑怎么补
- 用 14 条规则把 flow 变成可读的 Finding(含证据 / 原因 / 修复建议)
- 说清覆盖率为什么必须独立统计,以及它如何把退出码推到
4 - 说清"优先级 = 严重度 × 置信度"为什么比"只看严重度"更诚实
- 明确本工具不做什么(解密、改包、重放、利用),以及这些边界为什么是必要的
2. 概念解释
2.1 阶段项目要做什么(今天站在哪个位置)
Day 159 做了"审计工具箱 v0"(规则引擎 + 报告 + CLI 门禁),Day 160 做了日志分析。
Day 162–165 把这几天分散的能力拼成一个完整的资产审计工具,四天分工:
Day 162(前天) 资产面:端口扫描 + 目录爆破 + 指纹识别 ← 有哪些"资产"
Day 163(昨天) 漏洞面:SQLi 检测 + XSS 检测 ← 资产上"哪里可能有问题"
Day 164(今天) 流量面:代理中间人 + 流量分析 ← 实际"发生了什么" ★本课
Day 165(明天) 交付面:报告生成 + Docker 部署 ← 最后"怎么交出去"
为什么"流量面"不是"再补一层锦上添花",而是补一块结构性缺失?
因为前两天的结论全部是推断,它们各有各的盲区:
| 阶段 | 它回答的问题 | 它回答不了的问题 |
|---|---|---|
| Day 162 资产面 | 哪个端口开着、哪个路径返回 200 | 参数是怎么传的?走 GET 还是 POST? |
| Day 163 漏洞面 | id 这个参数疑似可注入 |
真实业务流量里,凭据是明文还是密文? |
| Day 164 流量面 | 真实请求与响应的每一个字节 | (盲区:TLS 隧道内部) |
一句话概括三者的区别:
资产面:门锁有没有装 → 快照
漏洞面:这把锁看起来像能撬 → 推断(有假阳性)
流量面:刚才有人拿钥匙开了门 → 事实(可复核、可留证)
为什么"事实"这件事在审计里值钱? 因为漏洞扫描器的输出不可复核:
"疑似布尔差分注入"这句话,开发会说"那是你探针的假阳性"。
而"登录口令 password(指纹 a1b2c3d4,11 字节)以明文出现在请求体里"
这句话不需要争论——它就是发生过的事实,而且开发一眼就知道要改哪一行。
今天开工前,你手上应该已经有两份东西(由 Day 162/163 产出):
- URL + 参数清单 → 代理要用它来决定"该代理哪些流量";
- 一场真实的业务会话(登录、查询、下单…)→ 代理要用它来做基线。
2.2 中间人代理:什么叫"中间人",什么叫"代理"
先用一句话把三个名词钉住:
| 名词 | 一句话定义 | 谁在监听 | 典型用途 |
|---|---|---|---|
| 正向代理 | 代表客户端去访问服务端 | 客户端侧 | 上网代理、审计、抓包 |
| 反向代理 | 代表服务端来接收客户端 | 服务端侧 | Nginx 负载均衡、WAF |
| 中间人(MITM) | 站在两端之间,两段连接都是它 | 中间任意位置 | 审计、调试、劫持(看你有没有授权) |
为什么审计要用正向代理,而不是反向代理?
因为审计的立场是"我代表客户端,我要看客户端发了什么"。
反向代理给你的是服务端视角——它看到的是"所有客户端",但你装不到它上面,
除非你本来就拥有那台服务器(那就不叫审计了,叫自测)。
正向代理的好处是:你在自己的机器上就能起一个,然后把客户端的流量指过去。
"中间人"这个名字为什么让人紧张? 因为它字面就是攻击手法。
同样的代码,装在你自己的机器上叫"内网审计",装在别人的机器上叫"中间人攻击"。
区别不在技术,在授权。这就是本课把"范围门禁"放在第一个提交、
把"只监听回环"做成硬断言的原因:把伦理约束写进代码,而不是写进注释。
两段连接,两种角色
┌──────────┐ ①客户端→代理 ┌────────────┐ ②代理→服务端 ┌──────────┐
│ 客户端 │ GET http://… │ 正向代理 │ GET /path │ 服务端 │
│ (审计会话)│ ────────────────▶ │ (本课实现) │ ───────────────▶ │ (靶场) │
│ │ ◀──────────────── │ │ ◀─────────────── │ │
└──────────┘ ④响应回客户端 └────────────┘ ③响应回代理 └──────────┘
│
▼
flows.jsonl(流量档案)
注意这里的"两次解析":代理必须把 ① 的报文完整收下来(包括请求体),
再用 ② 的形式重新发出。它不是一根管子,而是一个会思考的中转站。
"管子"(隧道)是 CONNECT 才做的事——那才是它唯一"不思考"的时刻(见 2.5)。
2.3 为什么"流量面"是观测,而前两面是推断
用一个具体的例子来看三层的差异。假设目标有个 /login:
Day 162 看:/login 返回 200 → "这个路径存在"
Day 163 看:/login?id=1 与 /login?id=1' 行为不同 → "id 可能可注入"(假阳性风险高)
Day 164 看:POST /login 明文body里 password=xxx → "口令以明文传输"(事实)
看:响应 Set-Cookie 缺 HttpOnly、Secure → "会话令牌可被 JS 读取"(事实)
为什么流量面能拿到"事实"? 因为它不需要制造任何输入。
它只是把已经发生的业务对话录下来。
这一点带来两个重要性质:
- 零打扰:不对业务系统注入任何探针,不增加负载(只增加一次转发)。
所以它可以用在生产环境——这是漏洞扫描器做不到的。 - 可复核:报告里的每一条都能点回到具体某一条 flow 的原始字段
(值是脱敏的,但结构、长度、指纹都在)。
代价也很明确:
- 被动:用户没点过的路径,流量面永远看不到(所以要靠 Day 162 的目录爆破补面);
- 盲区:TLS 隧道内部的内容它看不见(见 2.5);
- 依赖场景:需要有人(或脚本)真的去跑一遍业务,否则档案里啥也没有。
所以三层的正确关系是"互相补盲",不是"谁替代谁":
资产面 给流量面:该盯哪些 URL
漏洞面 给流量面:哪条推测值得在流量里找证据
流量面 给漏洞面:这条推测到底是不是真的发生了
2.4 代理协议的细节:两种请求行形式
这是手写代理第一个会踩的坑,必须讲清楚。
HTTP 请求行的格式是:
METHOD request-target HTTP-VERSION
而 request-target 在代理场景下有两种形式:
① 绝对 URI 形式(绝对形式)—— 正向代理的"标准姿势"
GET http://127.0.0.1:18081/api/user?id=1 HTTP/1.1
Host: 127.0.0.1:18081
② 源站形式(origin-form)—— 直接对源站说话时用
GET /api/user?id=1 HTTP/1.1
Host: 127.0.0.1:18081
为什么会有两种? 因为"收到报文的人"不同:
| 收报文的人 | 它知道"目标是谁"吗 | 所以 request-target 是 |
|---|---|---|
| 源站服务器 | 知道(就是它自己) | 源站形式(只需要路径) |
| 正向代理 | 不知道 | 绝对 URI 形式(必须自带主机名) |
为什么代理两种都得支持? 因为:
- 标准客户端(浏览器、
requests、urllib)配了代理后发的是 ①; - 但有些库/工具在"把代理当网关"或用某些 HTTP/1.0 客户端时,会发 ②;
- 更麻烦的是:有些客户端发 ①,却想让你只转 ② 的部分(比如它对代理说
"帮我连到 A",但Host头写的是 B —— 这叫 Host 头投毒,
是缓存的经典攻击面)。
本课的 _resolve() 两种都处理,并且在越界检查时用的是"request-target 里的主机",
而不是 Host 头——这是一个有意识的安全选择:
如果门禁信任
Host头,攻击者就能用GET http://evil.com/ HTTP/1.1+
Host: 127.0.0.1让门禁以为"这是回环",实际却连去了外部。
门禁必须校验"真正会连过去的主机",而不是"对方声称的主机"。
2.5 CONNECT:为什么隧道里的东西看不见
HTTPS 流量经过代理时,客户端不会把明文请求给代理(那 HTTPS 就没意义了)。
它会先让代理开一条裸字节隧道:
客户端 代理 服务端:443
│ CONNECT example.com:443 HTTP/1.1 │ │
│ ──────────────────────────────────▶ │ │
│ │ TCP connect │
│ │ ──────────────▶│
│ HTTP/1.1 200 Connection Established │ │
│ ◀────────────────────────────────── │ │
│ │ │
│ ══════ TLS 握手(代理看不懂)══════════════════════▶ │
│ ══════ 加密的 HTTP 请求(代理看不懂)════════════════▶ │
代理在这一刻变成了"哑管子":它能看到的有三样(而且只有三样):
- 目标主机名与端口(
CONNECT example.com:443)——这是能看出"去了哪"的; - 双方各传了多少字节、持续多久;
- 什么时候关闭。
它看不到的:URL 路径、请求头、请求体、响应体——全部。
为什么不"顺手解密一下"? 因为 TLS 的信任模型不是"谁转发谁能看":
证书信任链(简化)
浏览器/客户端
└─ 信任 ─▶ 一串内置根 CA
└─ 签发 ─▶ example.com 的证书(含公钥)
└─ 只有持有对应私钥的一方,才能完成握手
要解密,只有两条路,都没有捷径:
| 做法 | 前提 | 性质 |
|---|---|---|
| 拿到目标私钥 | 你拥有那个服务器 | 合法,但说明你不需要"中间人" |
| 让客户端信任你的自签 CA | 你在客户端装了根证书(企业 MDM) | 在自己拥有的设备上合法;在别人设备上就是劫持 |
所以本课的选择是:不尝试、也不假装能解密,而是把"看不见"如实记成一条覆盖缺口,
最终把退出码推到 4。这比"报告里啥也没写,看起来一切正常"诚实得多。
这条规则在报告里的措辞极其重要:
CONNECT的 flow 会让报告写"本段流量未被检查",
而不会写"未发现问题"。因为"没看"和"没问题"是两件事——
把它们混为一谈,是整个安全行业最常见、也最危险的一种自欺。
2.6 流模型:一次往返要记哪些字段
什么是"流"(flow)? 一次完整的"请求 → 响应"往返。
它是流量面的最小证据单元:报告里的每一条结论都能点回到某个 fid。
为什么用"流"而不是"包"? 因为:
| 粒度 | 得到什么 | 适不适合做审计报告 |
|---|---|---|
| 包(packet) | 字节级真相,但一条请求可能拆成几十个包 | ❌ 太细,规则引擎要自己重组 TCP |
| 流(我方定义) | 语义级:方法、URL、头、体、状态、耗时 | ✅ 一条结论对应一条流 |
| 会话(session) | 一次登录到退出的全过程 | 需要额外定义"会话边界",复杂 |
(Day 157 用 pyshark 做的是"包"层;今天做的是"应用层语义"层。两者互补。)
本课记的字段,以及为什么记它/为什么脱敏它:
| 字段 | 记它的理由 | 处理方式 |
|---|---|---|
fid |
让每条结论可点回原始证据 | 自增编号 |
ts / duration_ms |
时间线、慢响应、性能基线 | 原样 |
method / scheme / host / port |
判断"明文还是密文"、"是不是写方法" | 原样 |
path / url |
定位是哪个端点出的问题 | 查询串脱敏 |
query_keys |
单独留下"哪些参数名是敏感的" | 只留参数名 |
request_headers |
鉴权方式、内容类型、UA | 凭据类头整条脱敏 |
request_body |
判定"凭据是否明文传输" | 字段值脱敏 |
response_status |
成功/失败/重定向/服务端错误 | 原样 |
response_headers |
Set-Cookie 标记、安全头、指纹外泄 |
Set-Cookie 等脱敏 |
response_body |
报错回显、响应体泄密 | 字段值脱敏 + PII 擦除 + 截断 |
inspected |
这条流到底看没看见内容 | 布尔值(隧道 = false) |
error |
传输层失败也是事实,不能静默 | 异常类型 + 消息 |
note |
记录"命中了哪些敏感字段" | 只写字段名,不写值 |
两个字段值得单独解释:
① 为什么 inspected 要显式存成一个字段,而不是靠"body 是空的"推断?
因为"body 为空"有太多可能:204 No Content、真的返回空、
被截断、隧道不可见、请求失败——这一堆完全不同的处境,会塌缩成同一个表象。
显式存 inspected 就是拒绝这种塌缩。这是一个通用原则:
当多种原因会导致同一种表象时,必须把"原因"显式存下来,
否则下游只能在"表象"上做猜测。
② 为什么 note 只写字段名? 因为报告需要的是
"这条流里出现了 password 字段"这个结论,
而不是"password 的值是什么"这个秘密。少写一个字节都不行的那种克制。
2.7 本课最重要的设计:先脱敏,再检测
这是 Day 164 唯一一个"你必须能讲清楚,否则不算学会"的设计。
先看一个反面设计(大多数人第一版都会这么写):
# ❌ 反面写法:先检测明文,再把明文删掉
if re.search(r"password=([^&]+)", body):
findings.append("明文口令传输")
body = re.sub(r"password=([^&]+)", "password=<redacted>", body) # 事后补救
它的三个问题:
| 问题 | 后果 |
|---|---|
| 检测依赖明文 | 任何加密/编码变形(Base64、URL 编码)都会让检测失效 |
| 明文先进了内存 | 它可能落进临时文件、日志、异常栈、core dump |
| "先检测后清理"是两段逻辑 | 一旦漏掉一处(比如 path 里的同一个值),就全线失守 |
本课的做法:值在进入档案对象之前就已经被换成了指纹。
# ✅ 本课写法:redact 是第一道工序,不是补救措施
req_body_redacted, body_hits = redact_body(req_body_text, ctype) # 值 → <redacted:hash/NB>
req_body_redacted, req_pii = mask_pii(req_body_redacted) # PII → <pii:类别>
flow = Flow(..., request_body=req_body_redacted, note=body_hits)
于是检测逻辑面对的是这样的文本:
username=ada&password=%3Credacted%3A9f8e7d6c%2F11B%3E
它怎么还能判定"这是明文口令"? 靠两个结构信号:
- 字段名:
password出现在里面 → 说明这个字段承载的是口令; - 值的指纹标记
redacted:→ 说明这个值确实存在且被脱敏了
(不是空值、不是占位符)。
再加上传输层信号(scheme == "http"),结论就成立:"明文传输了口令"。
这个设计的收益是三层叠加的:
① 档案里没有明文 → 审计工具自己不会成为泄密渠道(合规底线)
② 检测不依赖明文 → 连"我要看一看才知道"这一步都不需要(更安全)
③ 脱敏是单一入口 → 只要 redact 覆盖面完整,就不会有漏网之鱼
代价(必须承认):
- 可读性下降:看报告时要对照字段名读,不能一眼看到原文;
- 指纹可被暴力破解:如果值很短(比如 4 位 PIN),
sha256 前 8 位是可以被离线穷举的。所以指纹的作用是"比对",
不是"保密"——真正的保密靠"值本身没被记下来"。
这条"先脱敏再检测"的原则,可以推广到所有日志系统:
日志/档案的默认状态应当是"不含秘密",
需要秘密的场景必须显式申请(而不是默认记下来、事后删)。
2.8 覆盖率:为什么"看不见"必须独立成一种结果
上一节的 inspected 字段,最终会汇成一个整体指标:覆盖率
= 已检查的 flow 数 ÷ 总 flow 数。
为什么它必须单独统计、单独汇报、单独进退出码? 用一个反例说明:
假设一次审计:100 个请求,其中 60 个走 HTTPS(CONNECT 隧道,看不见)
只有 40 个走 HTTP(看见了)
如果报告只写"未发现问题":
读者以为:100 个都检查过了,安全 ✅
实际是: 只有 40 个检查过,另外 60 个是黑洞 ❌
这是审计报告里最危险的一句话——它把"我没看"伪装成"没问题"。
本课的对策是把这件事结构性地表达出来:
报告的"发现汇总"一节开头就写:
(本规则集未命中任何发现。注意:这不等于『安全』,见覆盖率一节。)
覆盖率一节单独列出:
总 flow / 已检查 / 未检查(隧道)/ 传输错误 / 覆盖率%
退出码:
有隧道或传输错误 → 4(结论不可信),优先级高于 3(有发现)
为什么退出码 4 的优先级高于 3(有发现)?
因为"没测完"会掩盖发现:如果一半流量是黑洞,
报告里即使有 P0 也是"不完整的 P0"——先告诉上层"这轮结论不可信",
再谈发现了什么,是更诚实的顺序。
(越界的 2 由异常路径抛出,语义是"根本没开始测",自然排在 4 之前。)
最后一点:传输错误(连不上、超时)也算覆盖缺口。
因为"连不上"和"没问题"在报告里长得一样——都是"没有发现"。
不显式区分,报告就在撒谎。
3. 原理解释
3.1 代理的字节级工作流
把 _ProxyHandler.handle() 拆成 7 步,每一步都对应一个真实会出问题的地方:
① 读请求行 readline() → "GET http://h:p/pa?q=1 HTTP/1.1"
坑:行太长;用 readline(65537) 限制单行上限
② 读请求头 loop readline() 直到空行
坑:头可以有重复键(Set-Cookie);必须存成列表不是 dict
③ 判断是否 CONNECT 是 → 走隧道分支(记录"不可见"),结束
④ 解析目标 _resolve():绝对 URI 形式 / 源站形式,两种都要认
坑:门禁必须校验 request-target 的主机,不是 Host 头
⑤ 门禁检查 主机 + 端口双重白名单;越界 → 403,不建立任何连接
⑥ 读请求体 Content-Length 或 chunked
坑:不实现 chunked 会静默截断请求体(表单变空)
⑦ 转发 + 记录 剔除逐跳头 → http.client 发出去 → 读全响应
→ 脱敏 → 落 flow → 回给客户端
为什么"顺序"本身是设计? 因为第 ⑤ 步必须在第 ⑦ 步之前:
门禁如果放在"已经在转发过程中"再检查,就等于"先连上了再说不该连",
那已经构成了对越界目标的实际访问。门禁是前置条件,不是事后审计。
3.2 逐跳头(hop-by-hop):为什么必须剔除
HTTP 头分两类,这个区分是代理正确性的基础:
| 类别 | 含义 | 例子 |
|---|---|---|
| 端到端(end-to-end) | 描述消息本身,两端之间不该改动 | Content-Type、Authorization |
| 逐跳(hop-by-hop) | 只描述这一段连接,不该被转发 | Connection、Keep-Alive、Transfer-Encoding |
不剔除会怎样? 举三个真实后果:
① 转发 Connection: keep-alive
代理告诉客户端"我要保持这条连接",同时又把它转给服务端。
两端各自的连接状态开始互相污染 → 出现"响应错位"
(客户端读到的响应其实是上一个请求的)。
② 转发 Transfer-Encoding: chunked,同时又自己加了 Content-Length
两条互相矛盾的长度声明 → 客户端按哪个走?
这就是 HTTP 请求走私(request smuggling)的经典土壤。
③ 转发 Proxy-Authorization
把"客户端给代理的凭据"原样发给源站 →
源站日志里出现了一个它根本不该看到的凭据。
本课对请求方向的处理是:剔除 HOP_BY_HOP 集合里的头,然后自己重算
Content-Length 并显式加 Connection: close。对响应方向同样处理。
为什么选 Connection: close 而不是实现 keep-alive? 见 3.3 末尾的取舍说明。
3.3 分块传输与压缩:代理为什么主动去掉 Accept-Encoding
这一节讲两个"看起来是优化、实际是坑"的东西。
(一)chunked:长度不写在头里
Transfer-Encoding: chunked 的意思是"我边生成边发,长度事先不知道":
1a\r\n
{"user":"ada","role":"user"}\r\n
0\r\n
\r\n
为什么代理必须实现它? 因为不实现就会静默截断:
请求体读到 0 字节,但客户端明明发了数据。
表现为"服务端收到的表单是空的"——一个查一整天也查不出来的 bug。
但注意一个不对称:
| 方向 | 本课处理 | 原因 |
|---|---|---|
| 客户端 → 代理 | 解析 chunked(_read_chunked) |
不发出去就没法转发 |
| 代理 → 客户端 | 不用 chunked,统一用 Content-Length |
我们手里已有完整字节,长度是已知的 |
为什么回给客户端时不重新分块? 因为分块是"流式生成"的产物。
我们的数据已经完整躺在内存里了,再用分块只是无谓的复杂度。
(二)压缩:一个必须承认的取舍
Content-Encoding: gzip 的意思是"响应体是 gzip 压过的"。
问题:gzip 压缩后的字节流是二进制,
落到档案里就是一堆乱码——规则引擎看不懂,正则也匹配不到任何东西。
三种处理方式,各带代价:
| 做法 | 好处 | 代价 | 适合 |
|---|---|---|---|
| 透传(不改) | 完全透明,客户端行为不变 | 档案里是二进制,规则失效 | 纯转发代理 |
| 透传 + 按需解压 | 两者兼得 | 代码复杂(要处理 gzip/deflate/br 三种) | 生产代理 ✅ |
代理主动去掉 Accept-Encoding |
最简单,拿到的一定是纯文本 | 流量变大;改变了客户端的观测 | 本课 ✅ |
本课选第三种,理由与代价都写在这里:
# 转发前,从请求头里**删掉** Accept-Encoding
if low == "accept-encoding":
continue
- 收益:服务端返回身份编码(identity,即不压缩)→ 档案永远是纯文本 → 规则可靠;
- 代价 ①:响应体体积变大(少则 2–3 倍),代理链路带宽占用上升;
- 代价 ②:它改变了客户端原本会看到的东西——原本客户端可能期望压缩。
这在"帮你省流量"的角度是负优化。
生产环境该怎么做? 透传
Accept-Encoding,
然后在归档前对Content-Encoding做解压(只解不支持的编码就原样存二进制 + 标记)。
本课选"简化 + 明说",而不是"假装没有这个取舍"。
教学代码最忌讳的就是把取舍藏起来。
(三)为什么干脆不支持 keep-alive
真实代理要处理长连接上的多个请求(甚至流水线)。
本课每个连接只处理一个请求,然后回 Connection: close。
理由很直接:换来 60 行能读懂的核心逻辑。
代价是:每个请求多一次 TCP 三次握手(/slow 那类请求里,握手开销占比很小)。
这符合 HTTP/1.1 规范——Connection: close 是明文允许的行为,
标准客户端(urllib、requests、浏览器)都会正确降级,不会出错。
3.4 14 条规则的判定逻辑总表
规则引擎的核心问题是:"什么样的一个 flow,算是一个发现?"
每条规则都是对这个问题的一个回答。总表如下:
| # | 规则 | 严重度 | 判定条件(看 flow 的哪个字段) |
|---|---|---|---|
| R1 | cleartext-credential |
high | scheme==http ∧ 请求体含敏感字段标记 |
| R2 | sensitive-in-url |
medium | query_keys 非空(敏感参数名出现在查询串) |
| R3 | auth-header-cleartext |
high | scheme==http ∧ 请求头有 Authorization |
| R4 | cookie-missing-flags |
medium | 响应 Set-Cookie 缺 HttpOnly 或 Secure |
| R5 | missing-security-headers |
low | HTML 200 响应缺 nosniff/CSP/HSTS |
| R6 | error-disclosure |
high | 响应体命中报错特征(SQL/堆栈/调试页/路径) |
| R7 | secret-in-response-body |
high | 响应体含敏感字段名(值已脱敏) |
| R8 | server-error |
medium | response_status >= 500 |
| R9 | redirect-chain |
info | response_status ∈ |
| R10 | tls-tunnel-uninspected |
medium | inspected == false 或 method == CONNECT |
| R11 | state-changing-method |
info | 方法 ∈ {PUT,PATCH,DELETE} 或非豁免的 POST |
| R12 | slow-response |
low | duration_ms >= 300 |
| R13 | pii-in-request |
medium | pii_labels 非空 |
| R14 | server-banner |
info | 响应头有 Server/X-Powered-By/X-AspNet-Version |
R10 为什么第一个判定、并且直接 return?
因为它决定"其他规则的结果是否可信"。
一条隧道 flow 没有任何可分析内容,对它跑剩下 13 条规则只会产生无意义的空转——
更重要的是,它是唯一一条"关于覆盖面"的规则,
语义上和"关于内容"的规则不在一个层面。
R11 的"豁免"为什么必须显式写出来?
因为登录、搜索这类业务本身就是 POST。如果全报成"破坏只读",
报告会被噪音淹没,运维会直接忽略整份报告。
_is_benign_post() 的判定是:请求体里既没有敏感字段、也没有 PII → 视为常规业务。
代价写在这里:真实的写操作如果恰好不带敏感字段,会被漏掉。
这是一个显式的取舍,不是一个"没考虑到"。
3.5 明文凭据判定:为什么用"字段名 + 指纹"而不是匹配明文
_body_has_sensitive_marker() 是整个引擎里原理最需要讲清的一个函数。
它回答的是:在值已经被脱敏掉之后,怎么还能判定"这里传了口令"?
看它在实际数据上面对的东西:
username=ada&password=%3Credacted%3A9f8e7d6c%2F11B%3E
判定分两步:
第 1 步:文本里有没有脱敏标记?
"redacted:" 出现 或 "redacted%3a" 出现 ← ⚠️ 见 3.9 的踩坑 ②
├─ 没有 → 返回 False(这个 flow 里没有任何被脱敏的值)
└─ 有 ↓
第 2 步:这些标记是挂在哪个字段名上的?
password= / "api_key": / 'token': … 是否命中 SENSITIVE_KEYS / SECRET_BODY_KEYS?
├─ 命中 → True(存在敏感字段,且值确实被脱敏了)
└─ 未命中 → False
为什么"存在标记"是必要条件? 因为它区分了两种情况:
| 情况 | 文本长什么样 | 应该判定为 |
|---|---|---|
| 字段有值且被脱敏 | password=<redacted:…> |
✅ 明文传了口令 |
| 字段空值或字段名恰好出现在别处 | password= / 文本里提到 "password" |
❌ 不能判定 |
为什么值指纹(sha256 前 8 位)要保留下来,而不是全部删成固定字符串?
因为它支撑一类只能在流量层发现的结论:
同一个口令的指纹 a1b2c3d4 出现在 3 个不同端点的请求里
↓
结论:这个口令被重复使用(横向移动风险的信号)
这个结论不需要明文就能得出。这是"先脱敏"设计的直接收益:
脱敏不是把信息扔掉,而是把信息降维到"结构性信号"。
3.6 优先级 = 严重度 × 置信度
priority = SEVERITY_SCORE[severity] * confidence
level = P0(≥3.0) / P1(≥2.0) / P2(≥1.0) / P3(<1.0)
为什么不是"只看严重度"? 看两个例子就明白了:
| 发现 | 严重度 | 置信度 | 优先级 | 该不该马上处理 |
|---|---|---|---|---|
| 明文传输口令 | high (3.0) | 0.95 | 2.85 → P1 | ✅ 马上 |
| 隧道不可见 | medium (2.0) | 1.00 | 2.00 → P1 | ⚠️ 这是覆盖问题 |
| 缺安全头 | low (1.0) | 0.80 | 0.80 → P3 | 排期做 |
| 疑似时间盲注痕迹 | high (3.0) | 0.30 | 0.90 → P3 | 先复核,别慌 |
最后一行是关键:一个"严重但很可能是假阳性"的发现,
优先级必须低于"不严重但确定"的发现。
否则每次扫描都会把运维拉去追一堆幻觉,几轮之后他们就不再读报告了——
报告没人读,等于没有报告。
乘法还能表达"双重打折":严重度高、置信度也高 → 相乘后被放大;
两项都低 → 相乘后被压到几乎看不见。这正是我们想要的排序行为。
3.7 语义化退出码
def exit_code(self) -> int:
if self.scope_violation: return 2 # 越界(根本没开始测)
if self.coverage_incomplete: return 4 # 覆盖缺口(结论不可信)
if any(f.level in ("P0","P1") for f in self.findings): return 3
return 0
判定顺序就是优先级顺序,理由各自不同:
2 越界 → 这是**配置/流程**问题,不是安全问题。要人改工单,不是改代码。
4 覆盖不完整 → 这是**结论可信度**问题。必须先说"这轮结果别信",再谈发现。
3 有 P0/P1 发现 → 这是**安全结果**。要人去看、去修。
0 干净 → 唯一一个"可以自动通过"的信号。
为什么不用 0/1? 因为 CI 流水线的后续动作完全不同:
| 退出码 | 流水线该做什么 |
|---|---|
| 0 | 继续,自动合并 |
| 2 | 停下来,找流程负责人(工单/范围不对) |
| 3 | 停下来,找开发负责人(有洞要修) |
| 4 | 停下来,找运维(采集方案有盲区,结论不可用) |
注意退出码 3 的判定用的是 level(P0/P1)而不是"有任何发现"。
因为 info 级发现(重定向、服务端指纹)数量可能上百,
如果它们能让流水线失败,这条流水线一周内就会被关掉。
3.8 覆盖率与"结论可信度"
facts["coverage_percent"] = 100.0 * inspected / total # 只算"内容看得见"的
coverage_incomplete = (uninspected > 0) or (transport_errors > 0)
为什么 transport_errors(连不上/超时)也算不完整?
因为一次超时意味着"我们对这个端点一无所知"。
它不是"没问题",是"没有结论"。把两者分开是报告诚实性的基准线。
覆盖率低到什么程度就不能出结论了? 本课不设硬阈值——
因为"低"的容忍度取决于审计目的(合规取证 vs 内部自检完全不同)。
本课的做法是:把数字和事实摆出来,让决策者自己判断,
并保证"报告不会在覆盖率低的时候假装通过"(退出码 4)。
3.9 三个由"实测"驱动的修复(真实踩坑记录)
这一节是本课最有价值的部分。 下面三条不是"设计时的考虑",
而是本模块第一版真的写错了、跑起来才发现的问题。
它们都已经变成 mitm_core.py --self-test 里的断言。
踩坑 ①:build_opener() 会"偷偷"装上重定向处理器
症状:/redirect?to=/login 这条 flow,报告里记的状态码是 404 而不是 302。
redirect-chain 规则永远沉默。
根因:
# ❌ 看起来像"我没装重定向处理器,所以不会跟随"
opener = urllib.request.build_opener(ProxyHandler(...), HTTPHandler())
但 build_opener() 的语义是"在默认处理器的基础上加上你给的":
你没提供 HTTPRedirectHandler,它就自动补上默认实现 →
302 被悄悄跟随到 /login → 最终记录的是 404。
修复:提供子类并让 redirect_request() 返回 None:
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None # 返回 None → urllib 把 3xx 当 HTTPError 抛出来
opener = urllib.request.build_opener(ProxyHandler(...), _NoRedirect(), ...)
教训:
"我没有做某件事"和"某件事不会发生"是两回事。
遇到"框架替你做了默认选择"的场景,必须验证默认值,
而不是推理"我传了什么"。
踩坑 ②:脱敏标记在 urlencode 之后变形了
症状:cleartext-credential 规则对所有 form 表单都不命中。
根因:请求体是 application/x-www-form-urlencoded,
脱敏后值变成 <redacted:9f8e7d6c/11B>,
但 urlencode() 会把 : 转义成 %3A:
password=<redacted:9f8e7d6c/11B> ← 脱敏时
password=%3Credacted%3A9f8e7d6c%2F11B%3E ← 落进档案时
而检测代码里写的是 if "redacted:" in body → 永远不匹配。
修复:两个写法都认。
if "redacted:" not in lowered and "redacted%3a" not in lowered:
return False
教训:
序列化会改变字面量。 任何"写入 → 编码 → 再读出比对"的链路,
都要在真实编码之后的字面量上做匹配,而不是在"我构造时的样子"上做。
踩坑 ③:只脱敏了 URL,忘了 path 里也带着查询串
症状:自检里的"档案不得包含明文 token"断言失败:
AssertionError: 明文 token 泄漏进了档案! # tk_live_164_fake
根因:_forward() 里做了两件事:
raw_url, query_keys = redact_url(url) # ✅ url 脱敏了
flow = Flow(path=path, url=raw_url, ...) # ❌ path 还是**原始**的(带查询串)
path 是 /search?q=python&token=tk_live_164_fake,
同一个秘密在两处出现,只脱敏了一处。
修复:脱敏 path 里同一份查询串。
safe_path = _redact_query_in_path(path) # 值 → 指纹
flow = Flow(path=safe_path, url=raw_url, ...)
教训:
脱敏必须"穷尽同源数据"。
同一个秘密可能同时出现在 URL、path、Referer、日志字段、异常栈里。
只处理你知道的那一处,等于没处理。
写一条断言去扫整个档案文件(本课自检就是干这个的),
是唯一可靠的兜底手段。
这三个坑的共同结构
① 假设了框架的默认行为(其实是别的)
② 假设了字面量在编码后不变(其实会变)
③ 假设了"处理了一处 = 处理了全部"(其实不是)
↓
共同点:**都是关于"我以为"的假设,而不是关于"实际数据"的观测。**
↓
对策:把假设变成**断言**,让它在 CI 里替你记住。
4. 图解
完整的字符画与 Mermaid 图在
diagrams/README.md(7 张)。
这里只贴最核心的两张。
4.1 三层观测的关系(为什么流量面补的是结构性缺失)
资产面(Day 162) 门锁有没有装 → 快照
漏洞面(Day 163) 这把锁看起来像能撬 → 推断(有假阳性)
流量面(Day 164) 刚才有人拿钥匙开了门 → 事实(可复核、可留证)
↑
它不需要**制造**任何输入,只是把已发生的对话录下来
→ 所以它能用在生产环境(漏洞扫描器做不到)
4.2 CONNECT 之后:代理能看见什么
客户端 AuditProxy 服务端 :443
│ CONNECT host:443 │ │
│ ─────────────────────▶│ TCP connect │
│ ◀─────────────────────│ │
│ ═══ TLS 握手(看不懂)══▶│ ═══ 原样转发 ═══════════▶│
│ ═══ 加密 HTTP(看不懂)═▶│ ═══ 原样转发 ═══════════▶│
│ │
能记录:host:port ✅ 字节数 ✅ 时间 ✅
看不到:路径 ❌ 头 ❌ 体 ❌
│
▼
Flow(inspected=False) → 覆盖缺口 +1 → 退出码 4
5. 定义与使用方法(API 速查表)
5.1 mitm_core.py 模块速查
| 名称 | 类型 | 作用 |
|---|---|---|
Scope |
dataclass | 授权范围:hosts / ports / ticket;check_url() 越界抛异常 |
AuthorizationError |
异常 | 越界(映射退出码 2);AuditProxy 监听非回环也抛这个 |
Flow |
dataclass | 一条 flow 的完整档案(23 个字段,见 5.3) |
FlowStore |
类 | JSONL 档案馆:append() / load() / next_id() |
LabOrigin |
上下文管理器 | 自建实验网站,只绑 127.0.0.1,含 9 个演示端点 |
LabOpaqueTCP |
上下文管理器 | 假 TLS 服务(纯文本回声),演示"隧道不可见" |
AuditProxy |
上下文管理器 | 正向代理;url 属性给客户端用;拒绝绑定非回环 |
LabSession |
类 | 脚本化客户端:走代理发请求,默认不跟随重定向 |
FlowAnalyzer |
类 | 14 条规则 → Finding;facts / exit_code() |
Finding |
dataclass | rule / severity / confidence / priority / level / evidence / why / fix |
render_json() / render_markdown() |
函数 | 两种报告渲染 |
EXIT_* / EXIT_MEANING |
常量 | 语义化退出码与其含义 |
5.2 顶层常量
| 常量 | 值 | 说明 |
|---|---|---|
SEVERITY_SCORE |
critical 4.0 / high 3.0 / medium 2.0 / low 1.0 / info 0.5 | 优先级的乘数 |
SENSITIVE_KEYS |
30 个字段名 | 命中则值换指纹 |
REDACT_HEADERS |
7 个头名 | 命中则整个值换指纹 |
HOP_BY_HOP |
9 个头名 | 转发前必须剔除 |
ERROR_SIGNATURES |
4 类正则 | 报错回显特征(SQL / 堆栈 / 调试页 / 路径) |
PII_PATTERNS |
3 类正则 | 邮箱 / 手机号(CN) / 身份证(CN) |
MAX_BODY_BYTES |
65536 | 档案里正文的截断上限 |
SLOW_MS |
300.0 | 慢响应阈值(毫秒) |
TUNNEL_SECONDS |
1.5 | 隧道盲转发的时长上限(教学用,生产应无限) |
DOC_NET / LOOPBACK_HOSTS |
192.0.2. / 回环三种写法 |
允许的目标范围 |
5.3 Flow 字段语义(重点字段)
| 字段 | 类型 | 语义 | 是否脱敏 |
|---|---|---|---|
fid |
str | 流编号,报告的证据锚点 | — |
scheme |
str | http / https(判定"是否明文"的第一依据) |
— |
method / host / port / path |
str/int | 定位 | path 的查询串脱敏 |
url |
str | 完整 URL | 查询串脱敏 |
query_keys |
list | 命中的敏感参数名列表 | 只有名字 |
request_headers |
dict | 请求头 | 凭据类整条脱敏 |
request_body |
str | 请求体 | 值脱敏 + PII 擦除 |
response_headers |
list[list] | 响应头(保序保重复,因为 Set-Cookie 可重复) |
Set-Cookie 等脱敏 |
response_body |
str | 响应体 | 值脱敏 + PII 擦除 + 截断 |
response_body_truncated |
bool | 是否被截断(影响结论完整性) | — |
inspected |
bool | 这条流的内容到底看没看见 | — |
error |
str | 传输层异常("没结论"≠"没问题") | — |
note |
str | "命中过哪些敏感字段"(只写字段名) | — |
pii_labels |
list | 出现过的 PII 类别(擦除时必须当场记) | 只有类别 |
5.4 FlowAnalyzer 用法速查
scope = Scope(ticket="TICKET-164")
analyzer = FlowAnalyzer(scope=scope)
findings = analyzer.analyze(FlowStore("flows.jsonl").load())
analyzer.facts # {'flows':14,'inspected':13,'uninspected':1,
# 'coverage_percent':92.9,'rules_hit':{...},...}
analyzer.findings_by_severity # 按 critical→info 排好序的 Finding 列表
analyzer.findings[0].priority # 3.0 * 0.95 = 2.85
analyzer.findings[0].level # 'P0' / 'P1' / 'P2' / 'P3'
analyzer.coverage_incomplete # True → 有隧道或传输错误
analyzer.exit_code() # 2 / 4 / 3 / 0
5.5 CLI 速查
# 全自动:起靶场(18081) + 代理(18080) → 抓会话 → 分析 → 出报告
python3 03-traffic-audit-cli.py
# 只分析已有档案(CI 友好,不起任何服务)
python3 03-traffic-audit-cli.py --capture out/day164-capture-x.jsonl
# 指定标签与输出目录
python3 03-traffic-audit-cli.py --out out --tag nightly-2026-09-23
# 只打印两个报告路径(给脚本用)
python3 03-traffic-audit-cli.py --capture x.jsonl --quiet
# 端口被占用时的告警演示
python3 03-traffic-audit-cli.py --origin-port 1
| 退出码 | 含义 | 流水线该做什么 |
|---|---|---|
0 |
干净通过(在已检查流量里无 P0/P1) | 继续 |
2 |
被范围门禁拒绝(根本没开始测) | 找流程负责人(工单/范围) |
3 |
有 P0/P1 发现 | 找开发修 |
4 |
覆盖不完整(隧道 / 传输错误 / 清单端点未出现) | 找运维补采集 |
6. 实战代码案例
6.1 code/mitm_core.py — 共享引擎(1906 行)
六个可独立使用的部件,全部纯标准库:
Scope 范围门禁(主机 + 端口,独立成文件的快照式设计)
FlowStore JSONL 档案馆(追加即安全,坏一行只丢一行)
LabOrigin 实验网站(9 个端点,故意做出"会泄密的流量")
LabOpaqueTCP 假 TLS 服务(演示"隧道不可见")
AuditProxy 正向代理(socket 级,含 CONNECT、chunked、脱敏、落档)
FlowAnalyzer 14 条规则 + 覆盖率 + 语义化退出码
自检(强烈建议先跑这个):
python3 mitm_core.py --self-test # 期望输出:SELF-TEST OK
自检里 7 组断言覆盖了"我以为它会怎样"和"它必须怎样"的差距:
| # | 断言 | 抓的是什么问题 |
|---|---|---|
| ① | 11 条必要规则全部命中 | 规则被改坏 / 靶场端点被改坏 |
| ② | 档案里不含任何明文口令/token/密钥/PII | 脱敏失效(踩坑 ②③④ 的兜底) |
| ③ | 隧道被记为 inspected=False,覆盖率 < 100% |
覆盖缺口被"算没了" |
| ④ | 退出码必须是 4(不是 3、不是 0) |
优先级顺序被写反 |
| ⑤ | AuditProxy(host="0.0.0.0") 必须抛异常 |
开放代理这种法律风险被放开 |
| ⑥ | 8.8.8.8 必须被门禁拦住 |
门禁失效 |
| ⑦ | 报告含关键小节且 JSON 可解析 | 报告渲染被改坏 |
6.2 code/01-flow-capture.py — 基础用法
最小可运行闭环。为什么第一个例子就要三方齐备?
因为代理是三方系统,缺一方你就无法判断"是我写错了,还是源站本来就这么回"。
python3 01-flow-capture.py # 完整闭环
python3 01-flow-capture.py --no-tunnel # 覆盖率会变成 100%
python3 01-flow-capture.py --hold # 只起服务,手工 curl
实测输出(节选):
📊 捕获汇总
总 flow : 14
已检查 : 13
未检查(隧道) : 1
传输错误 : 0
覆盖率 : 92.9%
状态码分布:
2xx 成功 9
4xx 客户端错 3
3xx 重定向 1
5xx 服务端错 1
它还会把登录那条 flow 的档案原文完整打出来,让你亲眼看到三件事:
口令值变成了指纹、note 里保住了"命中过 password"的结论、
Set-Cookie 的值也被换成了指纹。
6.3 code/02-flow-analyze.py — 进阶用法与避坑
python3 02-flow-analyze.py # 分析 + 四个踩坑现场复现
python3 02-flow-analyze.py --no-pitfalls # 只看分析结果
实测输出(节选):
🔎 规则引擎输出
分析 flow : 14 条
覆盖率 : 92.9%(已检查 13 / 未检查 1 / 传输错误 0)
命中规则 : {'server-banner': 15, 'missing-security-headers': 3,
'pii-in-request': 2, 'state-changing-method': 2,
'cleartext-credential': 2, 'cookie-missing-flags': 2,
'server-error': 1, 'error-disclosure': 1, ...}
严重度分布 : {'info': 19, 'medium': 7, 'low': 4, 'high': 4}
🟧 1. [P1 · high] cleartext-credential
flow : f-000005 http://127.0.0.1:18081/login
证据 : POST /login 的请求体含敏感字段(值已脱敏为指纹)
优先级 : 2.85
四个踩坑演示的实测结果:
坑 ① 默认 opener : 404(❌ 已被跟随:/login 是 404)
显式 _NoRedirect: 302(✅ 停在第一跳,可被 R9 记录)
坑 ② 朴素检测 if 'redacted:' in body → False ← 规则全线沉默
正确检测 'redacted:' 或 'redacted%3a' → True
坑 ③ 只脱敏 URL、忘了 path → 档案里还有明文 token → True
坑 ④ 擦除后重扫正文 → [](空!PII 已经不在了)
正确做法:擦除时当场记进 flow.pii_labels
6.4 code/03-traffic-audit-cli.py — 实战流水线
# ① 全自动:正常路径 → 退出码 4(覆盖率 92.9%,因为它诚实地承认有隧道)
python3 03-traffic-audit-cli.py --tag demo
# 覆盖率(流量层) : 92.9%(已检查 13 / 未检查 1 / 传输错误 0)
# 覆盖率(业务层) : 100.0%(清单 9 个端点,命中 9,未见 0)
# 🚦 退出码 = 4
# ② 只分析样例档案(CI 友好)
python3 03-traffic-audit-cli.py --capture flows.example.jsonl --tag fixture
# 覆盖率(流量层) : 100.0% ← 样例档案里没有隧道
# 🚦 退出码 = 4 ← 但业务层有 9 个端点从未出现 → 仍判为不完整
# ③ 手工构造的越界档案 → 退出码 2(拒绝出结论)
python3 03-traffic-audit-cli.py --capture out/day164-scope-violation.jsonl
# ❌ 档案中出现越界主机:evil.example.com(拒绝出结论)
# 🚦 退出码 = 2 → 被范围门禁拒绝(根本没开始测)
第 ② 条特别值得体会:流量层 100%、没有任何问题——
但它仍然是 4,因为业务层的 9 个端点从来没被访问过。
如果只看第一层,这里就会得出"100% 覆盖、干净通过"的假象。
6.5 五个由"实测"驱动的修复(真实踩坑汇总)
| # | 现象 | 根因 | 修复 |
|---|---|---|---|
| ① | 302 在档案里记成 404,R9 永不命中 | build_opener() 自动补默认重定向处理器 |
显式传 redirect_request() 返回 None 的子类 |
| ② | 所有 form 表单的明文凭据都不命中 | urlencode 把 : 变成 %3A |
两种写法都认 |
| ③ | 自检报"明文 token 泄漏进档案" | 只脱敏了 url,忘了 path 带同一份查询串 |
_redact_query_in_path() |
| ④ | 响应体里的密钥原样落进档案 | 只对请求体做了脱敏 | 响应体也跑 redact_body |
| ⑤ | 业务层覆盖率恒为 0% | 靶站端口随机,与清单里的 :18081 对不上 |
固定端口 + 占用时退回并明确告警 |
这五条的共性:它们都不是"写错了逻辑",而是"假设错了事实"。
对策只有一个:把假设变成断言(第 ①②③④ 条现在都在 --self-test 里)。
7. 自检与验证
cd days/day-164-安全审计工具箱-part3/code
# ① 引擎自检(7 组断言,含"档案不得含明文"这条硬线)
python3 mitm_core.py --self-test
# 期望:SELF-TEST OK
# ② 基础闭环
python3 01-flow-capture.py | tail -20
# ③ 规则引擎 + 踩坑复现
python3 02-flow-analyze.py --no-pitfalls | tail -20
# ④ 完整 CLI(三种模式)
python3 03-traffic-audit-cli.py --tag verify ; echo "exit=$?" # → 4
python3 03-traffic-audit-cli.py --capture flows.example.jsonl --quiet ; echo "exit=$?" # → 4
python3 03-traffic-audit-cli.py --capture out/day164-scope-violation.jsonl; echo "exit=$?" # → 2
额外的"防泄密"验证(推荐每次改完脱敏逻辑都跑一遍):
# 用一组明文样本扫描整份档案,任何一个出现都说明脱敏漏了
python3 - <<'EOF'
import json, pathlib
BAD = ["Sup3rSecret!", "tk_live_164_fake", "sk_live_164_FAKE_NOT_REAL", "13800138000"]
for p in pathlib.Path("out").glob("*.jsonl"):
blob = p.read_text(encoding="utf-8")
hits = [b for b in BAD if b in blob]
print(f"{p.name}: {'❌ ' + str(hits) if hits else '✅ 无明文'}")
EOF
8. 思考题
-
框架默认值:踩坑 ① 里
build_opener()替你装了重定向处理器。
请举出另外两个"框架默认行为与直觉相反"的例子(不限 Python),
并说明"怎么才能在写代码时发现它"。
提示:dict.get的默认值、re.matchvsre.search、
float的相等判断、argparse的required… -
只脱敏一处 vs 穷尽同源:踩坑 ③ 里同一个 token 同时出现在
url与path。
请设计一个机制(不是"更仔细地检查")来保证"同一个秘密的所有落点都被处理"。
思考方向:是先归一化成一个结构、所有落点都从它派生?
还是在出口做一次全文扫描断言?两种各有什么代价? -
覆盖率的"两层"还不够:本课算了流量层与业务层。还有第三层吗?
提示:如果目标是 HTTPS,隧道 flow 会让"流量层"下降;
但如果目标机器上同时跑着一个你完全不知道的高危端点,哪一层能发现它?
这一层应该由谁负责(Day 162 的哪一步)? -
pii_labels的取舍:本课选择只记 PII 类别,不记值。
但假设真实需求是"审计发现某员工把客户数据外传"——需要值才能举证。
请写出一个既满足举证、又不让档案变成泄密源的方案,并列出它的残余风险。
思考方向:加密存储 + 访问审计 + 保留期;残余风险 = 密钥持有者本人。 -
退出码的语义:为什么本课把"覆盖不完整"(4)排在"有发现"(3)前面?
请构造一个反例:什么时候应该反过来(先报发现、再报覆盖)?
提示:如果目的是"立刻止血"而不是"出具报告",顺序可能反过来——
这说明退出码的优先级其实取决于谁在消费这个信号。 -
规则的假阳性:R11
state-changing-method把"非豁免 POST"报成 info。
_is_benign_post()的豁免条件是"请求体里既没有敏感字段、也没有 PII"。
请构造一个漏报场景(真实写操作被豁免掉了),
并说明为什么本课宁可漏报也不加更激进的规则。
提示:报告的可信度 > 报告的完备性。
9. 边界与合规(每一条都是设计决策,不是场面话)
| # | 边界 | 为什么它必须是硬约束 |
|---|---|---|
| 1 | 只监听回环(0.0.0.0 直接抛异常) |
开放 HTTP 代理会在几分钟内被扫到并滥用,这是实际法律责任,不是"配置选项" |
| 2 | 门禁校验 request-target 的主机,不是 Host 头 |
信任 Host 头 = 允许 Host 头投毒绕过门禁 |
| 3 | 只发幂等方法 | 写操作会让"审计"变成"操作",两者授权等级不同 |
| 4 | 不伪造证书、不降级 TLS、不破解隧道 | 客户端装自签 CA 在自己设备上合法,在别人设备上就是劫持 |
| 5 | 不改包、不重放、不注入响应 | 观测与利用的分界线。跨过去,同一份代码性质就变了 |
| 6 | 档案默认不含明文凭据 | 审计工具若是泄密渠道,比不审计更糟(它把秘密集中并长期保存了) |
| 7 | PII 当场擦除,只留类别 | 最小必要原则;审计记录应证明"发生过",而不是复制"内容" |
| 8 | 隧道如实记账,计入覆盖缺口 | "没看"≠"没问题"。这是整份报告诚实性的基准线 |
| 9 | 传输错误也算覆盖缺口 | "连不上"和"没问题"在报告里长得一样,不区分就是撒谎 |
| 10 | 报告先讲覆盖率,再讲发现 | 覆盖率低时,所有结论都要打折——顺序本身就是信息 |
一句话总结:本工具的全部价值,建立在"它说的话都能被复核"之上。
任何一条边界被放开,这个前提就没了。
10. 今日完成清单
- [x]
mitm_core.py共享引擎(Scope/FlowStore/LabOrigin/
LabOpaqueTCP/AuditProxy/FlowAnalyzer/ 报告渲染 /--self-test) - [x] 正向代理:绝对 URI 与源站形式、逐跳头处理、chunked、
CONNECT隧道 - [x] 脱敏流水线:URL / path / 头 / 体 / PII,先脱敏再检测
- [x] 14 条规则 + 优先级(严重度 × 置信度)+ 语义化退出码
- [x] 两层覆盖率(流量层 + 业务层)与"清单端点从未出现"报警
- [x]
01-flow-capture.py基础用法(三方闭环 + 档案原文展示) - [x]
02-flow-analyze.py进阶用法(规则引擎 + 四个真实踩坑现场复现) - [x]
03-traffic-audit-cli.py实战 CLI(两种模式 + 三种退出码实测) - [x]
targets.example.txt目标清单 ·flows.example.jsonl样例档案 - [x]
diagrams/README.md七张图(含两层覆盖率与退出码决策树) - [x]
exercises/checklist.md完成清单 + 基础练习 + 进阶挑战
明天(Day 165):交付面 —— 报告生成(把 Day 162/163/164 的产出汇总成一份
可交付的资产审计报告)+ Docker 部署(一键复现整条流水线)。