How API Key Exposure and Unsafe Process Spawning Happens in Node.js Scripts and How to Fix It
Introduction
In the scripts/close-issues.mjs file of a Node.js project, a critical security vulnerability combined two dangerous patterns: API key configuration examples were documented in public repository files, and the script used spawnSync() to execute curl commands with sensitive authentication tokens passed as command-line arguments.
The specific problem was in the post() and close() functions (lines 97-108 in the vulnerable version), where the code looked like this:
const r = spawnSync('curl', [
'-sS', '-X', 'POST',
'-H', `Authorization: Bearer ${token}`,
'-H', 'Accept: application/vnd.github+json',
'https://api.github.com/repos/HaloTech-Co-Ltd/hk2/issues/' + issue + '/comments',
'-H', 'Content-Type: application/json',
'-d', JSON.stringify({ body }),
], { encoding: 'utf8' });
This pattern is dangerous because:
1. Process argument visibility: The ${token} variable is passed as a command-line argument to the spawned curl process, making it visible to other processes on the system
2. Documentation exposure: The PR description indicates API key patterns were documented in README_zh.md and script comments
3. Loss of control: Spawning external processes for HTTP operations means losing direct control over error handling and security properties
For downstream consumers of this Node.js library, this vulnerability meant that anyone using this script could inadvertently expose credentials through process inspection or documentation leakage.
The Vulnerability Explained
What Makes This Dangerous?
When you use spawnSync('curl', [args]) with sensitive data in the arguments array, you create multiple security problems:
Problem 1: Process Argument Visibility
On Linux/Unix systems, any process can inspect /proc/[pid]/cmdline to see the arguments of running processes. This means:
# Another user or attacker can see:
$ cat /proc/12345/cmdline
curl-sS-XPOSTAuthorization: Bearer ghp_1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q...
The API token is now visible to any process running on the system. On Windows, similar information is available through the Windows API.
Problem 2: Logging and Monitoring
When spawnSync fails, error messages might include the command that was executed:
if (r.status !== 0) throw new Error(`comment #${issue} failed: ${r.stderr}`);
If the curl command itself appears in error messages or logs, the token is logged.
Problem 3: Documentation Exposure
The PR description explicitly mentions that API key configuration patterns were exposed in documentation files. This teaches attackers the exact patterns to look for when searching for credentials in repositories.
Attack Scenario
An attacker could:
- Clone the public repository
- Search documentation files for API key patterns (e.g., "Bearer ghp_" for GitHub tokens)
- Monitor the system where the script runs using tools like
psor/procinspection - Capture the API token from process arguments
- Use the stolen token to make unauthorized API calls to the GitHub repository
The combination of documented patterns + process argument visibility creates a critical vulnerability.
The Fix
What Changed?
The fix makes two key changes:
Change 1: Replace spawnSync with native fetch()
Instead of spawning curl as a child process, the code now uses Node.js's native fetch() API (available in Node.js 18+):
Before:
const r = spawnSync('curl', [
'-sS', '-X', 'POST',
'-H', `Authorization: Bearer ${token}`,
'-H', 'Accept: application/vnd.github+json',
'https://api.github.com/repos/HaloTech-Co-Ltd/hk2/issues/' + issue + '/comments',
'-H', 'Content-Type: application/json',
'-d', JSON.stringify({ body }),
], { encoding: 'utf8' });
if (r.status !== 0) throw new Error(`comment #${issue} failed: ${r.stderr}`);
const parsed = JSON.parse(r.stdout);
if (!parsed.id) throw new Error(`comment #${issue} failed: ${r.stdout.slice(0, 300)}`);
After:
const res = await fetch(`https://api.github.com/repos/${REPO}/issues/${issue}/comments`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ body }),
});
const text = await res.text();
const parsed = JSON.parse(text);
if (!res.ok || !parsed.id) throw new Error(`comment #${issue} failed: ${text.slice(0, 300)}`);
console.log(`commented #${issue}: ${parsed.html_url}`);
Why this is better:
- No process spawning: The token stays in memory within the Node.js process; it's never passed as a command-line argument
- Better error handling:
fetch()provides direct access to HTTP status codes and response bodies - No shell interpretation: There's no shell layer that could misinterpret special characters
- Cleaner code: The HTTP logic is explicit and easier to audit
Change 2: Remove the unsafe import
The vulnerable version imported spawnSync at the top of the file:
import { spawnSync } from 'node:child_process';
This import was completely removed, eliminating the dependency on child process spawning entirely.
Security Improvements
- Credential Protection: API tokens are no longer visible in process arguments
- Reduced Attack Surface: Removing
child_processusage eliminates an entire class of command injection vulnerabilities - Better Auditability: HTTP calls using fetch are easier to review and understand
- Process Isolation: The application no longer depends on external tools (curl), reducing supply chain risk
The same fix was applied to both the post() function (which comments on issues) and the close() function (which closes issues), ensuring consistent security across all API interactions.
Prevention & Best Practices
1. Never Spawn Processes for HTTP Calls
❌ Don't do this:
import { spawnSync } from 'node:child_process';
spawnSync('curl', ['-H', `Authorization: Bearer ${token}`, 'https://api.example.com']);
✅ Do this instead:
const res = await fetch('https://api.example.com', {
headers: { Authorization: `Bearer ${token}` }
});
2. Keep Secrets Out of Process Arguments
When you must spawn processes, never pass secrets as arguments. Use environment variables or stdin instead:
// ❌ Bad: token visible in process list
spawnSync('some-tool', ['--token', secretToken]);
// ✅ Better: pass via environment
spawnSync('some-tool', [], {
env: { ...process.env, TOOL_TOKEN: secretToken }
});
// ✅ Best: use stdin for sensitive data
const child = spawn('some-tool');
child.stdin.write(secretToken);
child.stdin.end();
3. Never Document API Key Patterns
Remove all examples that show:
- Hardcoded tokens (even fake examples)
- Token format patterns (e.g., "Bearer ghp_...")
- Configuration examples with placeholder credentials
4. Implement Secret Scanning
Use tools that scan your repository for credential patterns:
- git-secrets: Prevents credentials from being committed
- TruffleHog: Searches for high-entropy strings that look like credentials
- Semgrep: Static analysis rules for credential detection
- GitHub Secret Scanning: Built-in scanning for common credential formats
5. Use Environment Variables Correctly
// Load from environment at startup
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
// Validate that token exists before using
if (!token) {
throw new Error('GITHUB_TOKEN environment variable is required');
}
// Never log the token
console.log('Using GitHub API with token: [REDACTED]');
6. Audit Dependencies and Child Processes
Before spawning any external process:
- Ask: "Can I do this with a native Node.js API?"
- If yes, use the native API
- If no, ensure the external tool is from a trusted source and verify its integrity
7. Use CWE and OWASP References
- CWE-798: Use of Hardcoded Credentials - https://cwe.mitre.org/data/definitions/798.html
- CWE-78: Improper Neutralization of Special Elements used in an OS Command - https://cwe.mitre.org/data/definitions/78.html
- OWASP A02:2021: Cryptographic Failures - https://owasp.org/Top10/A02_2021-Cryptographic_Failures/
Key Takeaways
- Never pass API tokens as command-line arguments to spawned processes — they're visible to other processes on the system via
/procinspection - Replace spawnSync/spawn for HTTP calls with native fetch() — it keeps credentials in memory and eliminates shell interpretation risks
- Remove API key configuration examples from documentation — even fake examples teach attackers what to search for
- The combination of documented patterns + unsafe process spawning created a critical vulnerability — fixing one without the other would leave the application partially exposed
- Use static analysis and secret scanning in CI/CD — automated tools can catch these patterns before they're committed to your repository
How Orbis AppSec Detected This
Source: GitHub repository documentation files (README_zh.md) and script comments containing API key configuration patterns; HTTP requests in scripts/close-issues.mjs using sensitive authentication tokens.
Sink: spawnSync('curl', [...]) calls at lines 97-108 in the vulnerable version of scripts/close-issues.mjs, where the Authorization: Bearer ${token} header is passed as a command-line argument to a child process.
Missing control:
- No validation preventing sensitive data from being passed as process arguments
- No use of native HTTP APIs (fetch, http module) for HTTP operations
- No documentation review to remove API key patterns
- No environment variable isolation for secrets passed to child processes
CWE:
- CWE-798: Use of Hardcoded Credentials
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
Fix: Replace all spawnSync('curl', [...]) calls with native fetch() API calls, remove the child_process import, and eliminate API key configuration examples from documentation.
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 vulnerability in scripts/close-issues.mjs demonstrates a critical lesson: security is about layers, and removing even one layer can be catastrophic. By combining API key exposure in documentation with unsafe process spawning, the application created multiple attack vectors that an attacker could chain together.
The fix is straightforward: use native APIs for HTTP calls, keep secrets out of process arguments, and remove sensitive patterns from documentation. For Node.js developers, this means:
- Prefer
fetch()or thehttpmodule over spawning curl - Never pass secrets as command-line arguments
- Use environment variables for configuration, but don't document the values
- Implement secret scanning in your CI/CD pipeline
- Regularly audit your code for these patterns
By following these practices, you'll eliminate entire classes of vulnerabilities in your Node.js applications and libraries.
References
- CWE-798: Use of Hardcoded Credentials
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
- OWASP: Cryptographic Failures (A02:2021)
- OWASP: Secrets Management Cheat Sheet
- Node.js Fetch API Documentation
- Semgrep Rule: Process Spawning with Secrets
- GitHub PR: fix: api key references and patterns are exposed in ... in...