← 返回总目录

Day 164 — 阶段项目(三):安全审计工具箱 · 代理中间人 + 流量分析

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

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

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


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 产出):

  1. URL + 参数清单 → 代理要用它来决定"该代理哪些流量";
  2. 一场真实的业务会话(登录、查询、下单…)→ 代理要用它来做基线。

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 读取"(事实)

为什么流量面能拿到"事实"? 因为它不需要制造任何输入。
它只是把已经发生的业务对话录下来。
这一点带来两个重要性质:

  1. 零打扰:不对业务系统注入任何探针,不增加负载(只增加一次转发)。
    所以它可以用在生产环境——这是漏洞扫描器做不到的。
  2. 可复核:报告里的每一条都能点回到具体某一条 flow 的原始字段
    (值是脱敏的,但结构、长度、指纹都在)。

代价也很明确:

所以三层的正确关系是"互相补盲",不是"谁替代谁":

资产面 给流量面:该盯哪些 URL
漏洞面 给流量面:哪条推测值得在流量里找证据
流量面 给漏洞面:这条推测到底是不是真的发生了

2.4 代理协议的细节:两种请求行形式

这是手写代理第一个会踩的坑,必须讲清楚。

HTTP 请求行的格式是:

METHOD  request-target  HTTP-VERSION

而 request-target 在代理场景下有两种形式:

http
① 绝对 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 形式(必须自带主机名)

为什么代理两种都得支持? 因为:

本课的 _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 请求(代理看不懂)════════════════▶  │

代理在这一刻变成了"哑管子":它能看到的有三样(而且只有三样):

  1. 目标主机名与端口(CONNECT example.com:443)——这是能看出"去了哪"的;
  2. 双方各传了多少字节、持续多久;
  3. 什么时候关闭。

它看不到的: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 唯一一个"你必须能讲清楚,否则不算学会"的设计。

先看一个反面设计(大多数人第一版都会这么写):

python
# ❌ 反面写法:先检测明文,再把明文删掉
if re.search(r"password=([^&]+)", body):
    findings.append("明文口令传输")
    body = re.sub(r"password=([^&]+)", "password=<redacted>", body)   # 事后补救

它的三个问题:

问题 后果
检测依赖明文 任何加密/编码变形(Base64、URL 编码)都会让检测失效
明文先进了内存 它可能落进临时文件、日志、异常栈、core dump
"先检测后清理"是两段逻辑 一旦漏掉一处(比如 path 里的同一个值),就全线失守

本课的做法:值在进入档案对象之前就已经被换成了指纹。

python
# ✅ 本课写法: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

它怎么还能判定"这是明文口令"? 靠两个结构信号:

  1. 字段名:password 出现在里面 → 说明这个字段承载的是口令;
  2. 值的指纹标记 redacted: → 说明这个值确实存在且被脱敏了
    (不是空值、不是占位符)。

再加上传输层信号(scheme == "http"),结论就成立:"明文传输了口令"。

这个设计的收益是三层叠加的:

①  档案里没有明文         → 审计工具自己不会成为泄密渠道(合规底线)
②  检测不依赖明文         → 连"我要看一看才知道"这一步都不需要(更安全)
③  脱敏是单一入口         → 只要 redact 覆盖面完整,就不会有漏网之鱼

代价(必须承认):

这条"先脱敏再检测"的原则,可以推广到所有日志系统:

日志/档案的默认状态应当是"不含秘密",
需要秘密的场景必须显式申请(而不是默认记下来、事后删)。

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 最简单,拿到的一定是纯文本 流量变大;改变了客户端的观测 本课 ✅

本课选第三种,理由与代价都写在这里:

python
# 转发前,从请求头里**删掉** Accept-Encoding
if low == "accept-encoding":
    continue

生产环境该怎么做? 透传 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 优先级 = 严重度 × 置信度

python
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 语义化退出码

python
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 覆盖率与"结论可信度"

