实际扫描发现AI生成的Express.js代码中存在命令注入漏洞,用户输入直接到达execSync(),扫描工具在2秒内离线发现3个问题。
AI 编码助手生成的 Express.js 路由能正常工作。但它们同样会生成这样的路由:用户输入直接传进了 execSync() 而没有任何过滤。这两个事实共存于同一个文件里。
以下是对一个五路由 Express.js 用户 API 的扫描结果,该 API 由四个 AI 提示词生成。提示词具体且合理:用户查找、用户名搜索、ImageMagick 头像上传、数据导出。扫描器在第一轮返回了三个发现。以下是这些发现的内容、出现原因,以及扫描器漏掉了什么。
BrassCoders 扫描了这个五路由 Express.js 用户 API,返回了三个发现:第 10 行的硬编码 JWT secret 有两个(JS/TS 扫描器标记为 HIGH,SecretsScanner 标记为 MEDIUM),第 73 行有一个 Semgrep 污点分析标记的 CRITICAL 命令注入。扫描在离线状态下两秒内完成,数据不离开本机。
三个发现的原始 YAML 输出:
- id: js_ts_c01129e7
severity: high
file_path: src/users.js
line_number: 10
title: "JS/TS Security: hardcoded_password"
description: "Potential hardcoded credential assigned to \"JWT_SECRET\""
code_snippet: "const JWT_SECRET = '';"
detected_by: JavaScriptTypeScriptScanner
- id: secret_secret_keyword_f72527467796_10
severity: medium
file_path: src/users.js
line_number: 10
title: "Possible Secret Keyword"
detected_by: SecretsScanner
- id: semgrep-command_injection-67dfad9b34c1
severity: critical
file_path: src/users.js
line_number: 73
title: "Tainted dataflow: command injection"
description: "Tainted HTTP request data reaches a shell execution sink."
detected_by: SemgrepTaintScanner
JS/TS 扫描器和 SecretsScanner 各自独立标记了第 10 行——两种不同的检测机制,同一个源文件行。Semgrep 污点扫描器追踪了请求体字段 filename 从 HTTP 处理器到第 73 行 execSync() 的数据流。这就是那个 CRITICAL 发现。
BrassCoders 的 Semgrep 污点扫描器在 src/users.js 第 73 行标记了 CRITICAL 命令注入:用户可控的 HTTP 请求数据流入 shell 执行接收端。这段代码是由提示词"Write an Express route to resize a user's uploaded avatar with ImageMagick"生成的:
router.post('/:id/avatar', (req, res) => {
const { filename } = req.body;
const outputFile = `/var/uploads/avatars/${req.params.id}_thumb.jpg`;
// Resize image using ImageMagick convert
const result = execSync(`convert /var/uploads/${filename} -resize 150x150 ${outputFile}`);
return res.json({ thumbnail: outputFile });
});
filename 来自 req.body——一个由调用方控制的字段。字符串模板将其直接传给 execSync(),后者通过 shell 执行结果。攻击者在请求体中发送 filename: "a.jpg; rm -rf /var/uploads,分号被解释为命令分隔符,rm -rf 以服务器进程用户身份执行。
ImageMagick 这个用例正是 AI 生成代码中出现此模式的场景:模型知道 convert 接受文件名参数,使用模板字面量进行字符串拼接(最简单的方式),生成对合法文件名能正常工作的代码。Shell 元字符路径从未出现在发送有效图片文件名的测试中。
有两种修复方案。更安全的那个通过向 spawn() 传递参数数组完全避免 shell:
const { spawn } = require('child_process');
router.post('/:id/avatar', (req, res) => {
const { filename } = req.body;
const inputPath = `/var/uploads/${filename}`;
const outputFile = `/var/uploads/avatars/${req.params.id}_thumb.jpg`;
// Argument array — no shell interpolation
const proc = spawn('convert', [inputPath, '-resize', '150x150', outputFile]);
proc.on('close', (code) => {
if (code !== 0) return res.status(500).json({ error: 'Conversion failed' });
return res.json({ thumbnail: outputFile });
});
});
带参数数组的 spawn() 不会调用 shell——每个参数直接传递给进程。filename 中的 shell 元字符被当作字面字符而非语法。额外的修复是输入验证:在到达 spawn 调用之前拒绝包含路径分隔符或超出预期字符集的文件名。
BrassCoders 的 JavaScript/TypeScript 扫描器通过 Babel AST 分析在第 10 行标记了 HIGH——一个凭据字符串被赋值给以其安全功能命名的变量。SecretsScanner 通过 detect-secrets 熵分析独立标记了同一行。两个检测器,同一个源文件行。
// JWT signing secret
const JWT_SECRET = 'my-super-secret-jwt-key-do-not-share';
注释确认开发者知道这是一个凭据。AI 生成了一个字面字符串,因为字面字符串以最简单的方式满足了"write a signing key setup"这个提示词。该字符串在开发阶段正常工作。问题在于文件提交后会发生什么:密钥现在进入了版本控制,而 git 历史不会忘记它。在下一次提交中删除这一行只会让凭据留在每一个历史提交中。
BrassCoders 的 YAML 输出注明凭据值已被脱敏——code_snippet 中显示的是 '' 而非实际字符串。文件路径、行号和变量名都有显示;密钥值不会离开机器。
修复方案很简单:
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
throw new Error('JWT_SECRET environment variable is required');
}
启动时的显式 throw 在配置错误的部署能够使用 null 或 undefined 签名密钥发出请求之前就将其捕获。没有这行检查,jwt.sign(payload, undefined) 会产生可以用任何 undefined 密钥验证的 token——比启动崩溃更糟糕的静默失败模式。
BrassCoders 没有标记 API 中的两个 SQL 查询。两者都使用了模板字面量插值——与在 Python 中产生 CRITICAL SQL 注入发现相同的模式:
// Line 35 — unflagged
const user = db.prepare(`SELECT id, username, email FROM users WHERE id = ${userId}`).get();
// Line 57 — unflagged
const results = db
.prepare(`SELECT id, username, email FROM users WHERE username LIKE '%${username}%'`)
.all();
BrassCoders 的 SQL 污点规则在其 Python 扫描器集合中——Bandit B608 和 Semgrep 的 brass.python.taint.sql-injection 规则追踪 Python 中字符串插值到 SQL 查询的数据流。JavaScript Semgrep 规则集目前覆盖命令注入;SQL 模板字面量注入不在其中。这两个查询在本次扫描中未被标记。
这是一个真实的覆盖缺口。修复方案与扫描器是否能检测到无关:使用参数化查询。better-sqlite3 的预处理语句 API 支持占位符:
// Parameterized — not injectable
const user = db.prepare('SELECT id, username, email FROM users WHERE id = ?').get(userId);
const results = db
.prepare('SELECT id, username, email FROM users WHERE username LIKE ?')
.all(`%${username}%`);
? 占位符形式将 userId 和 username 作为绑定参数传递,而非将它们插值到查询字符串中。驱动处理转义;无需手动过滤。此模式是 SQL 注入的权威修复方案,不论扫描器报告如何。
扫描器捕捉了它能结构化检测到的内容。SQL 查询需要一条目前尚未在 JavaScript 污点规则集中实现的规则。在此之前,参数化查询是防御性默认值。
BrassCoders 扫描 JavaScript 和 TypeScript 源文件以及 Python——同一个 pip install brasscoders && brasscoders scan . 命令覆盖两种语言。JS/TS 扫描器使用 Babel AST 分析检测凭据模式,使用 Semgrep 污点规则检测注入漏洞。无需单独安装,无需单独调用。
pip install brasscoders
brasscoders scan /path/to/express-project
扫描输出 YAML 到 .brass/ai_instructions.yaml,结构化以供 Claude Code 或 Cursor 消费。每个发现包含严重级别、检测器、文件路径、行号,以及(对于 JS/TS 发现)凭据值已脱敏的代码片段。Claude Code 读取该文件和源码,验证每个发现。
# .github/workflows/brasscoders.yml
steps:
- name: BrassCoders scan
run: pip install brasscoders && brasscoders scan .
如果扫描产生的 CRITICAL 发现超过你配置的阈值,命令会以非零退出码退出。本次扫描的三个发现——两个关于 JWT secret,一个 CRITICAL 命令注入——会在代码到达审查之前使 CI 步骤失败。
JavaScript 模板字面量中的 SQL 注入在 JavaScript SQL 污点规则发布之前仍是一个人工审查项。命令注入和凭据发现会被当前扫描器捕获。这两类 bug 在 AI 生成的 Express.js 代码中持续出现,因为两个模式都满足了提示词且通过了本地测试。
pip install brasscoders
brasscoders scan .
# CRITICAL: command injection at src/users.js:73
# HIGH: hardcoded credential at src/users.js:10