首页
/ Ruff 安全规则深度解析:flake8-bandit 子进程系列(S602/S603/S607/S609)的检测逻辑与 mdtest 回归测试

Ruff 安全规则深度解析:flake8-bandit 子进程系列(S602/S603/S607/S609)的检测逻辑与 mdtest 回归测试

2026-09-07 14:09:10作者:凌朦慧Richard

本文以 Ruff 仓库中的 mdtest 回归测试文档 crates/ruff_linter/resources/mdtest/flake8-bandit/rules.md 为主线,深入讲解 flake8-bandit(S 系列安全规则)中与 subprocess 相关的四条规则:S602、S603、S607、S609。你将理解这些规则各自的触发条件、关键字参数与位置参数的差异、*args 为何被视为不可信输入,以及它们背后的源码实现与回归测试运行机制,从而能够精准地在项目中开启或排除对应规则。

这份文档是什么:一份可执行的 mdtest 回归测试

crates/ruff_linter/resources/mdtest/flake8-bandit/rules.md 并不是普通的手册性说明文档,而是 Ruff 独有的 mdtest 测试套件:Markdown 文档本身即测试用例。文件头部明确写道:

Regression tests for issue #27631。Keyword command arguments are checked, as well as positional arguments. *args is also treated as untrusted.

即这份文档是围绕 Ruff 上游 issue #27631 编写的一组回归测试,核心验证目标有三个:

  1. 关键字形式的命令参数(如 run(args="...", shell=True))会被检测;
  2. 位置形式的命令参数同样会被检测;
  3. 当命令以 *args(星号展开)方式传入时,一律视为不可信输入

mdtest 的运行机制

从测试基础设施看,mdtest 由 crates/ruff_mdtest/tests/mdtest.rs 通过 datatest_stable::harness! 发现 crates/ruff_linter/resources/mdtest 目录下所有 *.md 文件并逐个执行。执行流程(见 crates/ruff_mdtest/src/lib.rs)大致为:

  1. crates/mdtest/src/parser.rs 把 Markdown 解析成 MarkdownTestSuite,其中每个二级标题构成一个测试小节,小节内的 toml 代码块提供该节配置,py 代码块提供被测代码;
  2. 在内存文件系统中重建这些 Python 文件,并用 TOML 配置块构造 Configuration 与 lint 设置;
  3. 对每个 Python 文件执行真正的 lint,通过 crates/mdtest/src/assertion.rs 中的匹配器,把每行代码末尾的 # error: [rule-code] 行内注释当作预期诊断,与实际产出的诊断逐条比对。

因此,本文后面引用的每个代码示例中的 # error: [subprocess-...] 注释都是经过测试验证的确定性预期,而非示例附注,可直接作为理解规则语义的权威依据。

规则概览:子进程安全规则族及其统一入口

S602、S603、S604、S605、S606、S607、S609 这组规则虽然各自独立,但在 Ruff 源码中共享同一个实现入口 shell_injection()(见 shell_injection.rs)。它的工作流程分三步:

  1. 识别调用类型:通过 resolve_qualified_name 解析被调用函数,判断它是 subprocess 系、os 系还是其他模块的调用;
  2. 定位命令参数:找到传给被调函数的命令表达式;
  3. 按启用规则分发:根据 shell 关键字参数的取值、命令参数是否为可信输入等条件,决定报告哪条规则。

其中 get_call_kind()shell_injection.rs)维护了一张精确的函数清单:

分类 代表函数 对应的调用种类
subprocess 模块 Popencallcheck_callcheck_outputrun Subprocess
subprocess 模块 getoutputgetstatusoutput Shell
os 模块 systempopenpopen2popen3popen4 Shell
os 模块 execl*execv*spawnl*spawnv*startfile NoShell
popen2commands 模块 popen2/popen3/popen4/Popen3/Popen4getoutput/getstatusoutput Shell

