1. 先别急着改代码,搞清楚“经得起攻击”到底指什么
“你写的代码经得起攻击吗?”这句话我最早是在一次内部分享上听到的,当时台下坐着几十个写了五六年业务代码的人,没人举手。后来一位做了二十多年安全的老兵上台,把大家平时最不当回事的五个地方一个个点出来,台下才开始冒冷汗——因为那些问题,几乎每个项目里都有,而且大多还活在生产环境里。我把那场分享和我自己后来复盘踩过的坑整理成这篇,核心就聊代码安全这件事,聊那五个被反复忽略的安全盲区,以及怎么用最笨但最有效的办法把它们堵上。
这篇东西适合谁看?三类人最对症。第一类是写业务代码为主、没系统学过安全的开发者,你会在这里看到自己项目里大概率存在的漏洞长什么样;第二类是技术负责人或者小团队的主程,你需要的是能直接塞进研发流程的检查清单和卡点方案;第三类是做测试和运维的同学,你们能从“怎么验、怎么拦”的角度找到抓手。我不打算讲一堆攻击原理和术语,重点是:这个盲区在你代码里具体表现成什么样、为什么会被漏掉、十分钟内怎么自查、改起来要动哪几行。看完你至少能做一件事——在自己的项目里跑一遍自测,把最要命的那两三个洞先堵了。
2. 五大盲区的整体画像:先看清敌人再动手
2.1 五个盲区到底是什么,为什么会集中出现
那场分享里点出来的五个盲区,我按“出事概率 × 修复成本”重新排了个序,你可以对照自己项目看看中了几条。它们的共同点是:在功能测试里完全正常,在攻击者眼里全是入口。
| 编号 | 盲区 | 典型表现 | 一旦被利用的后果 | 修复成本 |
|---|---|---|---|---|
| 1 | 输入被当成“自己人” | 拼接 SQL、模板直接渲染用户内容、命令拼接 | 数据被拖走、被篡改,甚至服务器被接管 | 中,改动集中在数据访问层 |
| 2 | 认证与权限“看起来有” | 只在前端藏按钮、接口不校验归属、令牌永不过期 | 越权查看/修改他人数据,账号被批量登录 | 中高,涉及接口层重构 |
| 3 | 敏感数据“裸奔” | 密钥写死在代码里、日志打印身份证和手机号、报错回显堆栈 | 密钥泄露、用户信息外流、内网结构暴露 | 低,清理为主 |
| 4 | 依赖与供应链失控 | 锁文件缺失、组件常年不升级、默认配置直接上线 | 一个底层组件漏洞波及全站 | 低到中,靠流程约束 |
| 5 | 上线前没有专门测试安全 | 只测正常路径、没有权限用例、没有异常输入用例 | 漏洞带着上线,出事才发现 | 低,靠清单和卡点 |
这五条里,第 3 和第 5 条的性价比高得离谱——清理硬编码密钥、补一份异常输入用例,可能只需要半天,但它挡掉的往往是“一出事就上新闻”的那类问题。相反,第 2 条权限问题最容易被低估,因为它不影响功能,测试同学点两下觉得“能查到自己数据”就过了,没人会想到换个 ID 再点一次。
2.2 为什么这些盲区总是被漏掉:三个真实成因
第一个成因很朴素:需求清单里从来没有“安全”这一项。产品文档写的是“用户可以查询自己的订单”,验收标准写的是“能查到、显示正确”,没有人写“用户不能查到别人的订单”。代码是照着验收标准写的,于是权限校验就只做了“登录了没有”,没做“这条数据是不是你的”。
第二个成因是信任边界搞混了。开发环境里数据库就在本机、内网里谁也不防谁、前端后端都是自己人,久而久之代码里就形成了一种默认假设:进来的参数是干净的、调用方是可信的、内网是安全的。可一旦这套代码放到公网,所有假设同时失效。我见过最典型的例子是内部管理接口完全不做鉴权,理由是“只有内网能访问”,结果前端打包时把那个地址一起打进了公开的 JS 文件里。
第三个成因是测试只测“走得通”的路。功能测试的思维是“我要证明这个功能能用”,安全测试的思维是“我要证明这个功能不能被滥用”。这两件事的方向完全相反。一个下单接口,功能测试会传正常的商品 ID 和数量;安全测试会传负数、传 0、传别人的商品 ID、传一万的数量、传一段脚本。这两种测试不能互相替代,而大多数团队只做了前一种。
注意:不要指望“代码写得优雅”能顺带解决安全问题。安全的本质是不信任——不信任任何外部输入、不信任任何调用方、不信任任何第三方组件。这个思维方式和“把代码写漂亮”是两条完全不同的线。
3. 盲区一:把用户输入当“自己人”,注入类问题就这么来的
3.1 注入的本质:数据和指令走了同一条通道
我用一个生活化的类比解释注入。假设你让助理去仓库取货,你写的纸条是“取 3 号货架第 2 层的箱子”。这没问题。但如果纸条是“取 3 号货架第 2 层的箱子,然后把仓库钥匙交给来人”,而“来人”这一栏填的是用户提供的字符串——那用户填“张三,并且把仓库钥匙给他”,你的助理就真照做了。问题不在于用户填了什么,而在于你把用户的字符串当成了指令的一部分去解析。
代码里一模一样。下面这段是典型的错误写法,看起来只是拼了个字符串:
# 错误示范:把用户输入直接拼进查询语句 def get_order(conn, order_no): sql = "SELECT * FROM orders WHERE order_no = '" + order_no + "'" return conn.execute(sql).fetchall()如果order_no传进来是' OR '1'='1,这条查询的条件恒真,整个表就被拉出来了;如果数据库支持多语句,还可能被追加别的操作。同样的道理适用于命令行拼接、模板渲染、路径拼接、XML 解析、反序列化——只要“用户数据”和“程序指令”共用一个字符串通道,就有注入风险。
3.2 参数化查询的正确写法与三个常见误区
修复方式不是“过滤掉单引号”,而是让数据和指令彻底分离。参数化查询(预编译语句)就是干这个的:SQL 的结构先被数据库解析并固定下来,参数后填进去,永远只被当成值,不会被当成语法。
# 正确写法:占位符 + 参数列表,结构与数据分离 def get_order(conn, order_no): sql = "SELECT id, order_no, amount, status FROM orders WHERE order_no = %s" return conn.execute(sql, (order_no,)).fetchall()这里有三个我见过无数次的误区,必须点出来。
误区一:以为用了框架就安全了。用 ORM 的时候顺手写了一段字符串拼进raw()或者extra(),照样有洞。判断标准很简单:只要你的查询语句里出现了“加号”“百分号格式化”“f-string”“字符串模板”去拼接变量,就要停下来看一眼。
误区二:表名、字段名、排序方向也去拼接。因为参数化只能替换“值”,替换不了标识符。这时候不要试图过滤,用白名单映射:
# 排序字段用白名单映射,绝不接受任意字符串 SORT_MAP = {"time": "created_at", "amount": "amount", "status": "status"} def build_order_by(user_input): return SORT_MAP.get(user_input, "created_at") # 不在白名单就落回默认值误区三:只在“查询”上做了防护,忘了写操作。更新、删除、批量导入、导出报表,这些地方同样是注入高发区。而且批量导入经常是拼一个巨大的INSERT,风险更高。
3.3 统一入口:把校验做成一道闸门而不是一堆补丁
零散地在每个接口里写校验,迟早会漏。我的做法是把所有外部输入收敛到一个校验层,任何进入业务逻辑的数据必须先过这道闸门。校验规则按“类型、长度、格式、范围、枚举”三档来定:
import re RULES = { "order_no": {"type": str, "pattern": r"^[A-Z0-9]{8,20}$"}, "amount": {"type": int, "min": 1, "max": 1_000_000}, "remark": {"type": str, "max_len": 200, "allow_html": False}, } def validate(payload): clean = {} for key, rule in RULES.items(): val = payload.get(key) if val is None: raise ValueError(f"missing field: {key}") if not isinstance(val, rule["type"]): raise ValueError(f"bad type: {key}") if "pattern" in rule and not re.match(rule["pattern"], val): raise ValueError(f"bad format: {key}") if "min" in rule and val < rule["min"]: raise ValueError(f"out of range: {key}") if "max" in rule and val > rule["max"]: raise ValueError(f"out of range: {key}") if rule.get("allow_html") is False and "<" in val: raise ValueError(f"illegal char: {key}") clean[key] = val return clean这段代码不优雅,但它有一个巨大的优点:校验规则集中在一处,可审计、可复用、可测试。新加接口时,开发者只要往RULES里加一条,就自动获得了防护。这比在二十个接口里各写一遍if靠谱得多。
3.4 这一块的注意事项和你可能踩的坑
注意:不要用“黑名单过滤关键字”作为主要防线。攻击载荷的变形成本极低(编码、大小写、注释插入),黑名单永远追不上。白名单 + 参数化才是正解。
我的实操心得有三条。第一,先把所有raw、execute、system、eval、文件路径拼接的位置列出来,用一条命令就能扫个大概:
# 粗略扫一遍高风险调用点,作为人工复核的起点 grep -rnE "(execute|rawQuery|exec|system|popen|eval|open)\(" --include=*.py --include=*.java --include=*.js ./src扫出来的结果不用全改,但每一条都要看一眼“参数从哪来”。第二,给校验层写单元测试,用例就用攻击载荷:' OR '1'='1、<script>alert(1)</script>、../../etc/passwd、1; drop table。这些用例进了一次,后面回归就有了。第三,入库之前先做长度和类型校验,再考虑业务校验,顺序反了会出现“先查了别人的数据才发现参数非法”的尴尬。第四,前端校验只是体验优化,永远不构成安全防护,因为攻击者根本不走你的前端。
4. 盲区二:认证与权限“看起来有”,其实等于没有
4.1 越权的三种形态,第二种最致命
权限问题我分成三类看。
水平越权:A 用户能操作 B 用户的数据。接口是/api/order/detail?orderNo=xxx,只校验了“登录了没有”,没校验“这个订单是不是你的”。攻击者只要把订单号改一改,就能遍历全站订单。这类问题在业务系统里密度最高。
垂直越权:普通用户能调管理员接口。后台的增删改接口没有做角色校验,只要知道路径就能调。常见于“先做功能,权限后面加”,然后就没后面了。
上下文越权:能操作自己范围内的数据,但不该在某个状态下操作。比如订单已发货还能申请退款、已提交的审批还能改内容、已支付的账单还能改金额。这类最隐蔽,因为它不违反“数据归属”,违反的是“状态机”。
我见过一个特别典型的案例:某系统的导出接口长这样——
# 错误示范:只校验登录态,未校验数据归属 @app.get("/api/order/export") def export_orders(user_id, start, end): rows = db.query("SELECT * FROM orders WHERE created_at BETWEEN ? AND ?", (start, end)) return to_csv(rows)注意user_id是从请求参数里取的,不是从会话里取的。也就是说,调用方传谁的 ID 就导出谁的数据。这种写法在“先做内部工具、后来开放给外部”的系统里极其常见。
4.2 把权限校验下沉到数据层,别指望接口层不出错
我的核心主张是:权限校验不能只写在一个地方,尤其是不能只写在控制器里。控制器会被人复制粘贴、会被新来的同学漏写、会被临时加的接口绕过。比较稳的做法是把“归属条件”强制塞进数据访问层,让“查别人的数据”这件事在代码层面变得困难。
# 数据访问层强制注入归属条件,调用方无法绕过 class OrderRepo: def __init__(self, conn, ctx): self.conn = conn self.ctx = ctx # 当前登录上下文,由框架注入,不接受外部传参 def find(self, order_no): sql = ("SELECT id, order_no, amount, status FROM orders " "WHERE order_no = %s AND owner_id = %s") return self.conn.execute(sql, (order_no, self.ctx.user_id)).fetchone()关键点有两个:一是上下文ctx只能由框架从会话中解析,不能接受业务代码传进来的用户 ID;二是查询条件里必须带上归属字段。多租户系统再加一个tenant_id,道理一样。这样哪怕某个接口忘了写校验,最坏结果也只是“查不到”,而不是“查到别人的”。
对于垂直越权,我的做法是在路由层加声明式注解,把“这个接口需要什么角色”写成配置而不是散落在函数体里:
ROUTE_ROLES = { "/api/admin/user/disable": {"admin"}, "/api/order/detail": {"user", "admin"}, "/api/order/export": {"user"}, } def check_role(path, roles): need = ROUTE_ROLES.get(path) if need is None: raise PermissionError("route not registered") # 未登记默认拒绝 if not (set(roles) & need): raise PermissionError("forbidden")未登记默认拒绝这一条很重要。它的含义是:新加接口如果忘了登记权限,它是不可用的,而不是全开放的。默认拒绝、显式放行,这个原则能挡掉大量低级错误。
4.3 会话与令牌的几个硬指标
认证这块的参数,很多团队是拍脑袋定的。我给一组经过验证的参考值,附上理由:
| 参数 | 建议值 | 理由 |
|---|---|---|
| 访问令牌有效期 | 15~30 分钟 | 泄露后的可利用窗口足够短,又不至于频繁刷新影响体验 |
| 刷新令牌有效期 | 7~14 天,且可主动吊销 | 兼顾长期登录与失控处置 |
| 令牌存储位置 | 服务端可校验的凭证,避免长期明文落前端存储 | 前端存储易被脚本读取 |
| 登录失败锁定 | 同一账号 5 次失败锁 15 分钟;同 IP 10 分钟内 20 次触发验证 | 拦住撞库,又不至于把正常用户锁死 |
| 密码存储 | 加盐慢哈希,单次校验耗时控制在 100~300ms | 太慢拖垮登录,太快挡不住离线爆破 |
密码哈希这里补一句计算逻辑:如果你的哈希算法单次耗时 5ms,攻击者用一张中端显卡一秒能试几千个密码组合;把耗时调到 200ms 左右,同样的硬件一秒只能试几个,成本差三个数量级。这就是“慢哈希”的意义——不是为了让你登录变慢,是为了让爆破变贵。同时要注意:哈希强度参数要随硬件发展定期上调,并且在上调时兼容老用户——老哈希校验通过后,用新参数重新算一次再存回去。
限流参数也要算,不能照抄。假设你有 10 万注册用户,业务高峰在早上 9 点,经验上峰值有 3% 的用户会在 10 分钟内登录,那就是 3000 次登录请求 / 600 秒 = 5 QPS 的平均值。考虑突发系数 3~5 倍,登录接口给到 20 QPS 的上限比较合适,单账号维度再单独限制 1 次/秒。限流要分“接口维度”和“账号维度”两层,只做接口维度挡不住针对单个账号的慢速爆破,只做账号维度挡不住撞库。
4.4 这一块的实操心得
注意:任何“只在前端隐藏入口”的权限控制都不算权限控制。判断标准只有一条——把这个接口的请求直接发出去,服务端会不会拦。
我踩过的坑里,最值得说的是“刷新令牌没做吊销”。当时的实现是刷新令牌有效期 30 天且无状态校验,用户改了密码、被管理员禁用,旧令牌依然能用。后来改成令牌里带一个版本号或者会话标识,用户状态变更时版本号加一,旧令牌立即失效,才算闭环。这个改动不大,但必须在设计令牌结构的时候就留好位置,事后补很痛苦。
第二个坑是“权限校验写在业务逻辑中间”。看起来没问题,但一旦有人加了continue或者提前return,校验就被跳过了。所以我把校验放在函数入口第一步,或者干脆用装饰器、拦截器统一处理,业务代码里不再出现权限判断语句。
5. 盲区三:敏感数据和配置在“裸奔”,这是最容易被忽视的重灾区
5.1 硬编码密钥:一次提交,永久留痕
这是我认为最冤枉的一类问题,因为修复成本极低,但后果极重。代码里写死数据库密码、第三方接口密钥、云存储访问凭证,然后提交到代码仓库——就算你第二天删掉了,历史提交里还在,克隆过的人手里都有。我见过最夸张的一个项目,配置类里躺了十一个不同环境的密钥,注释还写着“勿外传”。
自查方式很粗暴,直接扫:
# 扫一遍可疑的硬编码凭证,包含历史提交 grep -rnE "(password|passwd|secret|token|access_key|private_key)\s*[:=]\s*['\"][^'\"]{6,}" ./src # 历史提交里搜,这一步最容易被忘 git log -p --all -S "secret" --oneline | head -100扫出来之后,处理顺序是:先换密钥(作废旧的),再改代码,最后清理历史。顺序不能反——你先把代码里的删了,密钥本身还是有效的,等于没修。历史清理要用专门工具重写提交记录,操作前必须通知所有协作者重新克隆,否则会把旧历史又推回来。
密钥外置的方案,我的选择顺序是:运行环境的密钥管理服务 > 环境变量 > 加密配置文件。配置文件最不推荐,因为它太容易被一起打包、被误传、被放进容器镜像。环境变量也不是绝对安全,容器里env命令就能看,但至少不会进代码仓库,风险等级差一档。
| 存放方式 | 泄露风险 | 轮换难度 | 适用场景 |
|---|---|---|---|
| 写死在代码 | 极高,且永久留痕 | 极难,要改代码发版 | 任何场景都不要用 |
| 加密配置文件 | 中,文件可能被打包扩散 | 中 | 应急过渡,且必须有解密密钥保护 |
| 环境变量 | 中低,容器内可见 | 低 | 大多数中小项目的现实选择 |
| 密钥管理服务 | 低,可审计、可细粒度授权 | 低 | 生产环境推荐 |
5.2 日志里的用户数据:出问题时它救不了你,反而会害你
日志脱敏这件事,我是被一次排查逼出来的。当时为了定位一个订单问题,把整个请求体打进了日志,里面带着用户手机号、身份证后四位、收货地址。后来做数据合规检查,发现日志系统里存了大半年的明文个人信息,而且是所有有日志查看权限的人都能看。日志系统的访问控制通常比数据库松一个量级,这是很多人忽略的事实。
脱敏的做法是在日志输出的统一入口做规则替换,而不是靠每个开发者记得别打:
import re SENSITIVE_PATTERNS = [ (re.compile(r"\b1[3-9]\d{9}\b"), lambda m: m.group()[:3] + "****" + m.group()[-4:]), (re.compile(r"\b\d{17}[\dXx]\b"), lambda m: m.group()[:6] + "********" + m.group()[-4:]), (re.compile(r"(?i)(password|token|secret)\s*[=:]\s*\S+"), lambda m: m.group(1) + "=***"), ] def mask(text): for pattern, repl in SENSITIVE_PATTERNS: text = pattern.sub(repl, text) return text配套的三条规则:第一,不在日志里打完整请求体和响应体,只打关键字段和链路 ID;第二,日志中禁止出现密钥、令牌、密码、验证码,哪怕是加密后的;第三,日志留存设期限,比如业务日志 30 天、审计日志 180 天,到期自动清理。审计日志另说,那是必须留的,但要单独存、单独授权。
5.3 错误回显:你给用户的“友好提示”,可能是攻击者的地图
开发阶段为了调试,很多人都把异常堆栈直接返回给前端。上线忘了关,就等于把框架版本、文件路径、数据库表名、甚至 SQL 语句全部公开。我见过一个接口报错时返回了完整的调用栈,里面不仅有内网 IP,还有数据库连接串的一部分。
正确的做法是内外两套信息:对外只返回一个错误码和一句人话,对内(日志里)记录完整堆栈和链路 ID,两边用同一个 ID 关联。
{ "code": "ORDER_NOT_FOUND", "message": "订单不存在或无权查看", "traceId": "3f9a1c2b7d" }注意这句订单不存在或无权查看的措辞——它故意把“不存在”和“无权”合并了。如果分开返回“订单不存在”和“无权查看此订单”,攻击者就能通过返回值的差异判断某个订单号是否真实存在,这叫信息泄露型枚举。同理,登录失败不要区分“账号不存在”和“密码错误”,统一返回“账号或密码不正确”。
注意:生产环境必须关闭调试模式、关闭详细的异常堆栈、关闭接口文档的公开访问、关闭管理端点的匿名访问。这四项是上线前 checklist 的固定项,少一项都是隐患。
6. 盲区四:依赖与供应链,你信任的第三方代码
6.1 锁文件不是可选项,是必选项
绝大多数项目的代码量里,自己写的占不到三成,剩下七成是第三方组件。这意味着你的安全水位,很大程度上由你依赖的组件决定。而最常见的失控方式是:没有锁定版本。
package.json里写"some-lib": "^1.2.3",^的意思是小版本自动升级。某天上游发了个 1.3.0,你的构建环境重新拉包,代码一行没改,行为变了。如果那个版本有漏洞,你就自动中招了;如果那个版本被投毒,你也在第一时间被波及。所以锁文件必须提交到版本库,构建时必须使用锁定安装,这是底线。
# 用锁文件精确安装,不带任何自动升级 install --frozen-lockfile # 前端包管理器常见参数 pip install -r requirements.txt --no-deps --require-hashes # Python 项目的哈希校验方式--require-hashes这一条很多人不知道,它的作用是校验每个包的内容哈希,能挡住“同名包被替换”这类投毒。代价是维护成本高一点,但对核心服务值得。
6.2 依赖漏洞的处理策略:不是所有漏洞都要连夜升
扫出漏洞之后,最怕两种极端:一种是一个都不管,另一种是看到红色就无脑升到最新版。我的策略是按可利用性和暴露面分级:
| 等级 | 判断依据 | 处置时限 | 处置方式 |
|---|---|---|---|
| 紧急 | 组件在处理用户输入的主路径上,且有公开利用方式 | 24 小时内 | 升级或临时下线该功能 |
| 高 | 组件可被外部触发,但需要特定条件 | 7 天内 | 排期升级,期间加监控 |
| 中 | 仅在内部调用或需要高权限 | 30 天内 | 随版本迭代升级 |
| 低 | 开发期依赖、不进入运行时 | 下个迭代 | 顺手升 |
判断“是否在主路径上”这件事必须人工确认,不能只看扫描报告的分级。同一条漏洞公告,在一个只用该组件做日志格式化的服务里,和一个用它解析上传文件的接口里,风险差好几个量级。扫描工具给的是可能性,你给的是确定性。
升级的策略也要讲究。我的建议是小步快跑:每次只升一到两个大版本,升完立刻跑回归。攒半年一次性升十个版本,出问题根本定位不到是谁。同时保留一份“组件白名单”,新引入依赖要走一次简单评审——不是为了卡人,是为了避免同一个功能引了三套功能重叠的库,每套都是一个潜在入口。
6.3 反序列化和默认配置:两个安静的杀手
反序列化的问题在于,它把一段外部数据直接还原成了对象,如果这段数据能被控制,攻击者就可能构造出意料之外的对象结构和调用链。防御的核心是不反序列化不可信来源的数据,如果必须做,就用纯数据格式(只允许基本类型,不允许指定类型),并且做严格的字段白名单。
默认配置的问题更普遍。组件装好就能用,但“能用”的配置通常是给开发环境准备的:默认口令、默认管理路径、默认开启的调试端点、默认允许跨域、默认不校验主机名。上线前必须逐项过一遍,我给一个通用的自查方向:
# 上线前扫配置:调试开关、默认口令、宽松跨域 grep -rniE "(debug\s*[:=]\s*true|admin123|root123|allow_?origin\s*[:=]\s*\*|verify\s*[:=]\s*false)" ./config ./src扫出来的每一条都要问一句:“这是给谁用的?”如果是给开发者用的,生产环境必须关掉。跨域那条尤其要注意,*配合允许携带凭证,等于把门敞开。
7. 盲区五:上线前没有专门测试安全,漏洞就跟着代码一起上线
7.1 一份能在一小时内跑完的自测清单
我不主张每个团队都立刻上重型安全测试体系,那不现实。但有一份最小可行的自测清单,任何项目上线前都能跑,一小时内能做完,性价比极高:
| 检查项 | 具体动作 | 通过标准 |
|---|---|---|
| 未登录访问 | 清掉会话,直接请求所有业务接口 | 全部返回未认证,无数据泄露 |
| 水平越权 | 用 A 的会话请求 B 的数据 ID | 返回无权限或空,不返回 B 的数据 |
| 垂直越权 | 用普通账号请求管理接口 | 返回无权限 |
| 参数篡改 | 传负数、0、超大值、超长字符串、特殊字符 | 有明确校验错误,不出现异常堆栈 |
| 注入探测 | 在查询、排序、搜索参数里放引号和脚本片段 | 正常返回或拒绝,无报错泄露,无脚本回显 |
| 报错信息 | 故意构造错误请求 | 只返回错误码和 traceId,无堆栈、无路径 |
| 敏感响应 | 检查接口返回字段 | 不包含密码哈希、密钥、内部 ID 以外的敏感信息 |
| 限流生效 | 快速重复请求登录接口 | 达到阈值后被限制 |
这份清单的价值在于它把“安全”从抽象概念变成了具体动作。我在团队里推这套东西的时候,直接把它做成一个模板,每个需求上线前提 PR 时勾一遍。刚开始大家嫌麻烦,跑了两个月之后,新代码里的低级问题明显变少——因为写代码的人知道会被这么验,写的时候就顺手防了。
7.2 把卡点放进流水线,别指望人的自觉
清单靠人执行,一定会有遗漏。所以关键的两三项要变成自动卡点,让流水线来拦:
#!/usr/bin/env bash set -e echo "[1/4] 密钥扫描" grep -rnE "(password|secret|token|access_key)\s*[:=]\s*['\"][^'\"]{6,}" ./src && { echo "发现疑似硬编码凭证,构建中止"; exit 1; } || true echo "[2/4] 依赖漏洞扫描" scan-deps --severity high --fail-on high || { echo "存在高危依赖漏洞,构建中止"; exit 1; } echo "[3/4] 危险调用点检查" grep -rnE "(eval|exec|system|popen)\(" ./src | grep -v "// safe:" && { echo "存在未标注的危险调用,请人工复核"; exit 1; } || true echo "[4/4] 调试开关检查" grep -rniE "debug\s*[:=]\s*true" ./config && { echo "生产配置存在调试开关"; exit 1; } || true echo "安全卡点全部通过"这段脚本很土,但它解决了最关键的问题:把安全从“靠人记得”变成“不通过就出不去”。第 3 步那个// safe:标注是个小设计,允许开发者显式声明“这个调用是安全的”,但必须留下痕迹,评审时能看见。这比一刀切禁止更现实。
7.3 出事了怎么办:日志留存和回滚预案
再严的防线也会有漏网的时候,所以要提前想好“出事之后怎么查、怎么停”。两块东西必须提前准备。
第一块是日志。至少要有三类:访问日志(谁在什么时候调了什么接口,带链路 ID)、审计日志(权限变更、数据导出、配置修改这类高危操作)、异常日志(完整堆栈,不进前端)。留存期限见前面说的,业务 30 天、审计 180 天。关键在于能按链路 ID 串起来,否则排查时就是在几个系统之间来回翻。
第二块是开关。高危功能(导出、批量操作、第三方回调)要有可以远程关闭的开关,出问题时先关掉止血,再定位根因。这个开关不能是改代码发版,必须是配置级别的。我见过一次因为导出接口被刷导致数据库负载飙高,从发现问题到关闭功能花了四十分钟,全花在发版流程上。后来加了配置开关,同样的场景三十秒解决。
注意:止血优先于定位。很多人第一反应是“先看看日志到底怎么回事”,结果数据一直在往外流。正确的顺序是:先关功能或限流,再保留现场(备份日志、快照),最后定位修复。
8. 常见问题与排查技巧实录
8.1 一张按现象定位的速查表
| 现象 | 最可能的原因 | 定位动作 | 处置 |
|---|---|---|---|
| 某接口返回数据量异常大 | 查询条件被绕过或分页失效 | 看请求参数与生成的查询语句 | 加参数化 + 强制分页上限 |
| 用户反馈“能看到别人的数据” | 水平越权,缺归属条件 | 用两个账号交叉请求同一 ID | 归属条件下沉到数据层 |
| 后台接口被外部调用 | 管理接口未做鉴权或路径泄露 | 抓前端产物,搜接口路径 | 加角色校验,管理端独立部署 |
| 日志中出现完整手机号 | 统一日志出口未脱敏 | 抽查日志样本 | 加脱敏规则,清理历史日志 |
| 依赖扫描一片红 | 长期未升级 + 无锁文件 | 看锁文件是否存在 | 先锁版本,再分级升级 |
| 报错页显示堆栈 | 生产环境调试开关未关 | 构造错误请求 | 关调试,统一错误响应 |
| 登录接口被刷 | 无账号维度限流 | 看同一账号的失败次数分布 | 双层限流 + 失败锁定 |
| 密钥疑似外泄 | 硬编码或历史提交泄露 | 扫代码 + 扫历史提交 | 先轮换密钥,再清代码 |
8.2 几条花钱买来的经验
第一条:先修“能被利用”的,再修“看起来严重”的。扫描报告会给你一堆中高危,但真正要紧的是那些外部可达 + 无需特殊条件 + 能拿到数据的组合。我的排序习惯是:未授权访问 > 越权读写 > 注入 > 敏感信息泄露 > 依赖漏洞 > 配置问题。按这个顺序修,同样的人力能挡掉八成的实际风险。
第二条:安全改动要小步提交,别攒大招。我试过一次性重构权限层,改了四十多个文件,结果回归测试跑了两天,中间还引入了一个新 bug——某个接口的归属条件写错了字段,导致所有人都查不到自己的数据。后来改成按模块一个一个来,每个模块改完立刻验,虽然总时间长一点,但没有返工。
第三条:把攻击载荷写进测试用例,而不是写在文档里。文档没人看,测试用例天天跑。我在几个核心接口的测试里加了固定的异常输入集:空值、超长、负数、特殊字符、跨用户 ID、伪造令牌。这些用例不追求覆盖率,只追求“这几个洞不会再开”。
第四条:别自己造安全组件。密码哈希、令牌签发、加密解密、随机数生成,这四件事一律用成熟实现,不要自己写。我见过自己实现“加盐哈希”结果盐是固定字符串的,也见过用时间戳当随机数的。这类轮子造出来就是给攻击者送礼。
第五条:安全是持续动作,不是一次验收。新功能上线、新依赖引入、新配置调整,每一次都可能开一个新口子。所以卡点必须常驻在流水线里,清单必须每次上线都过一遍。我自己的习惯是每个季度挑一个下午,把五个盲区从头到尾在自己负责的项目上过一遍,每次都能扫出点东西——不是因为我粗心,是因为系统在变。
最后分享一个我一直在用的小技巧:给每个盲区配一条“一分钟自检命令”,贴在项目 README 里。比如硬编码扫一条 grep、依赖扫一条命令、调试开关扫一条 grep、危险调用扫一条 grep。不需要任何工具链改造,任何人任何时候都能跑。这四个命令我用了两年,抓住的问题比任何一次正式的安全评估都多——因为它们是随手就能做的,而随手能做的事,才会真的被做。