python
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 规则永远沉默。

根因:

python
# ❌ 看起来像"我没装重定向处理器,所以不会跟随"
opener = urllib.request.build_opener(ProxyHandler(...), HTTPHandler())

但 build_opener() 的语义是"在默认处理器的基础上加上你给的":
你没提供 HTTPRedirectHandler,它就自动补上默认实现 →
302 被悄悄跟随到 /login → 最终记录的是 404。

修复:提供子类并让 redirect_request() 返回 None:

python
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 → 永远不匹配。

修复:两个写法都认。

python
if "redacted:" not in lowered and "redacted%3a" not in lowered:
    return False

教训:

序列化会改变字面量。 任何"写入 → 编码 → 再读出比对"的链路,
都要在真实编码之后的字面量上做匹配,而不是在"我构造时的样子"上做。

踩坑 ③:只脱敏了 URL,忘了 path 里也带着查询串

症状:自检里的"档案不得包含明文 token"断言失败:

AssertionError: 明文 token 泄漏进了档案!   # tk_live_164_fake

根因:_forward() 里做了两件事:

python
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 里同一份查询串。

python
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 用法速查

python
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 速查

bash
# 全自动:起靶场(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 条规则 + 覆盖率 + 语义化退出码

自检(强烈建议先跑这个):

bash
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 — 基础用法

最小可运行闭环。为什么第一个例子就要三方齐备?
因为代理是三方系统,缺一方你就无法判断"是我写错了,还是源站本来就这么回"。

bash
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 — 进阶用法与避坑

bash
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 — 实战流水线

bash
# ① 全自动:正常路径 → 退出码 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. 自检与验证

bash
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

额外的"防泄密"验证(推荐每次改完脱敏逻辑都跑一遍):

bash
# 用一组明文样本扫描整份档案,任何一个出现都说明脱敏漏了
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. 思考题

  1. 框架默认值:踩坑 ① 里 build_opener() 替你装了重定向处理器。
    请举出另外两个"框架默认行为与直觉相反"的例子(不限 Python),
    并说明"怎么才能在写代码时发现它"。
    提示:dict.get 的默认值、re.match vs re.search、
    float 的相等判断、argparse 的 required…

  2. 只脱敏一处 vs 穷尽同源:踩坑 ③ 里同一个 token 同时出现在 url 与 path。
    请设计一个机制(不是"更仔细地检查")来保证"同一个秘密的所有落点都被处理"。
    思考方向:是先归一化成一个结构、所有落点都从它派生?
    还是在出口做一次全文扫描断言?两种各有什么代价?

  3. 覆盖率的"两层"还不够:本课算了流量层与业务层。还有第三层吗?
    提示:如果目标是 HTTPS,隧道 flow 会让"流量层"下降;
    但如果目标机器上
    同时跑着一个你完全不知道的高危端点,哪一层能发现它?
    这一层应该由谁负责(Day 162 的哪一步)?

  4. pii_labels 的取舍:本课选择只记 PII 类别,不记值。
    但假设真实需求是"审计发现某员工把客户数据外传"——需要值才能举证。
    请写出一个既满足举证、又不让档案变成泄密源的方案,并列出它的残余风险。
    思考方向:加密存储 + 访问审计 + 保留期;残余风险 = 密钥持有者本人。

  5. 退出码的语义:为什么本课把"覆盖不完整"(4)排在"有发现"(3)前面?
    请构造一个反例:什么时候应该反过来(先报发现、再报覆盖)?
    提示:如果目的是"立刻止血"而不是"出具报告",顺序可能反过来——
    这说明退出码的优先级其实取决于
    谁在消费这个信号。

  6. 规则的假阳性: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. 今日完成清单

明天(Day 165):交付面 —— 报告生成(把 Day 162/163/164 的产出汇总成一份
可交付的资产审计报告)+ Docker 部署(一键复现整条流水线)。