只有被解析为上述限定名的调用才会进入这组规则的检测视野;其余同名函数(例如自定义的 subprocess_like.run)不会触发。规则 S602、S603、S607 自 Ruff v0.0.262 起稳定,S609 自 v0.0.271 起稳定,均属于 Category::Security(安全类)。

S602 subprocess-popen-with-shell-equals-trueshell=True 即告警

规则配置与触发样例(继承自 mdtest 文档原文):

[lint]
select = ["S602"]
from subprocess import Popen, call, check_call, check_output, run

Popen(args="true", shell=True)  # error: [subprocess-popen-with-shell-equals-true]
call(args="true", shell=True)  # error: [subprocess-popen-with-shell-equals-true]
check_call(args="true", shell=True)  # error: [subprocess-popen-with-shell-equals-true]
check_output(args="true", shell=True)  # error: [subprocess-popen-with-shell-equals-true]
run(args="true", shell=True)  # error: [subprocess-popen-with-shell-equals-true]

var_string = "true"
Popen(args=var_string, shell=True)  # error: [subprocess-popen-with-shell-equals-true]

cmd = input()
Popen(*cmd, shell=True)  # error: [subprocess-popen-with-shell-equals-true]

触发语义

该规则针对所有 Subprocess 种类调用(Popencallcheck_callcheck_outputrun)且 shell 关键字参数为 True其他真值的情形。一旦命中即报告——即使命令是纯字符串字面量 "true",也照样告警。原因正如规则文档(SubprocessPopenWithShellEqualsTrue 的 doc 注释)所解释:使用 shell 启动子进程会允许攻击者执行任意 shell 命令,属于 shell 注入(对应 CWE-78),应尽可能改用参数列表形式且不经过 shell。

消息文本的四种组合

对应源码结构体携带 safetyis_exact 两个字段,用于区分四种告警消息:

场景 触发示例 消息含义
shell=True 且命令是字面量字符串 run(args="true", shell=True) subprocess call with shell=True seems safe, but may be changed in the future; consider rewriting without shell
shell=True 且命令是动态表达式 run(args=var_string, shell=True) subprocess call with shell=True identified, security issue
shell=<其他真值> 且命令是字面量 run(args="true", shell=some_var) subprocess call with truthy shell seems safe, but may be changed in the future; consider rewriting without shell
shell=<其他真值> 且命令是动态表达式 run(args=var_string, shell=some_var) subprocess call with truthy shell identified, security issue

其中 safety 字段由 Safety::from(expr)shell_injection.rs)推导:字符串字面量 → SeemsSafe,动态计算值 → Unknown,这一约定直接沿袭自 Bandit 的定义;is_exact 则区分 shell=TrueTruthiness::True)与其他真值表达式(Truthiness::Truthy),消息实现见 shell_injection.rs

可见 S602 并不区分命令是否“看上去安全”,它关注的是使用 shell 这一行为本身。修复方向是消除对 shell 的依赖:

# 不安全:经过 shell 拼接执行
subprocess.run("ls -l", shell=True)

# 更安全:参数列表直接传递,不经过 shell
subprocess.run(["ls", "-l"])

S603 subprocess-without-shell-equals-true:无 shell 但参数不可信

[lint]
select = ["S603"]
from subprocess import Popen, call, check_call, check_output, run

a = input()

Popen(args=a, shell=False)  # error: [subprocess-without-shell-equals-true]
call(args=a, shell=False)  # error: [subprocess-without-shell-equals-true]
check_call(args=a, shell=False)  # error: [subprocess-without-shell-equals-true]
check_output(args=a, shell=False)  # error: [subprocess-without-shell-equals-true]
run(args=a, shell=False)  # error: [subprocess-without-shell-equals-true]
check_output(args=[a], shell=False)  # error: [subprocess-without-shell-equals-true]
run(*a)  # error: [subprocess-without-shell-equals-true]
run(args=["true"])

触发语义与“可信输入”豁免

