哈希链校验「报错位置全错位」+ 字节口径被静默换成半额:审计账单 4 个假绿灯排查实录(Python / sha256 / Docker)
日期:2026-10-20|分支
2026-10-20-0001|第 137 题 技术栈:Python 3.12 标准库、sha256 哈希链(hash chain)、TSV 台账、Docker Compose 关键词:hash chain 校验、sha256 digest、genesis prev、审计台账、字节口径 KiB、覆盖率统计、 假绿健康检查、health check 退出码、fail-open 合同、fail-closed、orphan extent、duplicate commit、prev_mismatch、digest_mismatch、pyc 缓存 mtime 过期、负向测试闸门
一、问题背景
audit-ledger 是备份系统里的审计账单引擎。每次备份把文件切成「区段(extent)」写盘,
每写一个区段就在台账 ledger.tsv 追加一条记录,并串成 sha256 哈希链:
seq verdict file_id extent_seq len_kib prev digest
1 commit f-orders 0 4 (GENESIS)
2 commit f-orders 1 4 56a74180245b5327 f3a13749d0af2706
...
4 rollback f-payments 0 0 ← 不参与链
5 snapshot f-metrics 3 0 ← 不参与链
7 reconcile 079f0b142c7ceb99 486638bea9452b61
合规审计发现了 5 个矛盾:
report.json里total_bytes = 8192,但台账实际提交的字节数远超这个数;chain.ok = false,但报错位置全部错位,指向prev_mismatch@4、digest_mismatch@5这些根本不参与链的rollback/snapshot记录;counts.orphan_extent = 0,但台账明明有「只被回滚/快照碰过、从未提交」的区段;- 重复提交的区段被算成「已覆盖」,
coverage.gaps少了 1; - 更糟的是四个绿灯:
run.py退出码0、health.py输出{"degraded": false, "issues": []}且退出码0、报告字段齐全、TSV 能正常解析。
唯一说真话的是外部独立探针(只读合同 + 输入事实 + report.json,不 import 被测代码):
$ python3 client/ledger_probe.py
RESULT: BAD(不一致 7 条)
- total_bytes:期望 63488 实际 8192
- chain.issues:期望 [...10 条...] 实际 [...12 条...]
- coverage.gaps:期望 10 实际 9
- files[f-orders].shape:期望 "duplicate" 实际 "prefix_only"
二、复现步骤
cd scenario/2026-10-20-0001
docker compose up -d --build
docker compose exec -T ledger bash -lc '
python3 app/run.py input state conf; echo "run rc=$?"
python3 app/health.py; echo "health rc=$?"
python3 client/ledger_probe.py; echo "probe rc=$?"
bash ops/verify.sh | tail -3
'
问题态输出:
{"ok": false, "report": "state/report.json"}
run rc=0 ← 绿灯①:有真问题却退出 0
{"degraded": false, "issues": []}
health rc=0 ← 绿灯②:判据表为空
RESULT: BAD(不一致 7 条)
probe rc=1
PASS=8 FAIL=5
RESULT: BAD
三、根本原因
对着唯一权威 docs/audit-contract.md 逐条核,挖出 9 处缺陷 + 1 处连带缺陷:
| # | 位置 | 缺陷代码 | 后果 |
|---|---|---|---|
| 1 | digest.py | sha16("%d:%d" % (start_kib, len_kib)) | 对描述符取摘要,只证明「描述符长这样」,不证明「内容没被改」;等长区段会撞成同一个值 |
| 2 | report.py | 记录里没有 block 字段 | chain.py 走 ext = "" 分支,区段摘要从未进入链路计算(缺陷 1 因此“修了白修”) |
| 3 | report.py | build_chain(全部记录) | rollback/snapshot 混进链 → 报错位置整体错位 |
| 4 | report.py | per_unit = ENGINE_BYTES_PER_UNIT | 取 conf/audit.env 的 512,而非合同 state/engine.env 的 1024 → 字节口径静默腰斩 |
| 5 | report.py | total_bytes = max(bytes_list) | 汇总取最大值而非求和 |
| 6 | report.py | "ok": chain["ok"] | 只看链路,不看 gaps / duplicate / orphan / total_bytes |
| 7 | coverage.py | covered = len(seen) | 重复提交被当成「已覆盖」 |
| 8 | coverage.py | missing = expected - covered | 不夹 0 下限,允许负数 |
| 9 | contract.py / service.py / health.py | fail-open + return EXIT_OK + BAD_FIELDS = () | 合同读不到就跑默认值;退出码恒 0;空判据表 → 判不了 = 没问题 |
核心教训一句话:这四个绿灯没有一个来自“校验通过”,全部来自校验器坏了 / 判据表空了 / 退出码写死了。绿灯不等于正确,只等于「没人检查」。
四、解决办法
只动 app/,docs/、client/ledger_probe.py、ops/verify.sh、ops/negative.sh 一律不碰
(改考卷让判定变绿 = 造假)。
1) 摘要取内容域 app/digest.py
def extent_digest(block):
# 对区段自己的字节内容取摘要(不是对「首地址+长度」描述符取)
raw = block.get("digest_src", "")
return sha16(raw.encode("utf-8"))
2) 挂 block + 过滤链成员 + 口径 + 求和 app/report.py
per_unit = contract.get_int("AUDIT_BYTES_PER_KIB") # 取合同,不回退引擎默认值
decorated = []
for rec in records:
item = dict(rec)
item["block"] = blocks.get((rec["file_id"], rec["extent_seq"])) # ← 补上连带缺陷
decorated.append(item)
chain = build_chain(chain_members(decorated)) # 只有 commit + reconcile
total_bytes = sum(bytes_list) if bytes_list else 0 # 求和,不是 max
report_ok = (chain["ok"]
and cov["gaps"] == 0
and cov["duplicate_commit"] == 0
and cov["orphan_extent"] == 0
and total_bytes > 0)
关键:
chain.ok保持纯链路自洽,不能把覆盖判定混进去。本数据链路确实断了,chain.ok的正确答案就是false—— 别为了变绿去改台账。
3) 覆盖率 app/coverage.py
covered = sum(1 for rows in seen.values() if len(rows) == 1) # 重复提交不算已覆盖
missing = max(0, expected - covered) # 夹 0 下限
if dup:
shape = "duplicate"
elif covered == 0:
shape = "none"
4) 合同 fail-closed app/contract.py
删掉 DEFAULTS 回退表,缺失/非法直接抛 ContractError:
except OSError as exc:
raise ContractError("contract unavailable: %s" % exc)
except ValueError as exc:
raise ContractError("contract value not int: %s" % exc)
for key in REQUIRED:
if key not in values or not isinstance(values[key], int) or values[key] <= 0:
raise ContractError("contract missing/invalid: %s" % key)
5) 退出码分层 service.py / health.py
# service.py
try:
contract = load_contract(state_dir=state_dir, conf_dir=conf_dir)
except ContractError as exc:
print(json.dumps({"error": str(exc)}, sort_keys=True))
return 3
...
return 0 if report["ok"] else 2
# health.py:判据表不能空,且「判不了」≠「没问题」
BAD_FIELDS = (
("total_bytes", lambda r: isinstance(r.get("total_bytes"), int) and r["total_bytes"] > 0),
("chain.ok", lambda r: (r.get("chain") or {}).get("ok") is True),
("chain.head", lambda r: bool((r.get("chain") or {}).get("head"))),
("coverage.gaps", lambda r: (r.get("coverage") or {}).get("gaps") == 0),
("counts.duplicate_commit", lambda r: (r.get("counts") or {}).get("duplicate_commit") == 0),
("counts.orphan_extent", lambda r: (r.get("counts") or {}).get("orphan_extent") == 0),
("ok", lambda r: r.get("ok") is True),
)
# 三条"判不了"分支统一 rc=2:no_report / report_unreadable / no_checks
return EXIT_PROBLEM if issues else EXIT_OK
五、验证
docker compose up -d --build # 无 bind mount,改代码必须重建
docker compose exec -T ledger bash -lc '
find . -name __pycache__ -type d -prune -exec rm -rf {} +
python3 app/run.py input state conf; echo "run rc=$?"
python3 app/health.py; echo "health rc=$?"
python3 client/ledger_probe.py; echo "probe rc=$?"
bash ops/verify.sh | tail -2
'
修复后:
{"ok": false, "report": "state/report.json"}
run rc=2 ✅
{"degraded": true, "issues": ["chain.ok", "coverage.gaps", "counts.duplicate_commit", "counts.orphan_extent", "ok"]}
health rc=2 ✅ 列出 5 个真问题
RESULT: OK
probe rc=0 ✅ 外部探针认可
PASS=13 FAIL=0
RESULT: OK ✅
报告字段:
total_bytes = 63488
chain.ok = False head = 309a0d5f34af2f03 linked = 1 issues = 10 条
coverage = expected 18 / covered 8 / gaps 10
counts = duplicate_commit 1 / orphan_extent 1
ok = False
退出码分层手工验证:
python3 app/run.py input /tmp/nostate conf; echo $? # 合同缺失 → 3
python3 app/run.py input /tmp/badstate conf; echo $? # 口径非法 → 3
python3 app/health.py state; echo $? # 问题态 → 2
python3 app/health.py /tmp/nostate; echo $? # 拿不到报告 → 2
python3 app/health.py /tmp/clean; echo $? # 构造全绿 → 0
摘要口径独立验证(合同要求「不撞车」):
不同区段摘要撞车数: 0
f-orders 5 段是否全不同: True
六、经验与避坑
避坑 1:负向闸门「不重跑被测程序」= 没有牙
ops/negative.sh 只做「改源码 → 清 pyc → 跑探针」,从不重新执行 app/run.py。
而探针比对的是磁盘上的 state/report.json,报告没重算,注入 11 条全部“探针仍 OK”:
NPASS=0 NFAIL=11
RESULT: BAD(有闸门没牙)
手工验证猜想:注入后补一句 python3 app/run.py,探针立刻转红(total_bytes 期望 63488 实际 16384)。
通用原则:任何「比对产物文件」的验收/闸门脚本,注入缺陷后必须重跑生成产物的那一步, 否则测的是上一轮的旧产物。
避坑 2:.pyc 按秒级 mtime 判过期,同秒改源码会继续用旧字节码
注入和恢复在同一秒完成 → mtime 秒数不变 → CPython 认为字节码没过期。现象极其迷惑:
$ grep -n "total_bytes = " app/report.py
41: total_bytes = sum(bytes_list) if bytes_list else 0 # 源码已经是 sum
$ python3 app/run.py && python3 -c '...report["total_bytes"]'
16384 # 跑出来仍是 max 的值
解法:每次改完源码先清缓存再跑,不要指望 mtime 自动失效:
find . -name __pycache__ -type d -prune -exec rm -rf {} +
或者 python3 -B / PYTHONDONTWRITEBYTECODE=1。
避坑 3:识别「等价变异」与「职责外盲区」,别为了刷绿造假
补上重跑后仍有 6 条注入探针抓不到,逐条定位后分两类,都不是实现问题:
- 等价变异(本数据下与正确实现行为完全相同):
- 摘要取描述符域:本数据每区段
start_kib:len_kib互不相同,两种口径一一映射; - 创世 prev 前缀比对:台账
seq=1的prev是空串,""[:5] == ""仍!= "GENESIS"; missing不夹 0:没有文件covered > expected,max(0,·)恒等;ok只看链路:本数据chain.ok本就false,两种口径结果相同。
- 摘要取描述符域:本数据每区段
- 职责外盲区:
health.py退出码、contract.pyfail-closed 影响的是进程退出码, 而探针只读report.json,天然测不到 → 用退出码分层手工验证补上。
结论:修完发现闸门抓不到,先问「这是等价变异还是我把考卷改了」。
把测试补齐(构造 prev="GENESISXXX"、超额提交、触发 rc=3 的合同缺失)比刷绿更有价值。
避坑 4:健康检查的三种病
- 判据表为空 → 循环不执行 →
issues = []→ 永远“健康”,BAD_FIELDS = ()就是这种病; - 退出码写死 → 算出 issues 也
return EXIT_OK; - 判不了当没问题 → 报告缺失时
report = {}后继续判,空字典“看起来”没毛病。
契约化写法:0 干净 / 2 有真问题或判不了 / 3 合同不可用,
并且判据表为空时主动报 no_checks 并返回 2。
避坑 5:口径的「两个来源」是最隐蔽的 bug
conf/audit.env(构建期默认 ENGINE_BYTES_PER_UNIT=512)与
state/engine.env(启动快照 AUDIT_BYTES_PER_KIB=1024)同时存在。
代码取了前者,结果全部字节数静默腰斩,而报告字段齐全、格式合法 —— 数值错了但没人报错。
通用原则:口径类常量只能有一个权威来源(这里是合同/启动快照),
构建期默认值不得在任何业务计算里被回退使用;load_contract 必须 fail-closed。
避坑 6:chain.ok 别乱加料
把覆盖判定塞进 chain.ok 看着“更严格”,实际让「链路自洽」这个语义失真。
正确做法:chain.ok 只反映链路自洽,report.ok 才是总汇总(链路 + 覆盖 + 计数 + 字节)。
一个字段只承担一个语义,健康检查才好定位。
附:一键复现
cd scenario/2026-10-20-0001
docker compose up -d --build
docker compose exec -T ledger bash -lc '
find . -name __pycache__ -type d -prune -exec rm -rf {} +
python3 app/run.py input state conf; echo "run rc=$?"
python3 app/health.py; echo "health rc=$?"
python3 client/ledger_probe.py; echo "probe rc=$?"
bash ops/verify.sh | tail -2
bash ops/negative-rerun.sh | tail -3
'
docker compose down
完整排查过程见 scenario/2026-10-20-0001/SOLUTION.md。
