Skip to content

Fix start and other npm exec commands#1520

Open
damyanpetev wants to merge 3 commits intomasterfrom
dpetev/fix-start-process-exec
Open

Fix start and other npm exec commands#1520
damyanpetev wants to merge 3 commits intomasterfrom
dpetev/fix-start-process-exec

Conversation

@damyanpetev
Copy link
Member

@damyanpetev damyanpetev commented Mar 4, 2026

Closes #1512 .

Additional information related to this pull request:
See issue details here: #1512 (comment)

  • reverted most of the changes from resolve code scanning vulnerability alerts #1331 and fix: command injection vulnerabilities in PackageManager and start command #1438 swapping exec with spawn because:

    • spawn is not equivalent to exec - it does not throw for bad execution exit, in fact it barely throws for anything other than call configuration, so it fails 'silently' for our use cases and required handling rework where called
    • spawn on Windows can't run npm since it's a script without shell evaluation option; used to be able to run npm.cmd but that's also not available since node@24+; I've checked and most secure calls into spawn that want to call in some node package rely on process.execPath and the path to the script (essentially calling node path/to/file.js) which could work for installed packages (think tsc), but no reliable way to resolve the npm path itself
  • Dropped extra param where it wasn't really needed and added a sanitize function

  • Swapped one older call using spawn w/ npm.cmd since that no longer works with node@24+

Note: CodeQL doesn't pick up the sanitize function out-of-the-box and my customization attempts have failed thus far - will leave for a separate PR. In fact, might re-work the command usage entirely to be async first and switch to writing to package.json instead with a single static command call at the end.

