牛马折腾日记 002:把 security-monitor 从 23 处硬编码升级到 8 个 env var
引子
上周做了一次 phase 28 的全量 audit,我盯着 grep 出来的 23 处 `/var/lib/security-monitor/...` 字面路径,整整发了三分钟呆。
原因不是这 23 处写得多离谱——说实话,每个都是必要的。问题是它们完全没法在测试环境里跑。每次跑 pytest,某个 `config_manager.py` 实例化都会真的去 `/var/lib/security-monitor` 写文件,测试机和部署机抢同一份 state,定位半个下午才搞明白是路径污染。
升级目标也很简单:把这些路径全部 env var 化。`SECURITY_MONITOR_STATE=/tmp/test` 一设,测试机跑到天荒地老也不会碰到部署机的事。
听起来简单,对吧?
实际上从周三晚上开工到周六早上发车,干了整整三天,中间把 `BaseDetector` 的 `state_file` 派生逻辑重写了两遍、`config.py` 的 dataclass 字段默认值重新设计了 18 个,最后还差点把所有测试搞挂。下面是这次踩坑的全过程。
问题:23 处硬编码 + 部署契约不动
先看战况——`grep -n '/var/lib/security-monitor' src/*/.py | wc -l` 给我的数字是 23:
$ grep -rn 'Path\(.var.\)' src/ | wc -l
23
$ grep -rn 'Path.*/var.*security' src/ | head -5
src/event_store.py:17:STATE_DIR = '/var/lib/security-monitor'
src/ban_manager.py:208:self._cycle_banned_file = Path('/var/lib/security-monitor/ban_cycle_state.json')
src/export.py:97:history_file = Path('/var/lib/security-monitor/ban_history.json')
src/detector/base.py:86:self.state_file = f"/var/lib/security-monitor/{self.__class__.__name__}_state.json"
src/config.py:369:export_dir: str = '/var/lib/security-monitor/exports'
为什么这些路径之前没改?因为这是部署契约。`/var/lib/security-monitor` 是 systemd unit 安装时创的固定路径,hardcode 是设计选择,不是疏忽。我要做的是在这之上加一层 env var override,不能改默认值。
第一反应(往往是错的):直接每个文件改成 `Path(os.environ.get('SECURITY_MONITOR_STATE', '/var/lib/security-monitor'))`。五秒钟搞定,23 处全部粘一遍完事。
但等等——`config.py` 第 28 行有个 `os.makedirs(STATE_DIR, exist_ok=True)` 的副作用:
for _dir in [STATE_DIR, CACHE_DIR, LOG_DIR]:
os.makedirs(_dir, exist_ok=True)
这就是个坑。`config.py` 一被 import 就去创建 `/var/lib/security-monitor`,即使你没用到任何 config 函数。测试时设 `SECURITY_MONITOR_STATE=/tmp/x` 也救不了——`config.py` 自己还没读到 env var。
调查:3 个不同位置的硬编码分别要怎么处理
我花了周四一整天梳理,发现这 23 处其实分 3 类,处理方式完全不同:
第一类:module-level 常量(14 处)。`event_store.py`、`health.py`、`threat_intel.py` 等 14 个文件,文件顶部直接 `STATE_DIR = '/var/lib/security-monitor'`。这 14 个最简单:
import os
STATE_DIR = os.environ.get('SECURITY_MONITOR_STATE', '/var/lib/security-monitor')
一行替换完事,部署契约默认 `/var/lib/security-monitor` 不动,测试时设 env var 就走隔离目录。
第二类:class-level lazy init(4 处)。`ban_manager.py` 里 `BanManager._load_cycle_banned` 这种方法体里:
self._cycle_banned_file = Path("/var/lib/security-monitor/ban_cycle_state.json")
这种第一次看上去可以替换成 `Path(STATE_DIR) / "ban_cycle_state.json"`,但有个微妙问题——`STATE_DIR` 是 module-level 常量,要从 `config` import。直接 `from config import STATE_DIR` 在文件顶部会触发那个 `os.makedirs` 副作用。
我第一版就这么写,pytest 上来就 `PermissionError: '/var/lib/security-monitor'`。调了半天以为是测试隔离问题,最后才发现是 `ban_manager.py` 顶部 import 链把 `config.py` 拽进来,`config.py` 第 28 行硬是要建部署路径。
解决:在方法体内 lazy import:
def _load_cycle_banned(self):
from config import STATE_DIR
if self._cycle_banned_file is None:
self._cycle_banned_file = Path(STATE_DIR) / "ban_cycle_state.json"
...
不影响别人 import `ban_manager.py`,副作用只在实际调到这个方法时才发生。
第三类:dataclass 字段默认值(5 处)。这是最恶心的——`config.py` 里的 `SystemConfig` dataclass:
@dataclass
class SystemConfig:
export_dir: str = '/var/lib/security-monitor/exports' # 默认值是字面
whitelist_file: str = '/var/lib/security-monitor/whitelist.json'
backup_dir: str = '/var/lib/security-monitor/config_backups'
state_dir: str = '/var/lib/security-monitor' # 这个字段压根没定义
注意最后那个 `state_dir`——之前根本没这个字段。`BaseDetector.__init__` 想读 `self.config.system.state_dir`,getattr 兜底永远是字面 `/var/lib/security-monitor`。属于长期死代码。
dataclass 默认值要 env var 化,Python 没原生支持。dataclass 字段默认值是在 class 创建时执行一次,不是实例化时。所以:
@dataclass
class SystemConfig:
_ENV_STATE = os.environ.get('SECURITY_MONITOR_STATE', '/var/lib/security-monitor')
export_dir: str = os.environ.get('SECURITY_MONITOR_EXPORT_DIR', f'{_ENV_STATE}/exports')
whitelist_file: str = os.environ.get('SECURITY_MONITOR_WHITELIST_FILE', f'{_ENV_STATE}/whitelist.json')
backup_dir: str = os.environ.get('SECURITY_MONITOR_BACKUP_DIR', f'{_ENV_STATE}/config_backups')
state_dir: str = _ENV_STATE
唯一问题:只支持 module-level env var 覆盖。yaml 配置了 `monitoring.export_dir: /custom/path` 时会覆盖字段,但 env var 必须在 `config.py` 被 import 之前就设好。我自己的 pytest helper 没问题(subprocess 隔离 env),但如果是全局测试会话里串行调起来就麻烦。
解决:把 `state_dir` 字段补全 + lazy import 模板
最终提交是 8 个 commit,分两条线:
主线 commit 序列:
- `fd26cfc` — 14 处 module-level STATE_DIR 改 env var(一个文件一行)
- `200c311` — ban_manager 4 处 lazy init 路径 → STATE_DIR 派生
- `4ee42b3` — export._collect_records 硬路径 → STATE_DIR 派生
- `b060017` — BaseDetector state_file / lock_dir fallback + SystemConfig 字段全 env var 化
- `502cc47` — ITERATIONS / README / AUDIT_FIX_SUMMARY 三份文档同步
关键 diff(BaseDetector 部分):
class BaseDetector(ABC):
def __init__(self, config):
self.config = config
self.logger = logging.getLogger(self.__class__.__name__)
# Phase 29.A.4: 优先 config.system.state_dir (29.A.5 字段化),
# fallback 到 module-level STATE_DIR (29.A.1 env var 化)
_state_dir = '/var/lib/security-monitor'
try:
if hasattr(self, 'config') and hasattr(self.config, 'system') and self.config.system:
_state_dir = getattr(self.config.system, 'state_dir', _state_dir) or _state_dir
except Exception:
pass
self.state_file = f"{_state_dir}/{self.__class__.__name__}_state.json"
看着简单是不是?前两版我直接 `self.state_file = Path(STATE_DIR) / ...`,结果 `from config import STATE_DIR` 又踩 `os.makedirs` 副作用。第三版才改成"先看 config,有就拿 config 的,没有就 fallback 到字面",完全避免 module-level import。
验证:8 个 env var + 41 个新测试 + 0 回归
跑完 git push,我用了一行 Python 验证所有 8 个 env var 都生效:
import os
os.environ.update({
'SECURITY_MONITOR_STATE': '/tmp/test/state',
'SECURITY_MONITOR_CACHE': '/tmp/test/cache',
'SECURITY_MONITOR_LOG': '/tmp/test/log',
'SECURITY_MONITOR_HOME': '/tmp/test/home',
'SECURITY_MONITOR_CONFIG': '/tmp/test/home/config.yaml',
})
import event_store, health, threat_intel, config
print('event_store.STATE_DIR:', event_store.STATE_DIR) # /tmp/test/state
print('health.CACHE_DIR:', health.CACHE_DIR) # /tmp/test/cache
print('SystemConfig.export_dir:', config.SystemConfig().export_dir) # /tmp/test/state/exports
全输出 `/tmp/test/...`。意味着测试机设 env var 就完全隔离,再不会和部署机抢 state 文件了。
跑全量 pytest:379 passed + 1 pre-existing failed(lazy_import 环境差异),基线 338 → +41 测试,0 真正回归。
但还有最后一个坑——之前我自己写的 test helper 用 `/tmp/honey_test` 固定路径 + tname 子目录隔离,单跑 10/10 PASS,全量套件跑就 4/10 fail。原因是 `BaseDetector.__init__` 写 state_file 到 `self.config.system.state_dir` 派生的目录,但前一个测试已经把那个路径的 state 文件写过一轮,第二次 `last_position` 还卡在前面。
修法是把 helper 改成 `tmp_path` fixture + uuid 唯一子目录,每个测试函数独立隔离。这事当时我调了一个半小时,写出来一篇 testing pitfall 备忘。
反思:3 个反直觉的洞察
第一,env var 化最恶心的是副作用。本来以为就是替换字面,结果 `config.py` 的 `makedirs`、`from config import STATE_DIR` 这些副作用会在你意想不到的地方炸。下次任何有"导入即副作用"的 module 都要先列个清单,把"lazy import 模板"准备好。
第二,dataclass 字段默认值不能用 env var 动态改。这是 Python dataclass 设计本身的限制——字段默认值在 class 创建时固化,不是实例化时。要支持 env var 化,要么走 "field with default_factory + factory 函数读 env",要么走"module-level 常量 + 类属性赋默认值"。我选了后者,简单且向后兼容 yaml 覆盖。
第三,测试隔离是设计问题不是工具问题。我以前一直觉得"用 tmp_path fixture 就够了",结果栽在"多个测试共享同一 fixture 时 state 文件路径也跟着共享"上。真隔离 = 唯一路径 + 唯一 fixture。这次我所有 test helper 都用 `tmp_path / tname / uuid4()` 三层前缀,彻底告别路径污染。
整体耗时:三天(周三晚到周六早)、8 个 commit、41 个新测试、8 个新 env var、23 处硬编码清零。
下一个 phase 29 的目标:把"测试用的临时蜜罐路径"也集成到 honeypot.detector 里,让用户能 dry-run 验证自己的诱饵路径生效。这事等周三继续折腾。
附赠踩坑:HoneypotDetector 的 test helper 翻车实录
既然写到 env var 化和测试隔离,就顺手把这次同步上线的 HoneypotDetector(第 29.B 期)test helper 的事也讲讲——是同一组问题在另一个测试套件里爆发的另一面镜子。
事件是这样的。周六早上发完 8 个 commit,我开始落地 Phase 29.B HoneypotDetector。新 detector 走 `BaseDetector._read_log_file` 增量读 nginx access log,里面用 `self.state[f'{log_path}_pos']` 持久化 `last_position` 到 state dict。这套设计没问题,但写测试时第一个版本我图省事,helper 直接硬编码:
def _isolated_env():
return {
**os.environ,
"SECURITY_MONITOR_STATE": "/tmp/honey_test/state", # 5 个测试共享
...
}
单跑 `pytest tests/test_phase29_b_honeypot_detector.py`:10/10 全 PASS。我高兴地发出去,继续干别的。
晚上 11 点准备睡了,手贱跑了一次全量:
$ pytest tests/ --tb=no -q
================= 5 failed, 375 passed in 6.32s ==================
FAILED tests/test_phase29_b_honeypot_detector.py::test_dynamic_honeypot_skips_whitelist_and_private
FAILED tests/test_phase29_b_honeypot_detector.py::test_dynamic_honeypot_custom_path_in_yaml
全量跑出来 5 个失败。单跑不 fail、全量 fail,这是什么灵异现象?
我盯着 traceback 看了十分钟:
state = {
"/tmp/honey_test/incr/access.log_pos": 1024, # ← 上一轮 incr 测试留下的
"/tmp/honey_test/hit/access.log_pos": 8192, # ← 上一轮 hit 测试留下的
}
真相:单跑测试时,`hits_matched_path` 跑完写 state 进 `/tmp/honey_test/state/HoneypotDetector_state.json`,`last_position=8192` 落盘。下次 `private` 测试跑,state dict 还是那个文件,被磁盘加载到内存,但 `private` 测试的 access log 是个全新文件——结果 `_read_log_file` 读新 log 文件时,`f.seek(last_position)` 用的是上一次 log 的 position 8192——直接越界读到空,新 log 里所有 honeypot 路径访问都"看不到",输出 0 事件。
修法:每个测试函数自己开隔离目录,用 `tmp_path` fixture + uuid 后缀:
def _run_with_config_and_log(cfg, log, tname, tmp_path):
if tmp_path is None:
import tempfile
tmp_path = Path(tempfile.mkdtemp(prefix=f"honey_{tname}_"))
else:
import uuid
tmp_path = Path(tmp_path) / tname / uuid.uuid4().hex[:8] # 三层隔离
base = tmp_path
(base / "state").mkdir(parents=True, exist_ok=True)
# ... env var 全指向 tname 自己的子目录
三层前缀 `tmp_path / tname / uuid4()` 保证每个测试函数、每个调用、甚至同一测试函数内多次调用都拿独立 state 文件。这事我调了整整一个半小时才彻底搞干净,最后跑了三次全量:
$ pytest tests/ --tb=no -q
1 failed, 379 passed, 5 warnings in 6.46s ← pre-existing lazy_import
$ pytest tests/ --tb=no -q
1 failed, 379 passed, 5 warnings in 6.51s
$ pytest tests/ --tb=no -q
1 failed, 379 passed, 5 warnings in 6.50s
连续三次稳定 379 passed + 1 pre-existing failed。这次不是靠运气,是真隔离。
教训和主线一样:helper function 是测试基础设施的第一公民。你写测试不写 helper 是懒惰,写了 helper 不验证它在全量套件里的表现是无知。下次写新 detector 的 test,第一件事是把 `_isolated_env()` 加上 `tmp_path` 参数和 uuid 后缀,别再让自己栽在同样的坑里。
整体收尾:三天(周三晚到周六晚)、9 个 commit(多了一个 honeypot detector + test helper fix)、51 个新测试、23 处硬编码清零、1 个新 detector 上线。
下一个 phase 30 的目标还是把"测试用的临时蜜罐路径"集成到 honeypot.detector 里——这回我希望 test helper 一开始就按这个标准写,省得再花一个半小时。
本文为虚构故事,技术细节基于 security-monitor 实际开源 commit(fd26cfc / 200c311 / 4ee42b3 / b060017 / aba0cd3)。文中出现的所有代码片段均可在 https://github.com/yy-niuma/security-monitor 仓库对应 commit 找到。
评论区