Ruff 安全规则深度解析:flake8-bandit 子进程系列(S602/S603/S607/S609)的检测逻辑与 mdtest 回归测试
本文以 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.
*argsis also treated as untrusted.
即这份文档是围绕 Ruff 上游 issue #27631 编写的一组回归测试,核心验证目标有三个:
- 关键字形式的命令参数(如
run(args="...", shell=True))会被检测; - 位置形式的命令参数同样会被检测;
- 当命令以
*args(星号展开)方式传入时,一律视为不可信输入。
mdtest 的运行机制
从测试基础设施看,mdtest 由 crates/ruff_mdtest/tests/mdtest.rs 通过 datatest_stable::harness! 发现 crates/ruff_linter/resources/mdtest 目录下所有 *.md 文件并逐个执行。执行流程(见 crates/ruff_mdtest/src/lib.rs)大致为:
- 由 crates/mdtest/src/parser.rs 把 Markdown 解析成
MarkdownTestSuite,其中每个二级标题构成一个测试小节,小节内的toml 代码块提供该节配置,py 代码块提供被测代码; - 在内存文件系统中重建这些 Python 文件,并用 TOML 配置块构造
Configuration与 lint 设置; - 对每个 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)。它的工作流程分三步:
- 识别调用类型:通过
resolve_qualified_name解析被调用函数,判断它是subprocess系、os系还是其他模块的调用; - 定位命令参数:找到传给被调函数的命令表达式;
- 按启用规则分发:根据
shell关键字参数的取值、命令参数是否为可信输入等条件,决定报告哪条规则。
其中 get_call_kind()(shell_injection.rs)维护了一张精确的函数清单:
| 分类 | 代表函数 | 对应的调用种类 |
|---|---|---|
subprocess 模块 |
Popen、call、check_call、check_output、run |
Subprocess |
subprocess 模块 |
getoutput、getstatusoutput |
Shell |
os 模块 |
system、popen、popen2、popen3、popen4 |
Shell |
os 模块 |
execl*、execv*、spawnl*、spawnv*、startfile |
NoShell |
popen2、commands 模块 |
popen2/popen3/popen4/Popen3/Popen4、getoutput/getstatusoutput |
Shell |
只有被解析为上述限定名的调用才会进入这组规则的检测视野;其余同名函数(例如自定义的 subprocess_like.run)不会触发。规则 S602、S603、S607 自 Ruff v0.0.262 起稳定,S609 自 v0.0.271 起稳定,均属于 Category::Security(安全类)。
S602 subprocess-popen-with-shell-equals-true:shell=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 种类调用(Popen、call、check_call、check_output、run)且 shell 关键字参数为 True 或其他真值的情形。一旦命中即报告——即使命令是纯字符串字面量 "true",也照样告警。原因正如规则文档(SubprocessPopenWithShellEqualsTrue 的 doc 注释)所解释:使用 shell 启动子进程会允许攻击者执行任意 shell 命令,属于 shell 注入(对应 CWE-78),应尽可能改用参数列表形式且不经过 shell。
消息文本的四种组合
对应源码结构体携带 safety 与 is_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=True(Truthiness::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 的文件),就可能把额外的参数“注入”进 chmod、chown、tar、rsync 等命令,造成越权删除或任意文件写入。
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())
})
}
其语义分两步:
- 优先精确定位命令参数:调用
find_argument_value("args", 0),既匹配run(args="true")这种关键字写法,也匹配run("true")这种首个位置参数写法; - 星号回退视为不可信:当
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"]
如何在本地运行验证
想要亲身体验本组回归测试,可按仓库中的测试基础设施运行:
- mdtest 文档测试:直接执行 crates/ruff_mdtest/tests/mdtest.rs 中的 harness,它会扫描
crates/ruff_linter/resources/mdtest下所有.md文件,把本文中的每段 TOML 配置与 Python 样例当作真实 lint 用例跑一遍,并与# error: [...]行内注释比对; - 快照测试: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/下,可对照真实告警文本; - 日常使用:直接在目标项目里加入前文的
[lint]配置后运行ruff check,即可复现文档中的每一条告警。
小结
Ruff 对 flake8-bandit 子进程安全规则的移植,既保留了 Bandit 的语义(字面量 vs 动态值、可信输入判定),又以 mdtest 的形式把“关键字参数、位置参数、*args”三种调用形态固化成了可回归的测试用例。理解 S602/S603/S607/S609 各自的触发条件与消息分级,能帮助你在真实项目中既不错过 shell 注入与通配符注入风险,又能理性权衡 S603 的误报成本,做到按需启用、精准修复。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0625
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00