@damyanpetev damyanpetev force-pushed the dpetev/fix-start-process-exec branch from 99949d0 to 495a83d Compare March 5, 2026 09:14
try {
// tslint:disable-next-line:object-literal-sort-keys
Util.spawnSync('npm', ['whoami', `--registry=${fullPackageRegistry}`], { stdio: 'pipe', encoding: 'utf8' });
Util.execSync(`npm whoami --registry=${fullPackageRegistry}`, { stdio: "pipe", encoding: "utf8" });

Check warning

Code scanning / CodeQL

Unsafe shell command constructed from library input Medium

This string concatenation which depends on
library input
is later used in a
shell command
.

Copilot Autofix

AI about 13 hours ago

To fix the problem, avoid constructing shell command strings that embed fullPackageRegistry and instead supply the registry value as an argument to a child process API that does not invoke a shell (for example, child_process.spawnSync with shell: false). This prevents shell interpretation of metacharacters and sidesteps reliance on custom sanitization. The behavior (running npm whoami, npm login, and npm config set) should be preserved while changing only how these commands are invoked.

Concretely, we will:

  • Add a small helper execSyncCmd in Util that wraps spawnSync to execute a command with an argv array and similar error handling to the existing execSync.
  • Update PackageManager.ensureRegistryUser so that:
    • npm whoami is executed as Util.execSyncCmd("npm", ["whoami", --registry=${fullPackageRegistry}], { ... }).
    • npm login is executed similarly with its arguments array.
    • npm config set is executed as Util.execSyncCmd("npm", ["config", "set", "@infragistics:registry", fullPackageRegistry]);.
  • Keep Util.sanitizeShellArg in place (it still helps validate/normalize the registry), but no longer depend on it for shell safety.

All changes will be contained within the shown snippets in packages/core/util/Util.ts (add the helper method) and packages/core/packages/PackageManager.ts (switch calls from execSync with a string to the new helper with argv arrays).

Suggested changeset 2
packages/core/packages/PackageManager.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/core/packages/PackageManager.ts b/packages/core/packages/PackageManager.ts
--- a/packages/core/packages/PackageManager.ts
+++ b/packages/core/packages/PackageManager.ts
@@ -227,7 +227,7 @@
 		const fullPackageRegistry = Util.sanitizeShellArg(config.igPackageRegistry);
 		try {
 			// tslint:disable-next-line:object-literal-sort-keys
-			Util.execSync(`npm whoami --registry=${fullPackageRegistry}`, { stdio: "pipe", encoding: "utf8" });
+			Util.execSyncCmd("npm", ["whoami", `--registry=${fullPackageRegistry}`], { stdio: "pipe", encoding: "utf8" });
 		} catch (error) {
 			// try registering the user:
 			Util.log(
@@ -250,12 +250,13 @@
 			}
 
 			try {
-				Util.execSync(
-					`npm login --registry=${fullPackageRegistry} --scope=@infragistics --auth-type=legacy`,
+				Util.execSyncCmd(
+					"npm",
+					["login", `--registry=${fullPackageRegistry}`, "--scope=@infragistics", "--auth-type=legacy"],
 					{ stdio: "inherit" }
 				);
 				//make sure scope is configured:
-				Util.execSync(`npm config set @infragistics:registry ${fullPackageRegistry}`);
+				Util.execSyncCmd("npm", ["config", "set", "@infragistics:registry", fullPackageRegistry]);
 				return true;
 			} catch (error) {
 				Util.log(message, "red");
EOF
@@ -227,7 +227,7 @@
const fullPackageRegistry = Util.sanitizeShellArg(config.igPackageRegistry);
try {
// tslint:disable-next-line:object-literal-sort-keys
Util.execSync(`npm whoami --registry=${fullPackageRegistry}`, { stdio: "pipe", encoding: "utf8" });
Util.execSyncCmd("npm", ["whoami", `--registry=${fullPackageRegistry}`], { stdio: "pipe", encoding: "utf8" });
} catch (error) {
// try registering the user:
Util.log(
@@ -250,12 +250,13 @@
}

try {
Util.execSync(
`npm login --registry=${fullPackageRegistry} --scope=@infragistics --auth-type=legacy`,
Util.execSyncCmd(
"npm",
["login", `--registry=${fullPackageRegistry}`, "--scope=@infragistics", "--auth-type=legacy"],
{ stdio: "inherit" }
);
//make sure scope is configured:
Util.execSync(`npm config set @infragistics:registry ${fullPackageRegistry}`);
Util.execSyncCmd("npm", ["config", "set", "@infragistics:registry", fullPackageRegistry]);
return true;
} catch (error) {
Util.log(message, "red");
packages/core/util/Util.ts
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/core/util/Util.ts b/packages/core/util/Util.ts
--- a/packages/core/util/Util.ts
+++ b/packages/core/util/Util.ts
@@ -363,6 +363,43 @@
 	}
 
 	/**
+	 * Execute synchronous command without using a shell, accepting arguments as an array.
+	 * Mirrors the error-handling semantics of execSync above.
+	 * @param command Command to be executed (binary name)
+	 * @param args Arguments to pass to the command
+	 * @param options Spawn options
+	 */
+	public static execSyncCmd(command: string, args: string[], options?: SpawnSyncOptions) {
+		const spawnOptions: SpawnSyncOptions = Object.assign({}, options, { shell: false });
+		const result = spawnSync(command, args, spawnOptions);
+
+		if (result.error) {
+			throw result.error;
+		}
+
+		// Non-zero exit code or terminated by signal
+		if (result.status !== 0) {
+			const error: any = new Error(`Command failed: ${command} ${args.join(" ")}`);
+			error.status = result.status;
+			error.signal = result.signal;
+			error.stdout = result.stdout;
+			error.stderr = result.stderr;
+
+			if (error.stderr && error.stderr.toString().endsWith() === "^C") {
+				return process.exit();
+			}
+
+			if (error.status === 3221225786 || error.status > 128) {
+				return process.exit();
+			}
+
+			throw error;
+		}
+
+		return result.stdout;
+	}
+
+	/**
 	 * Execute synchronous command with options using spawnSync
 	 * @param command Command to be executed
 	 * NOTE: `spawn` without `shell` (unsafe) is **not** equivalent to `exec` & requires direct path to run the correct process on win,
EOF
@@ -363,6 +363,43 @@
}

/**
* Execute synchronous command without using a shell, accepting arguments as an array.
* Mirrors the error-handling semantics of execSync above.
* @param command Command to be executed (binary name)
* @param args Arguments to pass to the command
* @param options Spawn options
*/
public static execSyncCmd(command: string, args: string[], options?: SpawnSyncOptions) {
const spawnOptions: SpawnSyncOptions = Object.assign({}, options, { shell: false });
const result = spawnSync(command, args, spawnOptions);

if (result.error) {
throw result.error;
}

// Non-zero exit code or terminated by signal
if (result.status !== 0) {
const error: any = new Error(`Command failed: ${command} ${args.join(" ")}`);
error.status = result.status;
error.signal = result.signal;
error.stdout = result.stdout;
error.stderr = result.stderr;

if (error.stderr && error.stderr.toString().endsWith() === "^C") {
return process.exit();
}

if (error.status === 3221225786 || error.status > 128) {
return process.exit();
}

throw error;
}

return result.stdout;
}

/**
* Execute synchronous command with options using spawnSync
* @param command Command to be executed
* NOTE: `spawn` without `shell` (unsafe) is **not** equivalent to `exec` & requires direct path to run the correct process on win,
Copilot is powered by AI and may make mistakes. Always verify output.
//make sure scope is configured:
try {
Util.spawnSync('npm', ['config', 'set', `@infragistics:registry`, fullPackageRegistry]);
Util.execSync(`npm config set @infragistics:registry ${fullPackageRegistry}`);

Check warning

Code scanning / CodeQL

Unsafe shell command constructed from library input Medium

This string concatenation which depends on library input is later used in a shell command.

Copilot Autofix

AI about 12 hours ago

In general terms, the fix is to stop interpolating fullPackageRegistry into a shell command string and instead pass it as a separate argument to a non‑shell API. That means replacing execSync with execFileSync (which takes a program and an array of arguments) or, for the wrapper, adding a variant that accepts command and args separately. This avoids any shell parsing of the argument, so even if fullPackageRegistry contains problematic characters, they will be treated as literal argument content, not control characters.

The minimal, behavior‑preserving change here is to (1) add a new helper in Util that uses execFileSync with an explicit command and argument array, and (2) update PackageManager.ensureRegistryUser to call this new helper instead of building a string command. Specifically:

  • In packages/core/util/Util.ts, add a static method, e.g. execFileSync(command: string, args: string[], options?: ExecSyncOptions), that wraps Node’s child_process.execFileSync. This keeps the same error‑handling semantics as the current execSync wrapper. We must also import execFileSync from child_process.
  • In packages/core/packages/PackageManager.ts, change the three Util.execSync(...) calls inside ensureRegistryUser to use Util.execFileSync with explicit argument arrays:
    • npm whoami --registry=<url>Util.execFileSync("npm", ["whoami", --registry=${fullPackageRegistry}], { ... })
    • npm login --registry=<url> --scope=@infragistics --auth-type=legacyUtil.execFileSync("npm", ["login", --registry=${fullPackageRegistry}, "--scope=@infragistics", "--auth-type=legacy"], { stdio: "inherit" })
    • npm config set @infragistics:registry <url>Util.execFileSync("npm", ["config", "set", "@infragistics:registry", fullPackageRegistry]);
      This keeps command semantics identical while eliminating shell string construction with tainted data.
Suggested changeset 2
packages/core/packages/PackageManager.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/core/packages/PackageManager.ts b/packages/core/packages/PackageManager.ts
--- a/packages/core/packages/PackageManager.ts
+++ b/packages/core/packages/PackageManager.ts
@@ -227,7 +227,7 @@
 		const fullPackageRegistry = Util.sanitizeShellArg(config.igPackageRegistry);
 		try {
 			// tslint:disable-next-line:object-literal-sort-keys
-			Util.execSync(`npm whoami --registry=${fullPackageRegistry}`, { stdio: "pipe", encoding: "utf8" });
+			Util.execFileSync("npm", ["whoami", `--registry=${fullPackageRegistry}`], { stdio: "pipe", encoding: "utf8" });
 		} catch (error) {
 			// try registering the user:
 			Util.log(
@@ -250,12 +250,18 @@
 			}
 
 			try {
-				Util.execSync(
-					`npm login --registry=${fullPackageRegistry} --scope=@infragistics --auth-type=legacy`,
+				Util.execFileSync(
+					"npm",
+					[
+						"login",
+						`--registry=${fullPackageRegistry}`,
+						"--scope=@infragistics",
+						"--auth-type=legacy"
+					],
 					{ stdio: "inherit" }
 				);
 				//make sure scope is configured:
-				Util.execSync(`npm config set @infragistics:registry ${fullPackageRegistry}`);
+				Util.execFileSync("npm", ["config", "set", "@infragistics:registry", fullPackageRegistry]);
 				return true;
 			} catch (error) {
 				Util.log(message, "red");
EOF
@@ -227,7 +227,7 @@
const fullPackageRegistry = Util.sanitizeShellArg(config.igPackageRegistry);
try {
// tslint:disable-next-line:object-literal-sort-keys
Util.execSync(`npm whoami --registry=${fullPackageRegistry}`, { stdio: "pipe", encoding: "utf8" });
Util.execFileSync("npm", ["whoami", `--registry=${fullPackageRegistry}`], { stdio: "pipe", encoding: "utf8" });
} catch (error) {
// try registering the user:
Util.log(
@@ -250,12 +250,18 @@
}

try {
Util.execSync(
`npm login --registry=${fullPackageRegistry} --scope=@infragistics --auth-type=legacy`,
Util.execFileSync(
"npm",
[
"login",
`--registry=${fullPackageRegistry}`,
"--scope=@infragistics",
"--auth-type=legacy"
],
{ stdio: "inherit" }
);
//make sure scope is configured:
Util.execSync(`npm config set @infragistics:registry ${fullPackageRegistry}`);
Util.execFileSync("npm", ["config", "set", "@infragistics:registry", fullPackageRegistry]);
return true;
} catch (error) {
Util.log(message, "red");
packages/core/util/Util.ts
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/core/util/Util.ts b/packages/core/util/Util.ts
--- a/packages/core/util/Util.ts
+++ b/packages/core/util/Util.ts
@@ -1,5 +1,5 @@
 import chalk from "chalk";
-import { execSync, ExecSyncOptions, spawnSync, SpawnSyncOptions } from "child_process";
+import { execFileSync, execSync, ExecSyncOptions, spawnSync, SpawnSyncOptions } from "child_process";
 import * as fs from "fs";
 import * as glob from "glob";
 import * as path from "path";
@@ -363,6 +363,30 @@
 	}
 
 	/**
+	 * Execute synchronous command without spawning a shell, using an explicit argument list.
+	 * @param command Command to be executed
+	 * @param args Arguments to pass to the command
+	 * @param options Command options
+	 * @throws {Error} On timeout or non-zero exit code. Error has 'status', 'signal', 'output', 'stdout', 'stderr'
+	 */
+	public static execFileSync(command: string, args: string[], options?: ExecSyncOptions) {
+		try {
+			return execFileSync(command, args, options);
+		} catch (error) {
+			// mirror execSync error handling for consistency
+			if (error.stderr && error.stderr.toString().endsWith() === "^C") {
+				return process.exit();
+			}
+
+			if (error.status === 3221225786 || error.status > 128) {
+				return process.exit();
+			}
+
+			throw error;
+		}
+	}
+
+	/**
 	 * Execute synchronous command with options using spawnSync
 	 * @param command Command to be executed
 	 * NOTE: `spawn` without `shell` (unsafe) is **not** equivalent to `exec` & requires direct path to run the correct process on win,
EOF
@@ -1,5 +1,5 @@
import chalk from "chalk";
import { execSync, ExecSyncOptions, spawnSync, SpawnSyncOptions } from "child_process";
import { execFileSync, execSync, ExecSyncOptions, spawnSync, SpawnSyncOptions } from "child_process";
import * as fs from "fs";
import * as glob from "glob";
import * as path from "path";
@@ -363,6 +363,30 @@
}

/**
* Execute synchronous command without spawning a shell, using an explicit argument list.
* @param command Command to be executed
* @param args Arguments to pass to the command
* @param options Command options
* @throws {Error} On timeout or non-zero exit code. Error has 'status', 'signal', 'output', 'stdout', 'stderr'
*/
public static execFileSync(command: string, args: string[], options?: ExecSyncOptions) {
try {
return execFileSync(command, args, options);
} catch (error) {
// mirror execSync error handling for consistency
if (error.stderr && error.stderr.toString().endsWith() === "^C") {
return process.exit();
}

if (error.status === 3221225786 || error.status > 128) {
return process.exit();
}

throw error;
}
}

/**
* Execute synchronous command with options using spawnSync
* @param command Command to be executed
* NOTE: `spawn` without `shell` (unsafe) is **not** equivalent to `exec` & requires direct path to run the correct process on win,
Copilot is powered by AI and may make mistakes. Always verify output.
@damyanpetev damyanpetev force-pushed the dpetev/fix-start-process-exec branch from 495a83d to 0aed238 Compare March 5, 2026 09:27
/* passes through */
default:
args = ['uninstall', packageName, '--quiet', '--save'];
command = `${managerCommand} uninstall ${sanitizePackage} --quiet --save`;

Check warning

Code scanning / CodeQL

Unsafe shell command constructed from library input Medium

This string concatenation which depends on
library input
is later used in a
shell command
.

Copilot Autofix

AI about 13 hours ago

General approach: Avoid building shell command strings that include tainted data and then passing them to execSync. Instead, pass the command and its arguments as separate parameters to child_process.spawnSync (or execFileSync), which does not invoke a shell and thus prevents shell interpretation of user‑controlled data. Where a helper (Util.execSync) currently wraps execSync, add a parallel helper that wraps spawnSync with an argument array, and update call sites that build dynamic commands from library inputs to use the array‑based helper.

Concrete fix:

  1. In packages/core/util/Util.ts, add a new static method, execFileSync, that accepts a command, an array of arguments, and optional options, and internally calls spawnSync. It should mirror the error‑handling logic of execSync as closely as possible (status checks, SIGINT handling) so behavior stays consistent.

  2. In packages/core/packages/PackageManager.ts, stop composing npm commands as single strings for the cases where arguments contain library input:

    • In installPackages, instead of command = \${managerCommand} install --quiet`andUtil.execSync(command, ...), build const args = ["install", "--quiet"];and callUtil.execFileSync(managerCommand, args, options)`.
    • In removePackage, instead of command = \${managerCommand} uninstall ${sanitizePackage} --quiet --save`, build const args = ["uninstall", sanitizePackage, "--quiet", "--save"];and callUtil.execFileSync(managerCommand, args, options)`.
    • In addPackage, modify getInstallCommand usage so that it returns args instead of a string, or, if we cannot modify that function here, reconstruct the equivalent array in addPackage. Since we only see the call site, we’ll construct the array directly in addPackage using managerCommand and sanitizePackage.
  3. Keep Util.sanitizeShellArg as‑is (it still adds a defense in depth against malformed or unexpected package names), but after the change it will no longer be relied upon for shell escaping, only for validating/normalizing input.

Imports: Util.ts already imports spawnSync and SpawnSyncOptions, so we can reuse those; no new external packages are needed.


Suggested changeset 2
packages/core/packages/PackageManager.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/core/packages/PackageManager.ts b/packages/core/packages/PackageManager.ts
--- a/packages/core/packages/PackageManager.ts
+++ b/packages/core/packages/PackageManager.ts
@@ -87,22 +87,15 @@
 	public static async installPackages(verbose: boolean = false) {
 		const config = ProjectConfig.localConfig();
 		if (!config.packagesInstalled) {
-			let command: string;
 			const managerCommand = this.getManager();
+			const args = ["install", "--quiet"];
 
-			switch (managerCommand) {
-				case "npm":
-				/* passes through */
-				default:
-					command = `${managerCommand} install --quiet`;
-					break;
-			}
 			await this.flushQueue(false);
 			Util.log(`Installing ${managerCommand} packages`);
 			try {
 				// inherit the parent process' stdin so we can catch if an attempt to interrupt the process is made
 				// ignore stdout and stderr as they will output unnecessary text onto the console
-				Util.execSync(command, { stdio: ["inherit"], killSignal: "SIGINT" });
+				Util.execFileSync(managerCommand, args, { stdio: ["inherit"], killSignal: "SIGINT" } as any);
 				Util.log(`Packages installed successfully`);
 			} catch (error) {
 				// ^C (SIGINT) produces status:3221225786 https://github.com/sass/node-sass/issues/1283#issuecomment-169450661
@@ -122,19 +108,12 @@
 	}
 
 	public static removePackage(packageName: string, verbose: boolean = false): boolean {
-		let command: string;
 		const managerCommand = this.getManager();
 		const sanitizePackage = Util.sanitizeShellArg(packageName);
-		switch (managerCommand) {
-			case "npm":
-			/* passes through */
-			default:
-				command = `${managerCommand} uninstall ${sanitizePackage} --quiet --save`;
-				break;
-		}
+		const args = ["uninstall", sanitizePackage, "--quiet", "--save"];
 		try {
 			// tslint:disable-next-line:object-literal-sort-keys
-			Util.execSync(command, { stdio: "pipe", encoding: "utf8" });
+			Util.execFileSync(managerCommand, args, { stdio: "pipe", encoding: "utf8" } as any);
 		} catch (error) {
 			Util.log(`Error uninstalling package ${sanitizePackage} with ${managerCommand}`);
 			if (verbose) {
@@ -150,10 +125,11 @@
 	public static addPackage(packageName: string, verbose: boolean = false): boolean {
 		const managerCommand = this.getManager();
 		const sanitizePackage = Util.sanitizeShellArg(packageName);
-		const command = this.getInstallCommand(managerCommand, sanitizePackage);
+		// Use argument array to avoid shell interpretation
+		const args = ["install", sanitizePackage, "--quiet", "--save"];
 		try {
 			// tslint:disable-next-line:object-literal-sort-keys
-			Util.execSync(command, { stdio: "pipe", encoding: "utf8" });
+			Util.execFileSync(managerCommand, args, { stdio: "pipe", encoding: "utf8" } as any);
 		} catch (error) {
 			Util.log(`Error installing package ${sanitizePackage} with ${managerCommand}`);
 			if (verbose) {
EOF
@@ -87,22 +87,15 @@
public static async installPackages(verbose: boolean = false) {
const config = ProjectConfig.localConfig();
if (!config.packagesInstalled) {
let command: string;
const managerCommand = this.getManager();
const args = ["install", "--quiet"];

switch (managerCommand) {
case "npm":
/* passes through */
default:
command = `${managerCommand} install --quiet`;
break;
}
await this.flushQueue(false);
Util.log(`Installing ${managerCommand} packages`);
try {
// inherit the parent process' stdin so we can catch if an attempt to interrupt the process is made
// ignore stdout and stderr as they will output unnecessary text onto the console
Util.execSync(command, { stdio: ["inherit"], killSignal: "SIGINT" });
Util.execFileSync(managerCommand, args, { stdio: ["inherit"], killSignal: "SIGINT" } as any);
Util.log(`Packages installed successfully`);
} catch (error) {
// ^C (SIGINT) produces status:3221225786 https://github.com/sass/node-sass/issues/1283#issuecomment-169450661
@@ -122,19 +108,12 @@
}

public static removePackage(packageName: string, verbose: boolean = false): boolean {
let command: string;
const managerCommand = this.getManager();
const sanitizePackage = Util.sanitizeShellArg(packageName);
switch (managerCommand) {
case "npm":
/* passes through */
default:
command = `${managerCommand} uninstall ${sanitizePackage} --quiet --save`;
break;
}
const args = ["uninstall", sanitizePackage, "--quiet", "--save"];
try {
// tslint:disable-next-line:object-literal-sort-keys
Util.execSync(command, { stdio: "pipe", encoding: "utf8" });
Util.execFileSync(managerCommand, args, { stdio: "pipe", encoding: "utf8" } as any);
} catch (error) {
Util.log(`Error uninstalling package ${sanitizePackage} with ${managerCommand}`);
if (verbose) {
@@ -150,10 +125,11 @@
public static addPackage(packageName: string, verbose: boolean = false): boolean {
const managerCommand = this.getManager();
const sanitizePackage = Util.sanitizeShellArg(packageName);
const command = this.getInstallCommand(managerCommand, sanitizePackage);
// Use argument array to avoid shell interpretation
const args = ["install", sanitizePackage, "--quiet", "--save"];
try {
// tslint:disable-next-line:object-literal-sort-keys
Util.execSync(command, { stdio: "pipe", encoding: "utf8" });
Util.execFileSync(managerCommand, args, { stdio: "pipe", encoding: "utf8" } as any);
} catch (error) {
Util.log(`Error installing package ${sanitizePackage} with ${managerCommand}`);
if (verbose) {
packages/core/util/Util.ts
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/core/util/Util.ts b/packages/core/util/Util.ts
--- a/packages/core/util/Util.ts
+++ b/packages/core/util/Util.ts
@@ -363,6 +363,42 @@
 	}
 
 	/**
+	 * Execute synchronous command without going through a shell, using an argument array.
+	 * Mirrors the error and SIGINT handling of execSync.
+	 * @param command Command to be executed
+	 * @param args Arguments to pass to the command
+	 * @param options Command options
+	 */
+	public static execFileSync(command: string, args: string[], options?: SpawnSyncOptions) {
+		const result = spawnSync(command, args, options);
+
+		// Handle interruption and non-zero exit codes similar to execSync's behavior
+		if (result.error) {
+			throw result.error;
+		}
+
+		if (result.stderr && result.stderr.toString().endsWith() === "^C") {
+			return process.exit();
+		}
+
+		if (result.status === 3221225786 || (typeof result.status === "number" && result.status > 128)) {
+			return process.exit();
+		}
+
+		if (typeof result.status === "number" && result.status !== 0) {
+			const error: any = new Error(`Command failed: ${command} ${args.join(" ")}`);
+			error.status = result.status;
+			error.signal = result.signal;
+			error.stdout = result.stdout;
+			error.stderr = result.stderr;
+			error.output = result.output;
+			throw error;
+		}
+
+		return result.stdout;
+	}
+
+	/**
 	 * Execute synchronous command with options using spawnSync
 	 * @param command Command to be executed
 	 * NOTE: `spawn` without `shell` (unsafe) is **not** equivalent to `exec` & requires direct path to run the correct process on win,
EOF
@@ -363,6 +363,42 @@
}

/**
* Execute synchronous command without going through a shell, using an argument array.
* Mirrors the error and SIGINT handling of execSync.
* @param command Command to be executed
* @param args Arguments to pass to the command
* @param options Command options
*/
public static execFileSync(command: string, args: string[], options?: SpawnSyncOptions) {
const result = spawnSync(command, args, options);

// Handle interruption and non-zero exit codes similar to execSync's behavior
if (result.error) {
throw result.error;
}

if (result.stderr && result.stderr.toString().endsWith() === "^C") {
return process.exit();
}

if (result.status === 3221225786 || (typeof result.status === "number" && result.status > 128)) {
return process.exit();
}

if (typeof result.status === "number" && result.status !== 0) {
const error: any = new Error(`Command failed: ${command} ${args.join(" ")}`);
error.status = result.status;
error.signal = result.signal;
error.stdout = result.stdout;
error.stderr = result.stderr;
error.output = result.output;
throw error;
}

return result.stdout;
}

/**
* Execute synchronous command with options using spawnSync
* @param command Command to be executed
* NOTE: `spawn` without `shell` (unsafe) is **not** equivalent to `exec` & requires direct path to run the correct process on win,
Copilot is powered by AI and may make mistakes. Always verify output.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't be reverting if spawn was a valid option for npm commands cross-plat...

@damyanpetev damyanpetev changed the title Fix start and other exec commands Fix start and other npm exec commands Mar 5, 2026
@damyanpetev damyanpetev force-pushed the dpetev/fix-start-process-exec branch from 0edf0b2 to 4dc57d6 Compare March 6, 2026 13:06
@coveralls
Copy link

coveralls commented Mar 6, 2026

Coverage Status

coverage: 70.495% (+0.1%) from 70.394%
when pulling cbb6502 on dpetev/fix-start-process-exec
into 0157241 on master.

@damyanpetev damyanpetev force-pushed the dpetev/fix-start-process-exec branch 2 times, most recently from 75f1b86 to 89f5958 Compare March 9, 2026 09:59

try {
Util.execSync(
`npm login --registry=${fullPackageRegistry} --scope=@infragistics --auth-type=legacy`,

Check warning

Code scanning / CodeQL

Unsafe shell command constructed from library input Medium

This string concatenation which depends on
library input
is later used in a
shell command
.

Copilot Autofix

AI about 13 hours ago

In general, to fix this problem we should avoid constructing shell commands as single strings that are then interpreted by a shell. Instead, we should call npm with an API that accepts an executable and a list of arguments (for example, spawnSync or execFile) so that the registry URL is passed as a discrete argument and is not subject to shell parsing. This removes the need to rely on ad-hoc sanitization for correctness and security.

The best change with minimal functional impact is to update Util.execSync (the central helper) so that it can accept either a string or an array form, and internally dispatch to child_process.execSync when given a string (preserving all existing behaviour) or to child_process.spawnSync when given an executable and argument array. Then we adjust the two npm calls in PackageManager.ensureRegistryUser to use the safer array form: Util.execSync(["npm", "whoami", --registry=${fullPackageRegistry}], options) and Util.execSync(["npm", "login", --registry=${fullPackageRegistry}, "--scope=@infragistics", "--auth-type=legacy"], { stdio: "inherit" }), plus the npm config set call similarly. This keeps the commands logically identical while avoiding shell parsing of fullPackageRegistry.

Concretely:

  • In packages/core/util/Util.ts, change the signature of Util.execSync to accept command: string | string[]. Inside, if typeof command === "string", call execSync(command, options) as before; if it is an array, treat command[0] as the executable and command.slice(1) as its arguments, and invoke spawnSync with those, mimicking execSync’s behaviour and error handling as closely as practical (throw on non-zero exit, surface stdout/stderr, respect options.stdio to the extent possible).
  • Preserve the existing error-handling logic for the string path, and for the array path, add analogous checks on result.status and result.stderr, throwing an Error when the spawned process fails.
  • In packages/core/packages/PackageManager.ts, update the three Util.execSync calls in ensureRegistryUser to use the array form described above. We keep the existing fullPackageRegistry sanitizer and log messages, but the value is no longer interpolated into a shell string.

This keeps overall behaviour the same from the caller’s perspective while structurally eliminating the unsafe shell command construction for these flows.

Suggested changeset 2
packages/core/packages/PackageManager.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/core/packages/PackageManager.ts b/packages/core/packages/PackageManager.ts
--- a/packages/core/packages/PackageManager.ts
+++ b/packages/core/packages/PackageManager.ts
@@ -227,7 +227,10 @@
 		const fullPackageRegistry = Util.sanitizeShellArg(config.igPackageRegistry);
 		try {
 			// tslint:disable-next-line:object-literal-sort-keys
-			Util.execSync(`npm whoami --registry=${fullPackageRegistry}`, { stdio: "pipe", encoding: "utf8" });
+			Util.execSync(
+				["npm", "whoami", `--registry=${fullPackageRegistry}`],
+				{ stdio: "pipe", encoding: "utf8" }
+			);
 		} catch (error) {
 			// try registering the user:
 			Util.log(
@@ -251,11 +254,17 @@
 
 			try {
 				Util.execSync(
-					`npm login --registry=${fullPackageRegistry} --scope=@infragistics --auth-type=legacy`,
+					["npm", "login",
+						`--registry=${fullPackageRegistry}`,
+						"--scope=@infragistics",
+						"--auth-type=legacy"
+					],
 					{ stdio: "inherit" }
 				);
 				//make sure scope is configured:
-				Util.execSync(`npm config set @infragistics:registry ${fullPackageRegistry}`);
+				Util.execSync(
+					["npm", "config", "set", `@infragistics:registry`, fullPackageRegistry]
+				);
 				return true;
 			} catch (error) {
 				Util.log(message, "red");
EOF
@@ -227,7 +227,10 @@
const fullPackageRegistry = Util.sanitizeShellArg(config.igPackageRegistry);
try {
// tslint:disable-next-line:object-literal-sort-keys
Util.execSync(`npm whoami --registry=${fullPackageRegistry}`, { stdio: "pipe", encoding: "utf8" });
Util.execSync(
["npm", "whoami", `--registry=${fullPackageRegistry}`],
{ stdio: "pipe", encoding: "utf8" }
);
} catch (error) {
// try registering the user:
Util.log(
@@ -251,11 +254,17 @@

try {
Util.execSync(
`npm login --registry=${fullPackageRegistry} --scope=@infragistics --auth-type=legacy`,
["npm", "login",
`--registry=${fullPackageRegistry}`,
"--scope=@infragistics",
"--auth-type=legacy"
],
{ stdio: "inherit" }
);
//make sure scope is configured:
Util.execSync(`npm config set @infragistics:registry ${fullPackageRegistry}`);
Util.execSync(
["npm", "config", "set", `@infragistics:registry`, fullPackageRegistry]
);
return true;
} catch (error) {
Util.log(message, "red");
packages/core/util/Util.ts
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/core/util/Util.ts b/packages/core/util/Util.ts
--- a/packages/core/util/Util.ts
+++ b/packages/core/util/Util.ts
@@ -333,32 +333,65 @@
 
 	/**
 	 * Execute synchronous command with options
-	 * @param command Command to be executed
+	 * @param command Command to be executed. If a string is provided, it is executed via the system shell.
+	 * If an array is provided, the first element is treated as the executable and the rest as arguments,
+	 * and the command is executed without shell interpretation.
 	 * @param options Command options
 	 * @throws {Error} On timeout or non-zero exit code. Error has 'status', 'signal', 'output', 'stdout', 'stderr'
 	 */
-	public static execSync(command: string, options?: ExecSyncOptions) {
-		try {
-			return execSync(command, options);
-		} catch (error) {
-			// execSync may throw an error during process interruption
-			// if this happens - stderr will end with "^C" which was appended in the checkExecSyncError function
-			// this means that a SIGINT was attempted and failed
-			// npm may be involved in this as it works just fine with any other node process
-			if (error.stderr && error.stderr.toString().endsWith() === "^C") {
+	public static execSync(command: string | string[], options?: ExecSyncOptions) {
+		if (typeof command === "string") {
+			try {
+				return execSync(command, options);
+			} catch (error) {
+				// execSync may throw an error during process interruption
+				// if this happens - stderr will end with "^C" which was appended in the checkExecSyncError function
+				// this means that a SIGINT was attempted and failed
+				// npm may be involved in this as it works just fine with any other node process
+				if (error.stderr && error.stderr.toString().endsWith() === "^C") {
+					return process.exit();
+				}
+
+				// if SIGINT killed the process with no errors
+				// 3221225786 - cmd- Ctrl+C
+				// 128 - bash - invalid argument to exit
+				// 130 - bash - Ctrl+C
+				// 255 - bash - exit status out of range
+				if (error.status === 3221225786 || error.status > 128) {
+					return process.exit();
+				}
+
+				throw error;
+			}
+		} else {
+			const [file, ...args] = command;
+			const spawnOptions: SpawnSyncOptions = options || {};
+			const result = spawnSync(file, args, spawnOptions);
+
+			if (result.error) {
+				// Re-throw underlying error where possible to keep behaviour similar to execSync
+				throw result.error;
+			}
+
+			if (result.stderr && result.stderr.toString().endsWith() === "^C") {
 				return process.exit();
 			}
 
-			// if SIGINT killed the process with no errors
-			// 3221225786 - cmd- Ctrl+C
-			// 128 - bash - invalid argument to exit
-			// 130 - bash - Ctrl+C
-			// 255 - bash - exit status out of range
-			if (error.status === 3221225786 || error.status > 128) {
+			if (result.status === 3221225786 || (typeof result.status === "number" && result.status > 128)) {
 				return process.exit();
 			}
 
-			throw error;
+			if (result.status && result.status !== 0) {
+				const error: any = new Error(`Command failed: ${file} ${args.join(" ")}`);
+				error.status = result.status;
+				error.signal = result.signal;
+				error.stdout = result.stdout;
+				error.stderr = result.stderr;
+				error.output = result.output;
+				throw error;
+			}
+
+			return result.stdout;
 		}
 	}
 
EOF
Copilot is powered by AI and may make mistakes. Always verify output.
return false;
}
} else {
Util.execSync(`npm config set @infragistics:registry ${fullPackageRegistry}`);

Check warning

Code scanning / CodeQL

Unsafe shell command constructed from library input Medium

This string concatenation which depends on
library input
is later used in a
shell command
.

Copilot Autofix

AI about 13 hours ago

In general, to fix this issue you should stop concatenating potentially untrusted input into a single string command executed by a shell. Instead, call child_process.execFileSync/spawnSync (or equivalent) with the command name and a list of arguments so that the shell does not interpret special characters. If you must use a shell, then you should properly quote/escape untrusted arguments using a robust library (for example shell-quote) rather than a custom sanitizer.

For this specific case, the commands being run are plain npm commands without pipes or redirections, so they can be safely executed without a shell. A minimal change that preserves existing functionality is:

  • Add a new helper method in Util (e.g. execFileSyncSafe) that wraps child_process.spawnSync (already imported) to execute a command and arguments array without a shell, reusing the existing error-handling semantics of execSync as closely as practical.
  • Update the three problematic calls in PackageManager.ensureRegistryUser:
    • Util.execSync(\npm whoami --registry=${fullPackageRegistry}`, ...)`
    • Util.execSync(\npm login --registry=${fullPackageRegistry} --scope=@Infragistics --auth-type=legacy`, ...)`
    • Util.execSync(\npm config set @Infragistics:registry ${fullPackageRegistry}`);`
      to instead call the new helper with explicit argument arrays:
    • Util.execFileSyncSafe("npm", ["whoami", --registry=${fullPackageRegistry}], {...});
    • Util.execFileSyncSafe("npm", ["login", --registry=${fullPackageRegistry}, "--scope=@infragistics", "--auth-type=legacy"], {...});
    • Util.execFileSyncSafe("npm", ["config", "set", "@infragistics:registry", fullPackageRegistry]);

Because spawnSync is already imported in Util.ts, no new imports are needed. The new helper should throw an Error in a way consistent with execSync when the child process fails (non‑zero exit code), so callers like ensureRegistryUser keep working as before.


Suggested changeset 2
packages/core/packages/PackageManager.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/core/packages/PackageManager.ts b/packages/core/packages/PackageManager.ts
--- a/packages/core/packages/PackageManager.ts
+++ b/packages/core/packages/PackageManager.ts
@@ -227,7 +227,7 @@
 		const fullPackageRegistry = Util.sanitizeShellArg(config.igPackageRegistry);
 		try {
 			// tslint:disable-next-line:object-literal-sort-keys
-			Util.execSync(`npm whoami --registry=${fullPackageRegistry}`, { stdio: "pipe", encoding: "utf8" });
+			Util.execFileSyncSafe("npm", ["whoami", `--registry=${fullPackageRegistry}`], { stdio: "pipe", encoding: "utf8" });
 		} catch (error) {
 			// try registering the user:
 			Util.log(
@@ -250,12 +250,18 @@
 			}
 
 			try {
-				Util.execSync(
-					`npm login --registry=${fullPackageRegistry} --scope=@infragistics --auth-type=legacy`,
+				Util.execFileSyncSafe(
+					"npm",
+					[
+						"login",
+						`--registry=${fullPackageRegistry}`,
+						"--scope=@infragistics",
+						"--auth-type=legacy"
+					],
 					{ stdio: "inherit" }
 				);
 				//make sure scope is configured:
-				Util.execSync(`npm config set @infragistics:registry ${fullPackageRegistry}`);
+				Util.execFileSyncSafe("npm", ["config", "set", "@infragistics:registry", fullPackageRegistry]);
 				return true;
 			} catch (error) {
 				Util.log(message, "red");
EOF
@@ -227,7 +227,7 @@
const fullPackageRegistry = Util.sanitizeShellArg(config.igPackageRegistry);
try {
// tslint:disable-next-line:object-literal-sort-keys
Util.execSync(`npm whoami --registry=${fullPackageRegistry}`, { stdio: "pipe", encoding: "utf8" });
Util.execFileSyncSafe("npm", ["whoami", `--registry=${fullPackageRegistry}`], { stdio: "pipe", encoding: "utf8" });
} catch (error) {
// try registering the user:
Util.log(
@@ -250,12 +250,18 @@
}

try {
Util.execSync(
`npm login --registry=${fullPackageRegistry} --scope=@infragistics --auth-type=legacy`,
Util.execFileSyncSafe(
"npm",
[
"login",
`--registry=${fullPackageRegistry}`,
"--scope=@infragistics",
"--auth-type=legacy"
],
{ stdio: "inherit" }
);
//make sure scope is configured:
Util.execSync(`npm config set @infragistics:registry ${fullPackageRegistry}`);
Util.execFileSyncSafe("npm", ["config", "set", "@infragistics:registry", fullPackageRegistry]);
return true;
} catch (error) {
Util.log(message, "red");
packages/core/util/Util.ts
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/core/util/Util.ts b/packages/core/util/Util.ts
--- a/packages/core/util/Util.ts
+++ b/packages/core/util/Util.ts
@@ -363,6 +363,43 @@
 	}
 
 	/**
+	 * Execute a command without involving a shell, using argument array.
+	 * Mirrors execSync error handling semantics as closely as possible.
+	 * @param command Command to be executed
+	 * @param args Arguments to pass to the command
+	 * @param options Command options
+	 */
+	public static execFileSyncSafe(command: string, args: string[], options?: SpawnSyncOptions) {
+		const result = spawnSync(command, args, options);
+
+		if (result.error) {
+			throw result.error;
+		}
+
+		if (result.signal || result.status && result.status !== 0) {
+			const error: any = new Error(`Command failed: ${command} ${args.join(" ")}`);
+			error.status = result.status;
+			error.signal = result.signal;
+			error.stdout = result.stdout;
+			error.stderr = result.stderr;
+			error.output = result.output;
+
+			// Mimic existing execSync interruption handling:
+			if (error.stderr && error.stderr.toString().endsWith() === "^C") {
+				return process.exit();
+			}
+
+			if (error.status === 3221225786 || error.status > 128) {
+				return process.exit();
+			}
+
+			throw error;
+		}
+
+		return result.stdout;
+	}
+
+	/**
 	 * Execute synchronous command with options using spawnSync
 	 * @param command Command to be executed
 	 * NOTE: `spawn` without `shell` (unsafe) is **not** equivalent to `exec` & requires direct path to run the correct process on win,
EOF
@@ -363,6 +363,43 @@
}

/**
* Execute a command without involving a shell, using argument array.
* Mirrors execSync error handling semantics as closely as possible.
* @param command Command to be executed
* @param args Arguments to pass to the command
* @param options Command options
*/
public static execFileSyncSafe(command: string, args: string[], options?: SpawnSyncOptions) {
const result = spawnSync(command, args, options);

if (result.error) {
throw result.error;
}

if (result.signal || result.status && result.status !== 0) {
const error: any = new Error(`Command failed: ${command} ${args.join(" ")}`);
error.status = result.status;
error.signal = result.signal;
error.stdout = result.stdout;
error.stderr = result.stderr;
error.output = result.output;

// Mimic existing execSync interruption handling:
if (error.stderr && error.stderr.toString().endsWith() === "^C") {
return process.exit();
}

if (error.status === 3221225786 || error.status > 128) {
return process.exit();
}

throw error;
}

return result.stdout;
}

/**
* Execute synchronous command with options using spawnSync
* @param command Command to be executed
* NOTE: `spawn` without `shell` (unsafe) is **not** equivalent to `exec` & requires direct path to run the correct process on win,
Copilot is powered by AI and may make mistakes. Always verify output.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Start command not working as expected

3 participants