与 S602 相反,S603 针对的是 shell 被设为真值的 subprocess 调用(shell=False 或省略 shell 均可)。不经过 shell 虽能避免 shell 注入,但若把未经校验的外部输入(如 input() 的结果)直接作为命令或参数传给子进程,仍可能被利用。对应源码逻辑位于 shell_injection.rs:当 shell 关键字不为真值时,如果命令参数不是可信输入,就报告 S603。

关键在于“可信输入”的定义。is_trusted_input() / is_trusted_element()shell_injection.rs)把以下情形视为可信,因而豁免:

  • 单个字符串字面量(如 "true");
  • 全部元素都是字面量的 list / tuple(如 ["true"],即使 list 整体由变量承接,只要元素全是字面量也可信);
  • NamedExpr 解包后的值可信;
  • 显式解析到 sys.executable 的表达式。

这解释了文档示例的最后一行的玄机:run(args=["true"]) 没有告警,因为它与 Popen(args=a, shell=False)check_output(args=[a], shell=False)(list 中混入不可信元素 a)的待遇完全不同。同时注意 run(*a) 也告警:正如文档开头声明的,*args 展开形式被一律视为不可信输入(实现见下文 find_subprocess_argument 的星号回退分支)。

规则文档还坦诚地标注了已知问题:由于难以判断传入参数是否已在上游被校验,S603 容易产生误报(对应 Ruff issue #4045)。所以它更适合作为安全审计线索,而非强制的风格约束;若误报过多,可在配置中单独忽略该规则。

S607 start-process-with-partial-path:避免使用不完整可执行路径

[lint]
select = ["S607"]
import os
import subprocess

os.spawnv(mode=os.P_WAIT, file="/bin/ls", args=["ls"])
subprocess.run(args="git status")  # error: [start-process-with-partial-path]

触发语义

S607 检查“以不完整路径启动外部进程”的行为。其安全依据对应 CWE-426:攻击者可以通过篡改 PATH 环境变量,让“只写命令名、不写完整路径”的调用去执行攻击者放置的恶意同名可执行文件。示例中 os.spawnv 使用了绝对路径 /bin/ls,因此不触发;而 subprocess.run(args="git status") 只给出命令名 git(相对路径),无法确定最终执行的是哪个二进制,故告警。

判定算法的实现细节

is_partial_path()is_full_path()shell_injection.rs)共同实现判定:对于字符串字面量,或 list/tuple 字面量的首个元素,只要首字符满足下列任一条件即视为完整路径:

  • 以反斜杠 \、正斜杠 / 开头(如 /bin/ls、Windows 风格 \bin\ls);
  • 以点 . 开头(如 ./bin/ls);
  • 形如 Windows 盘符 C:(首字符为字母且第二个字符为 :)。

其余一律视为“部分路径”并报告 S607。因此列表形式也会被检查,例如 subprocess.Popen(["ruff", "check", "file.py"])ruff 没有前缀路径就会命中;修复方式是写完整路径:

# 不推荐:依赖 PATH 查找可执行文件
subprocess.Popen(["ruff", "check", "file.py"])

# 推荐:使用完整路径
subprocess.Popen(["/usr/bin/ruff", "check", "file.py"])

S609 unix-command-wildcard-injection:通配符注入风险

[lint]
select = ["S609"]
import subprocess

subprocess.Popen(args="chmod -R 777 *", shell=True)  # error: [unix-command-wildcard-injection]

触发语义

S609 检测“经过 shell 执行、且命令中同时包含通配符 * 与高危文件操作命令”的调用(对应 CWE-78)。通配符会被 shell 展开,若当前目录中恰好存在以攻击者命名方式生成的文件(例如名为 -rf 或恶意 .so 的文件),就可能把额外的参数“注入”进 chmodchowntarrsync 等命令,造成越权删除或任意文件写入。

is_wildcard_command()shell_injection.rs)的判定规则是:命令字面量(字符串整体,或 list 中任一元素)同时包含 *chown / chmod / tar / rsync 之一。告警的触发还有前置条件(shell_injection.rs):调用必须属于 Shell 种类(如 os.system),或属于 Subprocess 种类且 shell 参数为真值——因为只有经过 shell,通配符展开才会真实发生。

