扫描器报了 sql-concat,为什么 4 个测试还是没抓到?

24 阅读4分钟

我最近在整理 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)

每个版本写两个行为测试:

  1. 正确用户名和密码返回 200;
  2. 错误密码返回 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。检查的位置越接近执行器,结论越接近真实调用路径。

一条契约测试也不需要覆盖整个应用。优先选择:

  1. 接收用户输入的查询;
  2. 以前出现过 SQL 拼接的模块;
  3. 修复过 sql-concat 的执行路径;
  4. 多个入口共用、改动后容易回归的查询封装。

修复后重新验证

把实现改成参数化查询:

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 是测试替身,只演示参数化查询这一条契约。真实项目还需要检查密码哈希、账号枚举、失败锁定、速率限制、日志和权限边界。

扫描器是启发式定位工具,不是正式安全审计,也不能承诺发现全部漏洞。

公开规则、示例报告和命令说明:

github.com/yuan1521913…

需要本地扫描器完整源码版:

code-audit-cli source package