Build Scripts and the Cost of Convenience
Build scripts exist in a privileged position in any codebase—they run with developer credentials, often on CI/CD systems with elevated permissions, and they frequently invoke external tools. When a build script also accepts command-line arguments, those arguments become an implicit trust boundary. If the script concatenates those arguments directly into shell commands without validation, that boundary dissolves.
This is exactly what happened in the build orchestration script. The second CLI argument—intended to pass options to a downstream tool—was concatenated directly into a shell command string without any validation or escaping. An attacker or insider who could invoke the build with a crafted argument could inject arbitrary shell commands.
Affected Versions
| Affected | N/A (first-party code) |
| Fixed in | N/A (first-party code fix) |
| Ecosystem | N/A |
| CVE / GHSA | not assigned |
| CWE | CWE-78 (Improper Neutralization of Special Elements used in an OS Command) |
The Vulnerability Explained
The vulnerable code pattern was straightforward:
// Receive user-controlled CLI argument
var args = process.argv[2] ? ' ' + process.argv[2] : '';
// Concatenate directly into shell command (vulnerable)
execPromise('zola build ' + args)
Here, process.argv[2] is passed by the caller—typically a developer or CI/CD pipeline. If a developer runs:
npm run abridge -- "--base-url https://example.dev"
The argument "--base-url https://example.dev" is benign and reaches the shell tool as intended. But what if the argument is:
npm run abridge -- "; rm -rf /"
Now the concatenated command becomes:
zola build ; rm -rf /
The shell interprets the semicolon as a command separator, executing the rm command after zola build completes. Other metacharacters work similarly:
&&– command chaining on success||– command chaining on failure`command`or$(command)– command substitution|– piping output to another command>,<– output redirection{}– subshell grouping
An attacker with the ability to control the build invocation—such as a CI/CD integration that accepts external input, a shared build machine, or a developer workstation—could exploit this to execute code with the privileges of the build process. In many environments, that means read/write access to the repository, credentials stored in environment variables, or access to deployment infrastructure.
The Fix
The fix introduces a simple but effective whitelist validation:
if (process.argv[2] && /[;&|`$(){}<>\\\n]/.test(process.argv[2])) {
throw new Error('ERROR: unsafe characters detected in CLI argument!');
}
var args = process.argv[2] ? ' ' + process.argv[2] : '';
Before the argument is concatenated into any shell command, the regex pattern checks whether the string contains any of the shell metacharacters: ;, &, |, `, $, (, ), {, }, <, >, \, or newline. If any are present, the script terminates with an error and never reaches the execPromise() call.
This approach is effective because:
- Early rejection – The validation happens before any shell command is constructed, eliminating the window for injection.
- Explicit enumeration – The regex targets the exact characters that have special meaning in shell syntax. Legitimate arguments like
--base-url https://example.devor--output-dir ./distpass through without issue. - Fail-safe – If an argument is rejected, the build terminates with a clear error message rather than silently executing unexpected commands.
The fix preserves the original behavior for safe arguments while blocking the attack vector entirely.
Key Takeaways
-
Never concatenate user-controlled input into shell command strings, even if you believe the input is "trusted" or comes from a "safe" source. Build arguments are often controlled by CI/CD systems or automation, which expand the attack surface.
-
Shell metacharacters are a closed set. When passing arguments to shell commands, validate against the explicit set of characters that have special meaning:
;,&,|,`,$, and others. A regex pattern like the one in this fix is more reliable than trying to escape characters (which is error-prone and context-dependent). -
Command injection in build scripts has elevated impact. Build processes run with developer or service account privileges and often have access to credentials, source code, and deployment systems. An injection here is not limited to user-facing data corruption—it can compromise the entire CI/CD pipeline.
-
Test your argument parsing with adversarial input. If your build script accepts CLI arguments, include test cases that attempt injection:
"; echo hacked",$(whoami), and similar payloads. Your tests should verify that these are either rejected or safely escaped. -
Prefer structured argument passing over shell concatenation. Where possible, use argument arrays instead of string concatenation when invoking child processes. Node.js tools like
child_process.execFile()can accept arguments as an array, eliminating the shell parsing step entirely.
How Orbis AppSec Detected This
Source: CLI argument passed via process.argv[2] without validation.
Sink: The args variable, containing the unsanitized argument, concatenated into a shell command string passed to execPromise().
Missing control: No validation or sanitization of the CLI argument before use in shell command construction.
CWE: CWE-78 – Improper Neutralization of Special Elements used in an OS Command.
Fix: A regex-based whitelist validation rejects any CLI argument containing shell metacharacters before the argument is used in command construction.
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
Command injection in build scripts is a high-impact vulnerability that can easily go unnoticed during code review, especially if the script has been in use for some time without incident. The fix here—a simple regex validation applied at the entry point—is minimal, maintainable, and eliminates the attack vector without breaking legitimate use cases.
The key lesson is that build orchestration code is not exempt from input validation. Any script that accepts external input and uses it to construct system commands must validate that input, regardless of how "internal" or "trusted" the source seems. In this case, a few characters in a regex pattern prevented a complete compromise of the build pipeline.