Command Injection in AI-Generated Express.js: A Real Scan
BrassCoders scans an AI-generated Express.js user API and returns CRITICAL command injection plus a hardcoded JWT secret. Real scan output and fixes.
AI coding assistants generate Express.js routes that work. They also generate routes where user input reaches execSync() without sanitization. Those two facts coexist in the same file.
Here’s the scan of a five-route Express.js user API built from four AI prompts. The prompts were specific and reasonable: user lookup, username search, avatar upload with ImageMagick, and data export. The scanner returned three findings in the first pass. Here’s what they were, why they appeared, and what the scan missed.
What BrassCoders Returns on an AI-Generated Express.js API
BrassCoders scanned the five-route Express.js user API and returned three findings: two on the hardcoded JWT secret on line 10 (flagged by the JS/TS scanner as HIGH and by SecretsScanner as MEDIUM), and one CRITICAL command injection flagged by Semgrep taint analysis on line 73. The scan ran offline in under two seconds with no data leaving the machine.
The raw YAML output for the three findings:
- 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 = '<REDACTED>';"
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
The JS/TS scanner and SecretsScanner both flagged line 10 independently — two different detection mechanisms, same source line. The Semgrep taint scanner traced the request body field filename from the HTTP handler into the execSync() call on line 73. That’s the CRITICAL finding.
The CRITICAL Finding: Command Injection via execSync
BrassCoders’s Semgrep taint scanner flagged CRITICAL command injection at line 73 of src/users.js: user-controlled HTTP request data flowing into a shell execution sink. The code was generated from the prompt “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 comes from req.body — a field the caller controls. The string template passes it directly to execSync(), which runs the result through a shell. An attacker sending filename: "a.jpg; rm -rf /var/uploads" in the request body gets the semicolon interpreted as a command separator. The rm -rf runs as the server process user.
The ImageMagick use case is exactly where this pattern appears in AI-generated code: the model knows convert takes a filename argument, uses template literals for string assembly (the simplest approach), and produces code that works for legitimate filenames. The shell metacharacter path never appears in a test that sends valid image filenames.
Two fixes are available. The safer one avoids the shell entirely by passing an argument array to spawn():
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() with an argument array doesn’t invoke a shell — each argument is passed directly to the process. Shell metacharacters in filename are treated as literal characters, not syntax. The additional fix is input validation: reject filenames containing path separators or characters outside the expected character set before reaching the spawn call at all.
The HIGH Finding: Hardcoded JWT Signing Key
BrassCoders’s JavaScript/TypeScript scanner flagged HIGH on line 10 via Babel AST analysis — a credential string assigned to a variable named after its security function. SecretsScanner independently flagged the same line via detect-secrets entropy analysis. Two detectors, one source line.
The code:
// JWT signing secret
const JWT_SECRET = 'my-super-secret-jwt-key-do-not-share';
The comment confirms the developer knew this was a credential. The AI generated a literal string because a literal string satisfies the prompt “write a signing key setup” in the simplest way. The string works during development. The problem is what happens when the file commits: the secret is now in version control, and git history doesn’t forget it. Deleting the line on the next commit leaves the credential in every prior commit.
BrassCoders’s YAML output notes that the credential value is redacted — '<REDACTED>' appears in code_snippet rather than the actual string. The file path, line number, and variable name appear; the secret value doesn’t leave the machine.
The fix is straightforward:
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
throw new Error('JWT_SECRET environment variable is required');
}
The explicit throw on startup catches misconfigured deployments before they can serve requests with a null or undefined signing key. Without it, jwt.sign(payload, undefined) produces tokens that verify against any undefined key — a silent failure mode worse than the startup crash.
What the Scan Missed: SQL Injection via Template Literals
BrassCoders didn’t flag the two SQL queries in the API. Both use template literal interpolation — the same pattern that generates CRITICAL SQL injection findings in Python:
// 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’s SQL taint rules are in its Python scanner set — Bandit B608 and Semgrep’s brass.python.taint.sql-injection rule trace string interpolation into SQL queries in Python. The JavaScript Semgrep ruleset currently covers command injection; SQL template literal injection isn’t in it. The two queries went unflagged in this scan.
This is a real coverage gap. The fix is the same regardless of whether the scanner catches it: use parameterized queries. better-sqlite3’s prepared statement API takes placeholders:
// 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}%`);
The ? placeholder form passes userId and username as bound parameters rather than interpolating them into the query string. The driver handles escaping; no manual sanitization is needed. This pattern is the authoritative fix for SQL injection regardless of what the scanner reports.
The scan caught what it could detect structurally. The SQL queries require a rule that doesn’t yet exist in the JavaScript taint ruleset. Until it does, parameterized queries are the defensive default.
Adding BrassCoders to a Node.js Project
BrassCoders scans JavaScript and TypeScript source files alongside Python — the same pip install brasscoders && brasscoders scan . command covers both languages. The JS/TS scanner uses Babel AST analysis for credential patterns and Semgrep taint rules for injection vulnerabilities. No separate install, no separate invocation.
pip install brasscoders
brasscoders scan /path/to/express-project
The scan emits YAML to .brass/ai_instructions.yaml, structured for Claude Code or Cursor consumption. Each finding includes severity, detector, file path, line number, and (for JS/TS findings) a code snippet with the credential value redacted. Claude Code reads the file and source-verifies each finding against the source.
For CI integration:
# .github/workflows/brasscoders.yml
steps:
- name: BrassCoders scan
run: pip install brasscoders && brasscoders scan .
The command exits non-zero if the scan produces CRITICAL findings above your configured threshold. The three findings from this scan — two on the JWT secret, one CRITICAL command injection — would fail the CI step before the code reaches review.
The SQL injection in JavaScript template literals remains a manual review item until the JavaScript SQL taint rules ship. The command injection and credential findings are caught by the current scanner. Both categories of bugs appear consistently in AI-generated Express.js code because both patterns satisfy the prompt and pass local tests.
pip install brasscoders
brasscoders scan .
# CRITICAL: command injection at src/users.js:73
# HIGH: hardcoded credential at src/users.js:10 Frequently Asked Questions
Does BrassCoders scan JavaScript and TypeScript?
Yes. BrassCoders runs a JavaScript/TypeScript scanner alongside its Python scanners — the same `brasscoders scan .` command covers both languages. The JS/TS scanner uses Babel AST analysis for credential patterns and Semgrep taint rules for injection vulnerabilities like command injection in Node.js.
What is command injection and why does it appear in AI-generated code?
Command injection occurs when user-controlled input reaches a shell execution function — execSync(), exec(), or spawn() with shell:true — without sanitization. The input can include shell metacharacters like semicolon, pipe, or command substitution that cause the shell to execute attacker-controlled commands. AI assistants generate execSync() calls because they satisfy the prompt and work correctly with well-formed input; the injection path only activates with crafted input that no local test exercises.
What's wrong with hardcoding a JWT secret in source code?
A hardcoded JWT secret commits to version control, where it persists in git history even after the source line is deleted. Anyone with repository access — current or former — can forge tokens for any user ID. The fix is process.env.JWT_SECRET with the actual secret stored in environment variables, loaded at runtime and never in the repository.
Does BrassCoders catch SQL injection in JavaScript template literals?
Not currently. BrassCoders's SQL taint rules are in its Python scanner set. The JavaScript/TypeScript scanner catches credential patterns and command injection via Semgrep taint analysis, but SQL template literal injection in JavaScript is not yet in the ruleset. Use parameterized queries — the question-mark placeholder form — regardless of whether the scanner flags the pattern.
What does BrassCoders miss in a Node.js project?
BrassCoders's JavaScript/TypeScript coverage focuses on credential patterns and injection via Babel AST and Semgrep taint analysis. The current gap is SQL injection via template literals in JavaScript — SQL taint rules are Python-specific. BrassCoders also doesn't audit npm dependencies for known CVEs; npm audit covers that separately. On credentials and command injection, BrassCoders covers the structural patterns AI assistants produce.