DEV Community

CopperSunDev
CopperSunDev

Posted on • Originally published at coppersun.dev

Command Injection in AI-Generated Express.js: A Real Scan

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 = '';"
  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
Enter fullscreen mode Exit fullscreen mode

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 });
});
Enter fullscreen mode Exit fullscreen mode

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 });
  });
});
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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 — '' 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');
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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}%`);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 .
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Top comments (0)