策略下发成功但业务行为不变:判定代码里写死的阈值压掉了配置/变更单策略(魔法数字、配置读错来源、静默吞异常、生效来源不可追溯)
日期:2026-10-06 · 分支:
2026-10-06-1600· 第108题 技术栈:Python 3.11 · Docker Compose · 配置中心 / 策略下发 / 变更单落地 / 热生效 关键词:配置下发成功但行为不变 · 判定代码里写死的阈值 · 魔法数字 magic number · 配置读错来源 · 本地副本 vs 权威源 conflating · 静默吞异常 except pass · 生效来源不可追溯 · 策略热生效失效 · 健康检查假绿 · 退出码恒 0 · fail-closed ·NameError被 except 吃掉 · 自述字段自证 ·BAD_FIELDS = ()空判据表
一、问题背景
一条访问审计策略链路:契约 conf/audit.env + 变更单登记表 conf/orders.env 是唯一权威来源,
app/collector.py 把策略收集成「已下发的策略清单」state/policy-manifest.conf
(每行 <策略键>=<生效值> + <策略键>.source=<生效来源>),
app/audit.py 按清单里的阈值判定告警/拦截,app/health.py 给健康结论与退出码。
变更单 CHG-2211 在 2026-10-05 22:40 把拦截阈值从 300 秒收紧到 180 秒:
CHG-2211|block_threshold_seconds|180|2026-10-05T22:40:00
policy.d/60-threshold.conf 里还留着 set block_threshold_seconds 300 的手工改动痕迹。
现象:下发「成功」、审计「跑完了」、健康检查全绿、退出码 0, 可审计引擎的判定行为与变更前一模一样 —— 改的是策略,跑的还是老数字:
== 1. 跑一轮下发 + 审计(8 个来源) ==
render 完成 清单=state/policy-manifest.conf ok=True
round OK 来源=8 引擎加载=True 告警=0 拦截=0
清单值 = {"values": {"warning_max_per_minute": "120", "block_threshold_seconds": "180"},
"source": {"warning_max_per_minute": "{base_file}",
"block_threshold_seconds": "{base_file}"}}
demo.py 退出码 = 0
== 5. 健康检查 ==
{"degraded": false, "issues": [], "self_consistent": true, "ok": true}
health 退出码 = 0 ← 判据表为空 + 退出码恒 0
== 6. 外部独立探针 ==
清单来源 = {"block_threshold_seconds": "{base_file}", "warning_max_per_minute": "{base_file}"}
不一致 / 无来源的键数 = 2
RESULT: BAD ← 唯一说真话的是它
探针退出码 = 1
四个绿灯没有一个指向真相:「跑完了」只证明进程没崩、「自述全绿」是自己写给自己看的、
「degraded: false」来自一张空判据表、退出码 0 来自无条件 return 0。
二、复现步骤
cd scenario/2026-10-06-1600
# 方式 A:本机直接跑(只需 python3,全部标准库)
bash ops/repro.sh # 一键复现:下发 → 引擎行为 → 自述 → 健康检查 → 外部探针
bash ops/verify.sh # 验收:问题态 PASS=4 FAIL=5 / RESULT: BAD
# 方式 B:docker 一键
docker compose up -d --build
docker compose exec -T auditctl bash ops/repro.sh # 非交互环境必须加 -T
docker compose exec -T auditctl bash ops/verify.sh
⚠️ 本次仓库初始状态误为参考答案(
app/里已经是修好的代码,跑出来直接PASS=9 FAIL=0)。 先bash ops/apply_fix.sh --restore打回问题态再复现。
决定性取证:把清单阈值从 180 改成 300,看引擎的「首次拦截 tick」有没有变:
问题态 清单 block=180 -> 首次拦截 tick = [799]
问题态 清单 block=300 -> 首次拦截 tick = [799] ← 一模一样,阈值写死了
799 = 500 + 300 - 1,正是 conf/pol-base.conf 基线(500/300)的值 ——
说明引擎每一轮都在按基线判断,清单里的 120/180 完全没被用上。
问题态验收红灯 5 条:
[FAIL] 生效来源不可追溯的键数(0=全部可追溯) (期望=0 实际=2)
[FAIL] 改清单后首次拦截 tick 未跟着变(yes=阈值写死了) (期望=no 实际=yes)
[FAIL] 清单缺失时 health 退出码(非 0=抓到) (期望=2 实际=0)
[FAIL] 删掉 conf/collector.env 后 health 退出码 (期望=3 实际=0)
[FAIL] 外部探针判定 (期望=OK 实际=BAD)
PASS=4 FAIL=5 / RESULT: BAD
三、根本原因
四处缺陷叠加,全都不抛异常(正是「静默降级」的教科书形态):
① 题眼:判定代码里写死的阈值。app/audit.py::evaluate() 读完了清单、
把阈值存进了 self.warning_max_per_minute / self.block_threshold_seconds,
判定时用的却是写在函数体里的数字字面量:
if tick < 500: # ← 应该是 self.warning_max_per_minute
return ACTION_ALLOW
if tick - 500 + 1 >= 300: # ← 应该是 self.block_threshold_seconds
return ACTION_BLOCK
于是「读到的策略」与「真正生效的策略」是两套。改策略、审计行为一点都不变。
② 配置读错来源。app/collector.py::_render() 里契约给的 collector["base_file"]
(pol-base.conf)被原地丢掉:
_ = collector["base_file"] # ← 契约给的唯一来源被抛弃
BASE_CANDIDATES = ("conf/base.conf", "base.conf") # ← 改挑本地副本
恰好那份运维 2026-09-14 手工转存的本地副本 conf/base.conf 存在且能解析成功,
流程于是继续往下走。这份副本还可能是共享挂载的:a 服务改了策略文件、b 服务读自己的副本,
谁都不知道自己看的是哪一份。
③ 解析失败被静默吞掉。except policy.PolicyError: base = {} ——
「解析不了」被判成了「这一段没有」,继续按剩下的值渲染。
④ 生效来源被写死。每个键的 .source 写成 "{base_file}"(一个未定义变量的
f-string 表达式,NameError 又被同一个 except 吃掉):
try:
lines.append("%s.source=%s" % (key, base["source"][key]))
except Exception: # ← 所有异常一锅端
lines.append("%s.source=%s" % (key, "{base_file}")) # ← 写死 + NameError
于是**「这个值是从哪来的」永远无从回答** —— 审计日志里挑不出一条完整的配置溯源链。
再叠上 render() 无条件 return True(渲染恒成功)、
rendered_ok / rounds_verified / self_consistent 全是写死的常量,
以及 app/health.py 的 BAD_FIELDS = ()(空判据表,for 一次都不循环)
main()无条件return EXIT_OK(退出码恒 0)。
为什么难查:
① 清单里的值是对的(block_threshold_seconds=180),照清单看没毛病,
只有真去跑一条新来源才能发现引擎用的是老数字;
② 没有对照物 —— 告警=0 拦截=0 既像「这轮流量很干净」也像「阈值调对了」,两种解释都自洽;
③ 本地副本 conf/base.conf 与契约登记的那一份长得一模一样、都能解析成功,读错也不报错;
④ 「渲染成功」是被 return True 写死的,渲染器到底产出过什么无人核对。
四、解决办法
只改 app/audit.py、app/collector.py、app/health.py 的逻辑,
conf/、policy.d/、client/audit_probe.py、ops/*.sh 一字未动。
1)阈值只来自清单,判定里零数字字面量(app/audit.py):
def evaluate(self, src: str, tick: int) -> str:
if tick < self.warning_max_per_minute:
return ACTION_ALLOW
warn_at = self.warning_max_per_minute
if tick - warn_at + 1 >= self.block_threshold_seconds:
return ACTION_BLOCK
return ACTION_WARN
2)基线文件只认契约,解析失败大声失败,来源可追溯(app/collector.py):
base_file = collector["base_file"] # 唯一来源就是契约
base_path = os.path.join(conf_dir, base_file)
try:
base = policy.read_policy_file(base_path)
except policy.PolicyError as exc:
raise CollectorError("契约登记的基线 %s 不可用:%s" % (base_file, exc)) from exc
...
for key in policy.POLICY_KEYS:
lines.append("%s.source=%s" % (key, effective["source"][key])) # 真实来源
3)渲染成功 = 清单真的能被消费方加载(不再无条件 return True):
os.replace(tmp, out_path)
try:
engine = audit.AuditEngine(out_path) # 消费方(审计引擎)能不能加载
except policy.PolicyError as exc:
raise CollectorError("下发的清单无法被审计引擎加载:%s" % exc) from exc
render() 顶层把 CollectorError 映射成 return 2(非 0 退出)。
4)健康检查有牙 + 退出码分层(app/health.py,由 33 行重写为 169 行):
- 非空判据表
BAD_FIELDS = (("ok", True), ("rounds_total", None)); - 四类判据全部落在可外部独立复核的事实上,不读被测方自述:
① 清单能被审计引擎加载;
② 清单值 == 「基线 + 变更单登记表」叠出来的生效值;
③ 每个键
.source与真实来源一致、不是占位符/空值; ④ 按清单阈值算出的告警/拦截区间 == 按真值阈值算出的区间 —— 少了这条,evaluate()里写死的数字就抓不出来; - 拿不到数据 = 判不了 = 不通过(fail-closed):清单缺失 / 报告缺失 / 契约缺失都非 0 退出;
- 退出码分层:
0正常 /2有真问题或拿不到数据 /3契约不可用或自相矛盾。
五、验证
docker compose exec -T auditctl bash ops/verify.sh
# PASS=4 FAIL=5 / RESULT: BAD → PASS=9 FAIL=0 / RESULT: OK
题眼对照实验(修好后行为真的跟着清单走):
== E. 题眼:审计引擎的实际行为必须跟着清单走 ==
{"before": 299, "after": 426, "expected": 426}
[PASS] 改清单后首次拦截 tick 未跟着变(yes=阈值写死了) (期望=no 实际=no)
清单 block=300 → 首次拦截 tick = [419]
清单 block=180 → 首次拦截 tick = [299] ← 跟着变了
ops/repro.sh 全链路修复后:
== 1 == round OK 告警=1432 拦截=816 demo.py 退出码 = 0
清单来源 = {"warning_max_per_minute": "contract:audit.env",
"block_threshold_seconds": "order:CHG-2211"} ← 真实可追溯
== 5 == {"degraded": false, "issues": [], "issue_count": 0, "self_consistent": true} 退出码 0
== 6 == 不一致 / 无来源的键数 = 0 RESULT: OK 探针退出码 = 0
负向验证 6 组(证明闸门真的有牙):bash ops/negative.sh
| 注入的缺陷 | 结果 |
|---|---|
A evaluate() 阈值改回写死 500/300 | PASS=8 FAIL=1 / RESULT: BAD(E 组立刻转红) |
| B 判据表清空、不读清单/契约 | 清单来源是占位符,health 退出码 0(闸门失效);外部探针仍 BAD |
| C 基线来源改回本地副本 + 来源退回占位符 | health 退出码 2、外部探针 退出码 1 / BAD |
D 契约基线内容非法(warning_max_per_minute=abc) | demo.py 退出码 2 + [FATAL] 清单渲染失败:契约登记的基线 pol-base.conf 不可用 |
E 删掉 conf/collector.env | demo.py 退出码 3 + [FATAL] 契约不可用:契约文件不存在 |
| F 登记表出现未知策略键 | demo.py 退出码 3 + [FATAL] 契约不可用:登记表出现未知策略键:'unknown_key' |
六、经验与避坑
核心认知
- 「读了配置」和「按配置干活」是两件事。最狠的检查:把配置改成另一组, 看行为会不会跟着变。判定函数里只要出现与配置项语义相同的数字字面量,就是第二套口径来源。
- 写死的数字是最隐蔽的配置来源。它不报错、不打日志、无从追溯, 且与代码里「变量形式的正确写法」只差几个字符。
- 健康检查的判据要落在能被外部独立复核的事实上。
self_consistent: true手写常量、issues: []手写空清单、BAD_FIELDS = ()空判据表、return 0恒定退出码 —— 这类「自述字段自证」比没有检查更危险,因为它静默地骗人。 - 「判不了」≠「没问题」(fail-closed)。契约缺失 / 文件解析失败 / 清单缺失时的默认动作 必须是拒绝(非 0 退出),不是「跳过、按剩下的值继续」。
踩坑记录
- 仓库初始状态误为参考答案。
git show HEAD:...app/audit.py里evaluate()用的 已经是self.warning_max_per_minute,直接跑verify.sh得PASS=9 FAIL=0,复现不出问题。 → 先bash ops/apply_fix.sh --restore打回问题态再复现。 (诊断技巧:diff app/X.py ops/problem-state/X.py/ops/expected-fixed/X.py立刻能看出当前处于哪一态。) ops/negative.sh会覆盖你自己的修复。它开头restore()与收尾都cp ops/expected-fixed/*.py app/—— 跑完负向验证后app/里已经不是你的解法了。 → 自研修复版本另存一份,跑完负向验证后重新写回app/再提交。docker compose exec在非交互流水线里必须加-T,否则报the input device is not a TTY。- 切换修复态/问题态前清
app/__pycache__,旧字节码会掩盖改动。 state/、reports/是 bind mount 的运行期产物且已被 gitignore, 提交前git status确认只有预期的 3 个app/*.py改动。- manually 做对照实验比读代码更快定位题眼:把清单阈值改一组,
看「首次拦截 tick」有没有变 —— 一行命令就能证明阈值写死了,还能反推出写死的那组值
(
799 = 500 + 300 - 1)。
自查三问
- 你的「策略 / 阈值 / 开关」读进来之后,判定代码里用的是变量,还是又写了一遍数字?
- 你读的配置文件,是契约登记的那一份,还是旁边一份「看起来一样」的副本
(
conf/pol-base.confvsconf/base.conf、application-local.yml、 K8s ConfigMap vs 烤进镜像的那份)?读错来源时会不会报错? - 你的
except Exception:/except: pass后面把「解析失败」降级成了什么? 「这个值是从哪来的」(生效来源)在日志/清单里说得出吗? 健康检查的判据表是不是空的?main()是不是无条件return 0?判不了时是放行还是拒绝?
同类影响面
- 一切「配置中心 / 策略下发 / 特性开关」:Apollo / Nacos / Consul / etcd 下发成功
但业务侧判定仍用代码里的默认值;K8s ConfigMap 挂了但进程读的是内置默认;
/etc/security/limits.conf改了、systemd unit 里却写死LimitNOFILE= - 一切「魔法数字」:超时时间、限流阈值、重试次数、批大小、缓冲区大小在代码里又写了一遍; 改了配置/环境变量行为不变的静默故障
- 一切「配置读错来源」:本地副本 vs 权威源、
-local后缀的覆盖文件、 容器里烤进镜像的那份 vs 挂载进来的那份、多级 merge 把权威来源压掉 - 一切「静默吞异常」:
except Exception: pass/except: return {}/or 默认值掩盖配置缺失 —— 把「判不了」降级成「没问题」 - 一切「生效来源不可追溯」:审计日志说不出「这条规则是谁改的」、灰度开关说不出生效时间、 合规场景里无法回答「当时用的是哪个策略版本」
- 一切「自述字段自证」的健康检查:
self_consistent: true手写常量、issues: []手写空清单、BAD_FIELDS = ()空判据表、return 0恒定退出码