修复方向是避免把通配符交给 shell 展开,改用更明确的路径:

# 不安全:shell 展开 `*` 可能匹配到攻击者文件
subprocess.Popen(["chmod", "777", "*.py"], shell=True)

# 更安全:使用具体路径,禁用 shell
subprocess.Popen(["chmod", "777", "main.py"])

源码级验证:关键字、位置参数与 *args 的定位逻辑

回到文档开头声明的三个回归目标,它们全部落实在 find_subprocess_argument() 中(shell_injection.rs):

fn find_subprocess_argument(arguments: &Arguments) -> Option<&Expr> {
    arguments.find_argument_value("args", 0).or_else(|| {
        // A starred first argument (`subprocess.run(*cmd)`) prevents us from locating the
        // command with `find_argument_value`; treat it as untrusted input.
        arguments
            .args
            .first()
            .filter(|argument| argument.is_starred_expr())
    })
}

其语义分两步:

  1. 优先精确定位命令参数:调用 find_argument_value("args", 0),既匹配 run(args="true") 这种关键字写法,也匹配 run("true") 这种首个位置参数写法;
  2. 星号回退视为不可信:当 run(*cmd) 这类星号展开导致无法精确定位参数时,回退逻辑把第一个星号表达式作为命令取出——而由于它是动态展开的表达式,在 S603 的可信输入判定中必然失败,从而被当作不可信输入报告。这正是 Popen(*cmd, shell=True)(S602)与 run(*a)(S603)两条样例的来源。

此外,find_shell_keyword()shell_injection.rs)负责解析 shell 关键字参数,并通过 Truthiness::from_expr 判断其真值性——只有求值为 True 或真值的表达式才触发 S602/S609 的相关分支。

规则间的协作与禁用建议

这组规则在同一函数内按“启用才检测”的方式协同工作(S604、S605、S606 也在此处处理),因此存在灵活的启用组合:

  • 只关心“用了 shell”:只开 S602(subprocess)与 S605(os.system 等);
  • 同时关心“参数可信度”:叠加 S603 / S606,但要接受其“难以判断输入是否已校验”的误报特性;
  • S607 关注可执行路径的完整度、S609 关注通配符注入,两者与 shell 与否相对独立,可按需单独开启。

在 pyproject.toml / ruff.toml 中可这样组合配置:

[lint]
select = ["S602", "S603", "S607", "S609"]

[lint.per-file-ignores]
# 在测试或运维脚本中临时压制误报
"tests/**/*.py" = ["S603"]

如何在本地运行验证

想要亲身体验本组回归测试,可按仓库中的测试基础设施运行:

  1. mdtest 文档测试:直接执行 crates/ruff_mdtest/tests/mdtest.rs 中的 harness,它会扫描 crates/ruff_linter/resources/mdtest 下所有 .md 文件,把本文中的每段 TOML 配置与 Python 样例当作真实 lint 用例跑一遍,并与 # error: [...] 行内注释比对;
  2. 快照测试crates/ruff_linter/src/rules/flake8_bandit/mod.rs 中的 rules() 测试为 S602、S603、S607、S609 各关联一个独立 fixture(如 S602.py),诊断快照存放在 crates/ruff_linter/src/rules/flake8_bandit/snapshots/ 下,可对照真实告警文本;
  3. 日常使用:直接在目标项目里加入前文的 [lint] 配置后运行 ruff check,即可复现文档中的每一条告警。

小结

Ruff 对 flake8-bandit 子进程安全规则的移植,既保留了 Bandit 的语义(字面量 vs 动态值、可信输入判定),又以 mdtest 的形式把“关键字参数、位置参数、*args”三种调用形态固化成了可回归的测试用例。理解 S602/S603/S607/S609 各自的触发条件与消息分级,能帮助你在真实项目中既不错过 shell 注入与通配符注入风险,又能理性权衡 S603 的误报成本,做到按需启用、精准修复。

登录后查看全文
热门项目推荐
相关项目推荐