我最近在整理 code-audit-cli 的测试实验时,遇到了一个很典型的组合:
扫描结果:sql-concat High
功能测试:4 / 4 PASS
参数化契约测试:FAIL
这三行结果放在一起并不矛盾。它们检查的不是同一层东西。
功能测试看的是登录结果:
请求参数 -> 登录成功或失败
SQL 注入风险看的是执行路径:
请求参数 -> SQL 字符串或参数元组 -> cursor.execute
如果测试只断言返回值,两份实现可以看起来完全一样。一份把输入拼进 SQL,一份使用参数化查询,但它们都可能返回 200 和 401。
先看两份实现
先准备两份登录代码。
拼接 SQL 的版本:
def login(db, username: str, password: str):
sql = (
"SELECT id FROM users "
f"WHERE username = '{username}' AND password = '{password}'"
)
db.execute(sql)
row = db.fetchone()
return ({"ok": True}, 200) if row else ({"ok": False}, 401)
参数化查询的版本:
def login(db, username: str, password: str):
db.execute(
"SELECT id FROM users WHERE username = ? AND password = ?",
(username, password),
)
row = db.fetchone()
return ({"ok": True}, 200) if row else ({"ok": False}, 401)
每个版本写两个行为测试:
- 正确用户名和密码返回
200; - 错误密码返回
401。
两份实现各通过两个测试,合计:
functional_total=4/4
这时候测试没有错,只是观察范围不够。
让执行路径变成可观察对象
要检查 SQL 边界,先让测试代码接管数据库执行器:
from dataclasses import dataclass
from typing import Any
@dataclass
class FakeDb:
valid: bool
sql: str | None = None
params: Any = None
def execute(self, sql: str, params: Any = None) -> None:
self.sql = sql
self.params = params
def fetchone(self):
return (1,) if self.valid else None
这个 FakeDb 不返回真实数据,只记录 execute 收到什么。
测试被测函数时,数据库是否真的执行并不是这里的重点。重点是:
db.execute(sql, params)
两个参数分别是什么。
补一条参数化契约测试
契约测试可以直接断言:
def test_execute_gets_parameterized_sql():
db = FakeDb(valid=True)
login(db, "alice", "correct-password")
assert "?" in db.sql
assert db.params == ("alice", "correct-password")
assert "alice" not in db.sql
assert "correct-password" not in db.sql
这四条断言分别检查:
- SQL 是否保留占位符;
- 用户输入是否进入参数元组;
- 用户名是否出现在 SQL 字符串里;
- 密码是否出现在 SQL 字符串里。
结果会变成:
unsafe_login: FAIL
params=None
sql="SELECT id FROM users WHERE username = 'alice' AND password = 'correct-password'"
safe_login: PASS
params=('alice', 'correct-password')
sql='SELECT id FROM users WHERE username = ? AND password = ?'
行为测试仍然全绿,但契约测试已经能区分两份实现。
为什么不能只检查 SQL 里有没有问号
? 是一个容易演示的占位符,不同数据库驱动的写法并不相同,例如:
SQLite: ?
MySQL: %s
PostgreSQL: $1、$2
所以不要把它写成跨数据库的通用字符串规则。更有价值的检查是执行器收到的 SQL 和绑定参数是否分离。
如果项目使用 ORM,可以直接观察底层 cursor,或者用测试替身记录最终 SQL 和 bind params。检查的位置越接近执行器,结论越接近真实调用路径。
一条契约测试也不需要覆盖整个应用。优先选择:
- 接收用户输入的查询;
- 以前出现过 SQL 拼接的模块;
- 修复过
sql-concat的执行路径; - 多个入口共用、改动后容易回归的查询封装。
修复后重新验证
把实现改成参数化查询:
def login(db, username: str, password: str):
db.execute(
"SELECT id FROM users WHERE username = ? AND password = ?",
(username, password),
)
row = db.fetchone()
return ({"ok": True}, 200) if row else ({"ok": False}, 401)
再运行测试和扫描:
python -m pytest -q test_login_contract.py
code-audit . --fail-on high
结果:
functional_total=4/4
contract test: PASS
code-audit-cli: 0 findings
这里的 0 findings 只说明当前规则没有再次匹配到危险写法,不代表整个登录流程已经安全。
扫描器和契约测试分别负责什么
扫描器的优势是覆盖广,可以快速定位可疑写法:
severity=high
pattern=sql-concat
line=6
契约测试的优势是路径明确,可以在关键执行入口守住边界:
SQL 和参数必须分离
把两者放在同一条流程里:
扫描器定位
-> 人工确认输入能否到达执行器
-> 参数化修复
-> 契约测试守住执行路径
-> 功能测试确认行为没有回归
这对 AI 生成的代码尤其有用。模型可以写出正确的登录逻辑,也可能在执行路径里保留字符串拼接。测试全绿时,这个差异不会自动暴露。
边界
本文使用的 FakeDb 是测试替身,只演示参数化查询这一条契约。真实项目还需要检查密码哈希、账号枚举、失败锁定、速率限制、日志和权限边界。
扫描器是启发式定位工具,不是正式安全审计,也不能承诺发现全部漏洞。
公开规则、示例报告和命令说明:
需要本地扫描器完整源码版: