朕喜技术(ZhenxiJS)
2026.10.20
1 分钟阅读

哈希链校验「报错位置全错位」+ 字节口径被静默换成半额:审计账单 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 个矛盾:

  1. report.json 里 total_bytes = 8192,但台账实际提交的字节数远超这个数;
  2. chain.ok = false,但报错位置全部错位,指向 prev_mismatch@4、digest_mismatch@5 这些根本不参与链的 rollback / snapshot 记录;
  3. counts.orphan_extent = 0,但台账明明有「只被回滚/快照碰过、从未提交」的区段;
  4. 重复提交的区段被算成「已覆盖」,coverage.gaps 少了 1;
  5. 更糟的是四个绿灯: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 处连带缺陷:

#位置缺陷代码后果
1digest.pysha16("%d:%d" % (start_kib, len_kib))对描述符取摘要,只证明「描述符长这样」,不证明「内容没被改」;等长区段会撞成同一个值
2report.py记录里没有 block 字段chain.py 走 ext = "" 分支,区段摘要从未进入链路计算(缺陷 1 因此“修了白修”)
3report.pybuild_chain(全部记录)rollback/snapshot 混进链 → 报错位置整体错位
4report.pyper_unit = ENGINE_BYTES_PER_UNIT取 conf/audit.env 的 512,而非合同 state/engine.env 的 1024 → 字节口径静默腰斩
5report.pytotal_bytes = max(bytes_list)汇总取最大值而非求和
6report.py"ok": chain["ok"]只看链路,不看 gaps / duplicate / orphan / total_bytes
7coverage.pycovered = len(seen)重复提交被当成「已覆盖」
8coverage.pymissing = expected - covered不夹 0 下限,允许负数
9contract.py / service.py / health.pyfail-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.py fail-closed 影响的是进程退出码, 而探针只读 report.json,天然测不到 → 用退出码分层手工验证补上。

结论:修完发现闸门抓不到,先问「这是等价变异还是我把考卷改了」。 把测试补齐(构造 prev="GENESISXXX"、超额提交、触发 rc=3 的合同缺失)比刷绿更有价值。

避坑 4:健康检查的三种病

  1. 判据表为空 → 循环不执行 → issues = [] → 永远“健康”,BAD_FIELDS = () 就是这种病;
  2. 退出码写死 → 算出 issues 也 return EXIT_OK;
  3. 判不了当没问题 → 报告缺失时 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。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-10-20-0001

分享这篇文章