fix: sanitize getExecCommand args, escape regex in getCommandPattern, clean up readStdinJson timeout, add 10 tests

Validate args parameter in getExecCommand() against SAFE_ARGS_REGEX to
prevent command injection when returned string is passed to a shell.
Escape regex metacharacters in getCommandPattern() generic action branch
to prevent malformed patterns and unintended matching. Clean up stdin
listeners in readStdinJson() timeout path to prevent process hanging.
This commit is contained in:
Affaan Mustafa
2026-02-13 02:27:04 -08:00
parent d9331cb17f
commit a62a3a2416
3 changed files with 80 additions and 6 deletions

View File

@@ -314,11 +314,15 @@ function getRunCommand(script, options = {}) {
}
}
// Allowed characters in arguments: alphanumeric, whitespace, dashes, dots, slashes,
// equals, colons, commas, quotes, @. Rejects shell metacharacters like ; | & ` $ ( ) { } < > !
const SAFE_ARGS_REGEX = /^[@a-zA-Z0-9\s_.\/:=,'"*+-]+$/;
/**
* Get the command to execute a package binary
* @param {string} binary - Binary name (e.g., "prettier", "eslint")
* @param {string} args - Arguments to pass
* @throws {Error} If binary name contains unsafe characters
* @throws {Error} If binary name or args contain unsafe characters
*/
function getExecCommand(binary, args = '', options = {}) {
if (!binary || typeof binary !== 'string') {
@@ -327,6 +331,9 @@ function getExecCommand(binary, args = '', options = {}) {
if (!SAFE_NAME_REGEX.test(binary)) {
throw new Error(`Binary name contains unsafe characters: ${binary}`);
}
if (args && typeof args === 'string' && !SAFE_ARGS_REGEX.test(args)) {
throw new Error(`Arguments contain unsafe characters: ${args}`);
}
const pm = getPackageManager(options);
return `${pm.config.execCmd} ${binary}${args ? ' ' + args : ''}`;
@@ -351,6 +358,11 @@ function getSelectionPrompt() {
return message;
}
// Escape regex metacharacters in a string before interpolating into a pattern
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Generate a regex pattern that matches commands for all package managers
* @param {string} action - Action pattern (e.g., "run dev", "install", "test")
@@ -387,12 +399,13 @@ function getCommandPattern(action) {
'bun run build'
);
} else {
// Generic run command
// Generic run command — escape regex metacharacters in action
const escaped = escapeRegex(action);
patterns.push(
`npm run ${action}`,
`pnpm( run)? ${action}`,
`yarn ${action}`,
`bun run ${action}`
`npm run ${escaped}`,
`pnpm( run)? ${escaped}`,
`yarn ${escaped}`,
`bun run ${escaped}`
);
}

View File

@@ -215,6 +215,11 @@ async function readStdinJson(options = {}) {
const timer = setTimeout(() => {
if (!settled) {
settled = true;
// Clean up stdin listeners so the event loop can exit
process.stdin.removeAllListeners('data');
process.stdin.removeAllListeners('end');
process.stdin.removeAllListeners('error');
if (process.stdin.unref) process.stdin.unref();
// Resolve with whatever we have so far rather than hanging
try {
resolve(data.trim() ? JSON.parse(data) : {});