
产品思维(二):安全工程 — 不偏执的纵深防御
如何把安全内建到系统本身——pre-commit hook、原子守卫、双层防火墙,以及自动化防御的艺术。
消失的安全感#
安全曾经在我的认知里是附加物:发版前的清单、每季度一次的渗透测试、标题里写着"安全"的代码审查。后来我意识到这个思路是错的。过去两年构建的系统给了我另一个答案:最好的安全是你已经忘记它存在的安全——因为它早就织进了系统本身。

这种转变是渐进的。早期独立开发者阶段,同时管理四台生产服务器和十六个跨供应商的 API key,我依赖记忆和自律撑着。提醒自己:“不要提交那个 key。““IP 变了记得检查防火墙。““每次发版前手动审查支付流程。“每一条单独看都合理。合在一起,是不可持续的认知负担,注定迟早失手。记忆在压力下会退化,系统结构不会。
这篇文章不谈抽象的威胁模型。要讲的是六个生产环境里实际遇到的安全问题:暴露它们的事件、做的诊断、以及为了让它们永远不再发生而建造的防御机制。贯穿其中的是一条主线:把防御自动化,然后让它隐形。
1. Pre-Commit 秘钥守卫:一个 Hook 拦住所有泄漏#
事件#
起因是一次险些酿成事故的操作。测试配置文件里加了一个 DashScope API key,随手 git add .,写了提交信息,按下回车。Key 进了提交历史。在 git log 复查时发现了它——纯属运气。下一次可能就没这么幸运。
问题规模让人肉警觉不现实:四台生产服务器、十六个跨供应商 API key(阿里云 LTAI...、DashScope/OpenAI sk-...、腾讯云 AKID...、AWS AKIA...、GitHub ghp_...),以及多个代码仓库。问题不是"会不会泄漏”,而是"什么时候”。
方案#
写了一个 Python pre-commit hook,在每次提交前扫描暂存区 diff 里的凭证模式:
| |
裸正则会把每一篇含示例 key 的教程都标红。关键补充是一套启发式误报过滤器:
| |
Hook 还会跳过已知安全的路径:package-lock.json、构建输出目录(/public/、/dist/、/.next/)、Hugo 已审核内容的部署克隆。
安装方式:通过 core.hooksPath(git >= 2.9)全局安装,自动应用于机器上的所有仓库。旧版本 git 的服务器通过 init.templateDir 注入并遍历现有 .git/hooks/ 目录。一个脚本搞定所有安装,四台服务器加本地机器同步更新:
| |
教训#
Hook 从 2026 年初开始运行,零凭证泄漏。更重要的是,从此再没想过"凭证泄漏"这件事——这才是重点。Hook 平时隐形,只在救人的时候现身,打印一条清晰消息:“Blocked: Aliyun AccessKey detected in config.ts line 14.”
一个全局、常开的防御胜过无数个项目级清单。维护一个 Python 文件的成本几乎为零。轮换一个泄漏的 API key、撤销下游访问权限、审计暴露范围的成本以天计——还不算在被发现之前已经造成的损失。公有仓库的密钥泄漏通常几分钟内就会被自动化扫描器抓到。“提交"和"轮换"之间的窗口期才是决定爆炸半径的变量,而 hook 让这个窗口归零。
还有一个心理层面的收益:安全焦虑是每次提交时低频但持续的认知开销。把检查自动化掉,消除的不只是风险,还有焦虑本身。最好的自动化不是做复杂的事,而是把一个反复出现的心理负担转化为一次性的结构保证。
另:hook 需要一条绕过路径用于真正的误报——git commit --no-verify,附注释说明原因。这个选项要少用、用得刻意。绕过安全控制的门槛理应高,麻烦感是正确的摩擦。
2. TOCTOU 竞态:先检查再使用并不够#
秘钥守卫阻止的是一类在版本历史留痕之前的错误。另一类 bug——并发状态变更——需要另一种防御机制,直接嵌在数据库查询里,而不是开发工作流。
事件#
AI4Marketing 使用点数配额系统:免费用户每月 30 点、Pro 400 点、Enterprise 3000 点。每次内容生成、视频渲染或 GEO 优化扣对应点数。某天发现一个用户的剩余点数是 -3。负数配额在逻辑上不可能——这意味着天花板检查没生效。
诊断#
原始配额检查是教科书级的检查时间-使用时间(TOCTOU)漏洞:
| |
在 findUnique(读)和 update(写)之间,另一个并发请求可以读到相同的 quotaUsed 值,通过检查,继续递增。两次写入都成功。用户超额。
Node.js 的 async/await 加数据库 I/O,事件循环在每个 await 处切换请求。两个相差几毫秒的请求会在恰好错误的位置交错。配额上限 30 点、用户用了 29 点的情况下,两个同时到达的 2 点请求都读到 quotaUsed=29,都算出 29+2=31>30,却都通过了检查——因为读和决策没有跟写串行化。结果是 quotaUsed=33,超出三点。按设计不可能,在负载下可复现。
修复#
把有条件的先读后写换成原子条件更新:
| |
PostgreSQL 获取行锁,对行的最新已提交状态求值 WHERE 条件,然后要么更新(返回 count=1)要么什么都不做(返回 count=0)。检查和写入是单个不可分割的操作。竞态从结构上被消除。
这个模式现在出现在每一个消费有限资源的地方:配额消费(quota-checker.ts)、支付订单认领(payment-provisioning.ts)、调度器任务认领(scheduler.ts)、视频项目状态转换(video-pipeline/retry-scheduler.ts)、日报生成去重(calendar-engine.ts)。形状都一样:读取用于展示和提前退出,updateMany 带 WHERE 守卫用于实际状态变更。读取是建议性的,写入才是权威。
同次审计还发现一个相关失效模式:六个 API 路由在调用外部 API 前消费配额,失败时不退款。用户遇到服务器错误,点数没了、内容没得到。失败路径清一色是 .catch(() => {}) ——静默吞错,资源消耗,零反馈。修复方案是系统性的:每条失败路径都加 refundQuota,退款追踪器在 try 块之前声明以确保 catch 块里能引用。其中一个路由更隐蔽——用 Promise.allSettled 做批处理,即便全部任务失败也不会抛异常,外层 catch 永远触发不到。修法是按"实际交付数量"退差额,而不是简单的全额退款。
为了不让同类 bug 再悄悄出现,写了一条扫描规则:
| |
教训#
TOCTOU 不稀奇。它出现在任何读取一个值、基于它做决策、然后写入的地方——也就是 Web 应用里大多数操作的标准路径。在并发环境中,这是不加防护时的默认失败模式。修复不是"加锁”(会引入死锁和性能悬崖),而是"让写入有条件”——把决策推到数据库里,在那里原子性是存储引擎并发协议的内置保证。
永远不要信任过去读到的值。让写入自我验证。
这个模式以各种面目出现在不同领域:检查计数器然后递增的限流器、验证库存然后扣减的库存系统、检查可用性然后预约的预订系统。任何时候你的心智模型里出现"检查"然后"写入"的序列,就有一个 TOCTOU 窗口。通用修法始终相同:把检查和变更折叠成单个原子操作,把决策下推到能保证串行化的那一层。
3. 双层防火墙:被锁门外教会我的纵深防御#
理解单一检查不可靠这件事,从应用代码延伸到了基础设施。某天早上被锁在自己服务器门外,让这个原则变得具体。
事件#
研究服务器(天翼云,113.249.102.134)每天都要 SSH 登录。某天早上,连接超时。检查云安全组——IP 在允许列表里。移除再添加,刷新规则。仍然超时。浪费了四十分钟才想起来:还有第二层防火墙,完全独立于第一层。
诊断#
服务器有两个相互独立的过滤层:
第一层:云安全组(云厂商边缘)
- 通过云控制台编辑,默认拒绝入站
- 在操作系统重装和硬重启后依然存在
- 流量到达内核之前就被拦截
第二层:主机 iptables(机器本身)
- INPUT 链底部有一条 22 端口的 DROP 规则
- 上方是已知管理员 IP 的显式 ACCEPT 规则
- 在云侧配置出错时仍然有效,提供更细粒度控制
家庭 IP 变了(运营商 DHCP 轮换),两层里都还是旧地址。只修云安全组不够,必须两层都修,SSH 才能通。而要修第二层,得先进得了机器——死锁。
方案#
紧急修复:VNC 控制台登录(每个云厂商都提供紧急控制台访问,绕过所有网络过滤):
| |
真正的解决方案是让这件事不需要再手动做。三条自助路径,按优先级排列:
管理员端点(
/api/admin/ssh-whitelist):把调用者 IP 加入主机 iptables 并持久化的 Web API。受管理员认证保护,任何浏览器可访问,不依赖 IP 白名单本身。管理员活动自动白名单(2026-06-01 起生效):每次认证到仪表盘(查看
/account、检查流水线状态),处理器调用lib.auto_whitelist.ensure_admin_ip()。日常浏览行为自动保持当前 IP 白名单,零刻意操作。VNC 后备:两个自动化路径都失效时(服务器完全不可达),云控制台 VNC 始终是最后手段。慢而笨拙,这是对的——它理应是最后手段。
诊断模式也已编码:如果 nc -zv <host> 22 超时但 nc -zv <host> 8081 成功,问题在第二层(主机 iptables),因为 8081 全局允许而 22 需要逐 IP 主机白名单。这条规则让下次诊断时间从四十分钟降到两分钟。
教训#
两层各自防御不同的失败模式:第一层阻挡噪声(端口扫描、随机 IP 的暴力尝试永远到不了内核),第二层阻挡配置错误(如果云安全组被意外开得太宽,主机仍然拦截未授权访问)。
这是纵深防御最纯粹的形式:不是"同一扇门上两把锁”,而是"两扇门,各自有自己的锁,守卫不同的故障场景”。第二层的成本接近零——几条 iptables 规则,一个 Python 函数。没有第二层的成本是一个手忙脚乱的早晨和 VNC 救援。
如果安全依赖于单一层完美工作,就没有安全——只有祈祷。
现在每搭一个新服务就会条件反射地问:“如果这一层完全失效会怎样?“如果答案是"一切暴露”,就加另一层。各层应该廉价、独立、且防御不同的东西。WAF 防应用层攻击,主机 iptables 防网络层绕过,应用级认证防 WAF 规则失误——每一层都简单,组合起来就是鲁棒的。
4. 支付流审计:应用层纵深防御的样子#
基础设施层讲完了,同样的原则放到应用层是什么样——一次支付流审计把这个问题拉到了具体。
事件#
这不是单一事件,而是一次主动审计。AI4Marketing 有支付流程:选套餐、创建订单、通过支付宝支付、获得升级配额。审计方式不是逐行读代码,而是提对抗性问题:“如果支付两次会怎样?如果订阅过期了但没人检查会怎样?如果 API 在扣费后失败会怎样?“每个问题映射到一条代码路径,答案要么是"系统处理了”,要么是"系统没处理”。五条路径的答案是后者。
漏洞链#
订单认领非原子。 原始流程:检查订单状态,更新为"已支付”,然后在单独事务中升级用户。进程在两个事务之间崩溃,订单被标已支付但用户从未收到配额。重放攻击还能多次触发第二个事务。
订阅过期从不检查。 已过期 Pro 订阅的用户无限期保留 400 点配额。
currentPeriodEnd字段写入时存,之后再没读过。配额上限是占位值。 Pro 设为 40 点(定价页承诺 400),Enterprise 设为 600(承诺 3000)。开发时的测试值,定价确定后从未更新。
重复订单创建。 点击"订阅"两次创建两个待处理订单。
WHERE status != 'paid'不够——认领条件应该是status = 'pending',否则两个都能支付。退款静默失败。 五个路由在调用外部 API 前消费配额,失败路径是
.catch(() => {})——点数扣了,内容没有,用户没有任何反馈。
修复模式#
每个漏洞得到定向修复,总体模式是:让每个状态转换原子且自验证。
原子订单认领:
| |
整个操作全有或全无,不存在"订单已支付但用户未升级"的状态。
惰性订阅过期——在每次配额消费时检查,不依赖可能静默失败的 cron:
| |
教训#
支付是所有其他安全原则汇聚的地方:原子性(TOCTOU 防护)、幂等性(重放保护)、惰性执行(订阅过期)、故障关闭默认(API 失败退款)、去重(订单复用)。这些都不新奇,每一个都是已知模式。漏洞来自五个地方各自走了捷径,每个捷径单独看"不太可能被利用”——但一个网络慢又连续点了两次的倒霉用户就能连续触发多个。
审计方法论和修复本身同样重要。对抗性场景测试行为,不测实现。实现看上去正确,在特定时序和意图的组合下仍然会以意想不到的方式失效。
5. 永远不跳闸的断路器:一个微妙的状态机 Bug#
就算实现正确、通过了所有单元测试的守卫,也可能因为来自错误来源的成功信号静默重置而完全失效。断路器 bug 是这个失效模式最清晰的示例。
事件#
研究管线每小时通过钉钉文档同步层处理数千次 API 调用。钉钉 MCP 网关降级时(周期性发生),每次失败调用阻塞 15 秒直到超时。断路器本该阻止这种连锁:60 秒内 10 次失败后打开,立即返回而不尝试调用。
断路器存在。经过测试。通过了单元测试。一次降级事件中记录了 388 次/小时失败,同时记录了恰好零次 OPENED 事件。从来没跳过闸。
诊断#
Bug 在 record_success() 里:
| |
文档同步的调用模式:先读(search_documents,快速,降级时也成功),再写(create_document,慢速,降级时超时)。每个周期:读成功 → 调 record_success() → 失败列表清空 → 写失败 → 加一次失败。下一个周期:读成功 → 列表归零 → 写失败。计数器在 0 和 1 之间摆动,永远到不了阈值 10。
单元测试只按顺序调用 record_failure,从不测试交错模式。在真实工作负载下——廉价的读成功和昂贵的写失败交错——断路器完全无效。诊断线索:失败日志只出现在写工具,从不出现在读工具——读在静默地每次成功,每次重置守卫。
修复#
修复区分 CLOSED 状态(正常运行,某些失败是预期的,窗口应该累积)和 HALF_OPEN 状态(超时期后的试探性探测,成功意味着真正恢复):
| |
在 CLOSED 状态,成功不清除失败——只移除窗口外的过期条目。只有 HALF_OPEN 探针成功(超时期结束后的第一次调用)才重置断路器。交错的读成功不再掩盖写失败的累积。
部署后(2026-06-02),断路器在第 10 次写失败时准确打开。每次打开事件节省 10 × 15s = 150 秒被浪费在注定失败调用上的时间。一小时降级期间,每 60 秒节省 150 秒——阻塞时间减少 2.5 倍。
这个模式现已编码为检测规则:标记任何在没有 HALF_OPEN 或探针状态门控的情况下将 [] 或 0 赋值给失败累加器的 record_success/on_success。自进化系统(kaizen)能在新代码中检测这种形状,在部署前标记出来。
教训#
这个 bug 对代码审查不可见(逻辑孤立看是对的),对单元测试不可见(不复现交错模式),对监控不可见(失败有记录,但没人检查断路器是否真的在打开)。只有在问"每小时 388 次失败,为什么断路器没帮上忙?“时才暴露。
如果一个守卫存在但系统表现得好像它不存在,就检查是否有一个不相关的成功在重置守卫的状态。
这可以推广到断路器之外。任何有状态的守卫——限流器、重试预算、健康检查器、异常检测器——都可能被来自被守卫对象之外的成功信号静默中和。根因始终相同:把"系统健康"和"某个特定调用成功了"混为一谈。这两件事不是一回事。在部分降级时——恰恰是你最需要守卫的时候——它们会完全背离,而把它们等同对待会让守卫在最关键的时刻失效。
6. 永远不触发的奖励守卫:产出点 vs 使用点#
断路器教会了我守卫可以被错误的信号重置。之后的一个事故揭示了更微妙的变体:守卫可以逻辑上完全正确,却因为放在了异步管线的错误位置而从不执行。
事件#
研究管线的统计师 agent 检测欠功效实验——样本量太小、结论不可靠的分析(min_n < 30,power_adequate = False)。当 LLM 生成的分析在这种条件下声称 hypothesis_support = True 时,一个奖励守卫应该把这个值强制翻为 False,防止假阳性污染学习闭环里的"这个方法有效"信号。
扫描 101 份历史 analysis.json,找到 14 份该翻没翻的。其中 4 份是守卫部署之后生成的。守卫的触发次数:零,0% 有效率。
诊断#
守卫挂在派发统计师 agent 的调用之后:
| |
统计师通过异步 dispatch 在远端 worker 上运行。主进程的 run_claude_code 在制品落盘之前就返回了。os.path.exists(analysis_path) 在产出点几乎始终是 False,守卫跳过。管线后来通过 _check_done 检测到制品时,走的是两条 reconcile 分支(_reconcile_executed_with_analysis 和 run() 里的 reconcile 路径)——两条分支都不调守卫。结果:制品存在,守卫存在,两者从未相遇。
修复#
守卫移到每一个使用点:每条将实验从 executed 转为 analyzed 状态的代码路径。抽出 helper _apply_analysis_guard(ap, idea_id),在所有三个调用点的 proto['status'] = 'analyzed' 赋值之前各调一次。
一个细节:脏检查用 json.dumps 快照比较修改前后,不用对象身份或相等比较——validate_analysis_contract 原地修改并返回同一个对象,repaired is not ana 始终为 False,基于等号的脏检查会静默地永远不写文件。
修复后回填了 101 份历史文件:42 份需要修复,14 份是应该触发但没触发的 True→False 翻转。守卫随后用合成测试用例验证——故意构造一个 min_n=3、power_adequate=False、hypothesis_support=True 的 analysis.json,直接调 _apply_analysis_guard,断言结果翻为 False 且 _reward_guard 时间戳已写入。“守卫没触发"不应该意味着"可能在工作”——它应该意味着"有测试证明它工作,只是还没遇到触发条件”。
教训#
任何确定性守卫,如果它依赖的制品是远端异步进程产出的,就绝不能挂在"产出点"(dispatch 调用),必须挂在"使用点/状态跃迁接缝"。在产出点,制品还不存在于调用机器上。通用规则:每一个校验器、每一个 schema 检查、每一个不变量守卫,都应该在结果被消费和执行动作的时刻生效,不是在生产它的任务被启动的时刻。
相关的静默泄漏模式从另一个角度切同一问题:pipeline 的跳过条件如果检查的是"输入是否存在"而不是"自己的输出是否存在",在任何回填触碰输入表之后,就会永久封锁下游处理。研究管线知识引擎的跳过条件曾是 arxiv_id in kg.get_paper_ids()——检查的是论文元数据节点,而回填已为 8273 篇论文填充了这些节点,却没有跑真正的语义提取。知识引擎看到节点就跳过,9406 次深度读取操作被静默放弃。添加回填时,列出所有读取你要写入的表的守卫,验证回填填充之后每个守卫的语义仍然成立。
哲学:安全作为系统属性#
六个事件,六种不同的失效模式。一个一致的模式:安全失效不是因为不知道威胁,而是因为防御放在了错误的层、在错误的时机触发、或被错误的信号重置。每个修复都是结构性的——改变的是防御在系统中嵌入的位置和方式,不是在清单上加一条提醒。
在最底层自动化#
Pre-commit hook 在每台机器的每次提交前运行。TOCTOU 守卫嵌在数据库查询本身里。防火墙自动白名单在正常管理员活动时触发。没有一个需要开发者记住去做什么。它们发生是因为系统就是这样建造的。记忆在压力下退化,系统结构不会——尤其是在你最忙、最不想被打断的时候。
越是后果严重的检查,越要自动化。手动安全步骤被业务压力和发版紧迫感过滤掉,恰恰是在它们最重要的时刻被略过。
纵深防御意味着独立的层#
双层防火墙是最清晰的例子,但原则是通用的。配额有预检查(用户侧错误消息)和原子认领(实际执行)。支付有订单级认领和订阅级过期检查。每层可以独立失效,系统仍然安全。“同一扇门上两把锁"不是深度,是表演。真正的深度是每一层防御对方覆盖不到的失效模式。
测试交错情况#
断路器 bug 对顺序单元测试不可见。真实系统产生交错的、并发的、部分重叠的操作。如果守卫只在隔离状态下测试过,就不知道它在真实负载下是否有效。要模拟对抗性模式:两个同时到达的请求、一次成功紧跟一次失败、超时窗口内的重试。破坏系统的场景很少是你写测试时自然想到的那个。
对于有状态的守卫尤其如此——滑动窗口累加器要用交替的廉价成功和昂贵失败测试,不只是连续失败的序列。
让守卫自我报告#
一个静默失败的守卫比没有守卫更糟——它给人虚假的信心。Pre-commit hook 精确打印哪个模式在哪个文件的哪一行匹配。配额认领在原子守卫捕获并发超额时记录 ceiling_exceeded_race。断路器在每次打开和关闭时记录日志。奖励守卫在每次 True→False 翻转时记录 _reward_guard 时间戳。
如果无法从日志判断守卫是否曾经激活过,就无法判断它是否有效。可观测性对安全机制不是可选的。“守卫从未触发"可以是两种意思:系统干净,或守卫坏了。没有日志,无法区分。
把每次修复当作模板#
修完配额 TOCTOU 竞态,审计了系统中每一个状态变更并应用了相同模式。修完断路器,把修法转化为检测规则。修完奖励守卫的位置问题,抽出可复用 helper 并部署到所有三个调用点,而不只是修我注意到的那一个。
每一次安全修复都是一堂课。课只有在被泛化、自动化、并变成可在新代码中检测的规则之后才算上完。只存在于一个地方的修复是补丁;变成规则的修复才是防御。
接受不完美,拒绝自满#
没有系统是完全安全的。目标不是完美——是充分且可见。我接受会出现新的漏洞类型。不接受的是对它们视而不见。每个守卫都包含自身的可观测性:激活时记录日志,统计它拯救系统的频率,检测到超出设计范围的异常时告警。
这是偏执和工程之间的区别。偏执说"假设一切都坏了”。工程说"针对特定威胁建造特定防御,给它们装上仪表盘以便知道新威胁何时出现,然后迭代”。前者令人疲惫且无法扩展。后者可持续且自我进化。偏执无法自动化,工程可以。
结语:忘掉它#
这些系统已经稳定运行数月——没有凭证泄漏、没有配额超额、没有防火墙锁定、没有失效的断路器、没有重复认领的支付订单、没有静默不触发的奖励守卫。不是因为我对安全保持警觉,而是因为把警觉自动化掉了。
说这一切从一开始就设计正确了会更令人满意,但不是事实。Pre-commit hook 是险些提交 key 之后建的。原子 TOCTOU 守卫是用户给我看负数配额余额之后建的。防火墙自动化是四十分钟困惑的 VNC 救援之后建的。断路器修复是注意到每小时 388 次失败零跳闸并追问原因之后建的。奖励守卫的位置错误是 14 份被污染的训练样本进入学习闭环之后发现的。每个防御都是针对具体失败建造的,不是对抽象威胁的预判。
这其实是正确的顺序。从真实事故提炼的防御性工程,精准对标真实的失效模式,不是假想的。风险在于:每次事故只做一个点修复,停在那里。纪律在于:让每次事故变成一个模式,模式变成一条规则,规则变成一个自动化检查。TOCTOU 修好之后,搜索整个代码库的相同形状并逐一修复。断路器修好之后,写一条检测规则。奖励守卫位置问题修好之后,回填了 101 份历史文件,并加了合成测试用例验证守卫在等待自然触发之前就已经能正确 fire。
每一次安全事故都是消灭一类事故的机会,不只是消灭这一个实例。 只修你发现的这个 bug,你的未来版本——在截止日期压力下——会再写出同一个 bug,只是换个形式。把模式修了——自动化检测、泛化修法、给守卫加仪表盘——这一类就变得在结构上更难出现。代码库随时间变得更安全,而不是始终停留在同一风险水位。
这就是目标。不是偏执——偏执无法扩展。不是清单——清单在最需要的时候被遗忘。不是季度审计——在两次审计之间漏洞已经被利用了。而是:把防御内建到系统中如此之深,以至于它变得隐形、自动、不经刻意为之便无法绕过。
最好的安全是你已经忘记它存在的安全。因为它早就在那里了。
本文是 产品思维 系列的第 2 篇,共 5 篇。 上一篇:第 1 篇 — 架构设计 · 下一篇:第 3 篇 — 用户体验与设计系统
产品思维 5 篇
- 01 产品思维(一):架构设计 — 从单体到自治 Agent
- 02 产品思维(二):安全工程 — 不偏执的纵深防御 当前
- 03 产品思维(三):用户体验与设计系统 — Token、暗色与双语
- 04 产品思维(四):自愈系统 — 教机器自己修自己
- 05 产品思维(五):抽象思维 — 从数学到系统