Skip to content

Commit a356169

Browse files
authored
Merge pull request #1520 from IgniteUI/dpetev/fix-start-process-exec
Fix start and other npm exec commands
2 parents 0157241 + cbb6502 commit a356169

File tree

9 files changed

+143
-258
lines changed

9 files changed

+143
-258
lines changed

packages/cli/lib/commands/start.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,16 @@ import { PositionalArgs, StartCommandType } from "./types";
77
import { ArgumentsCamelCase } from "yargs";
88

99
const execSyncNpmStart = (port: number, options: ExecSyncOptions): void => {
10-
const args = ['start'];
1110
if (port) {
1211
// Validate port is a number to prevent command injection
1312
if (!Number.isInteger(port) || port < 0 || port > 65535) {
1413
Util.error(`Invalid port number: ${port}`, "red");
1514
return;
1615
}
17-
args.push('--', `--port=${port}`);
16+
Util.execSync(`npm start -- --port=${port}`, options);
17+
return;
1818
}
19-
Util.spawnSync('npm', args, options);
19+
Util.execSync(`npm start`, options);
2020
};
2121

2222
const command: StartCommandType = {

packages/core/packages/PackageManager.ts

Lines changed: 33 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { spawn } from "child_process";
1+
import { exec } from "child_process";
22
import * as path from "path";
33
import { TemplateManager } from "../../cli/lib/TemplateManager";
44
import { Config, FS_TOKEN, IFileSystem, ProjectTemplate } from "../types";
@@ -88,9 +88,8 @@ export class PackageManager {
8888
const config = ProjectConfig.localConfig();
8989
if (!config.packagesInstalled) {
9090
let command: string;
91-
let managerCommand: string;
91+
const managerCommand = this.getManager();
9292

93-
managerCommand = this.getManager();
9493
switch (managerCommand) {
9594
case "npm":
9695
/* passes through */
@@ -123,49 +122,52 @@ export class PackageManager {
123122
}
124123

125124
public static removePackage(packageName: string, verbose: boolean = false): boolean {
125+
let command: string;
126126
const managerCommand = this.getManager();
127-
let args: string[];
127+
const sanitizePackage = Util.sanitizeShellArg(packageName);
128128
switch (managerCommand) {
129129
case "npm":
130130
/* passes through */
131131
default:
132-
args = ['uninstall', packageName, '--quiet', '--save'];
132+
command = `${managerCommand} uninstall ${sanitizePackage} --quiet --save`;
133133
break;
134134
}
135135
try {
136136
// tslint:disable-next-line:object-literal-sort-keys
137-
Util.spawnSync(managerCommand, args, { stdio: "pipe", encoding: "utf8" });
137+
Util.execSync(command, { stdio: "pipe", encoding: "utf8" });
138138
} catch (error) {
139-
Util.log(`Error uninstalling package ${packageName} with ${managerCommand}`);
139+
Util.log(`Error uninstalling package ${sanitizePackage} with ${managerCommand}`);
140140
if (verbose) {
141141
Util.log(error.message);
142142
}
143143
return false;
144144
}
145145

146-
Util.log(`Package ${packageName} uninstalled successfully`);
146+
Util.log(`Package ${sanitizePackage} uninstalled successfully`);
147147
return true;
148148
}
149149

150150
public static addPackage(packageName: string, verbose: boolean = false): boolean {
151151
const managerCommand = this.getManager();
152-
const args = this.getInstallArgs(packageName);
152+
const sanitizePackage = Util.sanitizeShellArg(packageName);
153+
const command = this.getInstallCommand(managerCommand, sanitizePackage);
153154
try {
154155
// tslint:disable-next-line:object-literal-sort-keys
155-
Util.spawnSync(managerCommand, args, { stdio: "pipe", encoding: "utf8" });
156+
Util.execSync(command, { stdio: "pipe", encoding: "utf8" });
156157
} catch (error) {
157-
Util.log(`Error installing package ${packageName} with ${managerCommand}`);
158+
Util.log(`Error installing package ${sanitizePackage} with ${managerCommand}`);
158159
if (verbose) {
159160
Util.log(error.message);
160161
}
161162
return false;
162163
}
163-
Util.log(`Package ${packageName} installed successfully`);
164+
Util.log(`Package ${sanitizePackage} installed successfully`);
164165
return true;
165166
}
166167

167168
public static async queuePackage(packageName: string, verbose = false) {
168-
const args = this.getInstallArgs(packageName).map(arg => arg === '--save' ? '--no-save' : arg);
169+
const command = this.getInstallCommand(this.getManager(), Util.sanitizeShellArg(packageName))
170+
.replace("--save", "--no-save");
169171
const [packName, version] = packageName.split(/@(?=[^\/]+$)/);
170172
const packageJSON = this.getPackageJSON();
171173
if (!packageJSON.dependencies) {
@@ -190,24 +192,13 @@ export class PackageManager {
190192
// D.P. Concurrent install runs should be supported
191193
// https://github.com/npm/npm/issues/5948
192194
// https://github.com/npm/npm/issues/2500
193-
const managerCommand = this.getManager();
194195
const task = new Promise<{ packageName, error, stdout, stderr }>((resolve, reject) => {
195-
const child = spawn(managerCommand, args);
196-
let stdout = '';
197-
let stderr = '';
198-
child.stdout?.on('data', (data) => {
199-
stdout += data.toString();
200-
});
201-
child.stderr?.on('data', (data) => {
202-
stderr += data.toString();
203-
});
204-
child.on('close', (code) => {
205-
const error = code !== 0 ? new Error(`Process exited with code ${code}`) : null;
206-
resolve({ packageName, error, stdout, stderr });
207-
});
208-
child.on('error', (err) => {
209-
resolve({ packageName, error: err, stdout, stderr });
210-
});
196+
const child = exec(
197+
command, {},
198+
(error, stdout, stderr) => {
199+
resolve({ packageName, error, stdout, stderr });
200+
}
201+
);
211202
});
212203
task["packageName"] = packName;
213204
this.installQueue.push(task);
@@ -233,10 +224,10 @@ export class PackageManager {
233224
}
234225

235226
public static ensureRegistryUser(config: Config, message: string): boolean {
236-
const fullPackageRegistry = config.igPackageRegistry;
227+
const fullPackageRegistry = Util.sanitizeShellArg(config.igPackageRegistry);
237228
try {
238229
// tslint:disable-next-line:object-literal-sort-keys
239-
Util.spawnSync('npm', ['whoami', `--registry=${fullPackageRegistry}`], { stdio: 'pipe', encoding: 'utf8' });
230+
Util.execSync(`npm whoami --registry=${fullPackageRegistry}`, { stdio: "pipe", encoding: "utf8" });
240231
} catch (error) {
241232
// try registering the user:
242233
Util.log(
@@ -257,20 +248,16 @@ export class PackageManager {
257248
if (process.stdin.isTTY) {
258249
process.stdin.setRawMode(true);
259250
}
260-
const cmd = /^win/.test(process.platform) ? "npm.cmd" : "npm"; //https://github.com/nodejs/node/issues/3675
261-
const login = Util.spawnSync(cmd,
262-
["adduser", `--registry=${fullPackageRegistry}`, `--scope=@infragistics`, `--auth-type=legacy`],
263-
{ stdio: "inherit" }
264-
);
265-
if (login?.status === 0) {
251+
252+
try {
253+
Util.execSync(
254+
`npm login --registry=${fullPackageRegistry} --scope=@infragistics --auth-type=legacy`,
255+
{ stdio: "inherit" }
256+
);
266257
//make sure scope is configured:
267-
try {
268-
Util.spawnSync('npm', ['config', 'set', `@infragistics:registry`, fullPackageRegistry]);
269-
return true;
270-
} catch (error) {
271-
return false;
272-
}
273-
} else {
258+
Util.execSync(`npm config set @infragistics:registry ${fullPackageRegistry}`);
259+
return true;
260+
} catch (error) {
274261
Util.log(message, "red");
275262
return false;
276263
}
@@ -292,11 +279,7 @@ export class PackageManager {
292279
}
293280
}
294281

295-
private static getInstallArgs(packageName: string): string[] {
296-
return ['install', packageName, '--quiet', '--save'];
297-
}
298-
299-
private static getManager(/*config:Config*/): string {
282+
private static getManager(/*config:Config*/): 'npm' {
300283
//stub to potentially swap out managers
301284
return "npm";
302285
}

packages/core/util/Util.ts

Lines changed: 28 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -283,11 +283,6 @@ export class Util {
283283
}
284284

285285
for (const key of Object.keys(source)) {
286-
// Skip keys that can affect the prototype of objects to avoid prototype pollution vulnerabilities
287-
if (key === "__proto__" || key === "constructor") {
288-
continue;
289-
}
290-
291286
const sourceKeyIsArray = Array.isArray(source[key]);
292287
const targetHasThisKey = target.hasOwnProperty(key);
293288

@@ -321,6 +316,21 @@ export class Util {
321316
}
322317
}
323318

319+
/**
320+
* Fairly aggressive sanitize removing anything but ASCII, numbers and a few needed chars that have no action:
321+
* - semicolons (:), dots (.), underscores (_) for paths/URLs
322+
* - dashes (-) & forward slashes (/) for packages and paths/URLs
323+
* - at (@), non-leading tilde (~) for package scope & version
324+
* @remarks
325+
* Most shells should be UTF-enabled, but current usage is very limited thus no need for `\p{L}`
326+
*/
327+
public static sanitizeShellArg(arg: string): string {
328+
return arg
329+
.replace(/[^a-z0-9@:_~\^\/\.\-]/gi, '') // remove unsafe chars
330+
.replace(/\^(?!\d)/g, '') // keep only ^<digit>
331+
.replace(/^~/, ''); // remove leading ~
332+
}
333+
324334
/**
325335
* Execute synchronous command with options
326336
* @param command Command to be executed
@@ -353,29 +363,18 @@ export class Util {
353363
}
354364

355365
/**
356-
* Execute synchronous command with options using spawnSync
357-
* @param command Command to be executed
358-
* @param args Command arguments
359-
* @param options Command options
360-
* @throws {Error} On non-zero exit code. Error has 'status', 'signal', 'output', 'stdout', 'stderr'
361-
*/
362-
public static spawnSync(command: string, args: string[], options?: SpawnSyncOptions) {
363-
try {
364-
return spawnSync(command, args, options);
365-
} catch (error) {
366-
// Handle potential process interruption
367-
// Check if the error output ends with "^C"
368-
if (error.stderr && error.stderr.toString().endsWith() === "^C") {
369-
return process.exit();
370-
}
371-
372-
// Handle specific exit codes for different signals
373-
if (error.status === 3221225786 || error.status > 128) {
374-
return process.exit();
375-
}
376-
377-
throw error;
378-
}
366+
* Execute synchronous command with options using spawnSync
367+
* @param command Command to be executed
368+
* NOTE: `spawn` without `shell` (unsafe) is **not** equivalent to `exec` & requires direct path to run the correct process on win,
369+
* e.g. `npm.cmd` but that is also blocked in node@24+ for security reasons
370+
* Do not call with/add commands that are not known binaries without validating first
371+
* @param args Command arguments
372+
* @param options Command options
373+
* @returns {SpawnSyncReturns} object with status and stdout
374+
* @remarks Consuming code MUST handle the result and check for failure status!
375+
*/
376+
public static spawnSync(command: 'node' | 'git', args: string[], options?: Omit<SpawnSyncOptions, 'shell'>) {
377+
return spawnSync(command, args, options);
379378
}
380379

381380
/**
@@ -388,8 +387,7 @@ export class Util {
388387
const options: any = { cwd: path.join(parentRoot, projectName), stdio: [process.stdin, "ignore", "ignore"] };
389388
Util.execSync("git init", options);
390389
Util.execSync("git add .", options);
391-
const commitMessage = `"Initial commit for project: ${projectName}"`;
392-
Util.spawnSync('git', ['commit', '-m', commitMessage], options);
390+
Util.execSync("git commit -m " + "\"Initial commit for project\"", options);
393391
Util.log(Util.greenCheck() + " Git Initialized and Project '" + projectName + "' Committed");
394392
} catch (error) {
395393
Util.error("Git initialization failed. Install Git in order to automatically commit the project.", "yellow");

packages/ng-schematics/src/ng-new/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ export function newProject(options: OptionsSchema): Rule {
174174
}
175175
if (!options.skipGit) {
176176
const gitTask = context.addTask(
177-
new RepositoryInitializerTask(options.name, { message: `Initial commit for project: ${options.name}` }),
177+
new RepositoryInitializerTask(options.name, { message: `Initial commit for project` }),
178178
[...installChain] //copy
179179
);
180180
installChain.push(gitTask);

packages/ng-schematics/src/ng-new/index_spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ describe("Schematics ng-new", () => {
152152
authorEmail: undefined,
153153
authorName: undefined,
154154
commit: true,
155-
message: `Initial commit for project: ${workingDirectory}`
155+
message: `Initial commit for project`
156156
};
157157
const expectedStart: RunSchematicTaskOptions<any> = {
158158
collection: null,
@@ -206,7 +206,7 @@ describe("Schematics ng-new", () => {
206206
authorEmail: undefined,
207207
authorName: undefined,
208208
commit: true,
209-
message: `Initial commit for project: ${workingDirectory}`
209+
message: `Initial commit for project`
210210
};
211211
expect(taskOptions.length).toBe(2);
212212
expect(mockProject.upgradeIgniteUIPackages).toHaveBeenCalled();

spec/acceptance/new-spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ describe("New command", () => {
135135
process.chdir(projectName);
136136
expect(fs.existsSync(".git")).toBeTruthy();
137137
expect(Util.execSync("git log -1 --pretty=format:'%s'").toString())
138-
.toMatch("Initial commit for project: " + projectName);
138+
.toMatch("Initial commit for project");
139139
process.chdir("../");
140140
testFolder = "./angularProj";
141141
});

spec/unit/new-spec.ts

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -284,24 +284,12 @@ describe("Unit - New command", () => {
284284
getProjectLibrary: mockProjLib
285285
});
286286

287-
spyOn(Util, "spawnSync").and.returnValues({
288-
status: 0,
289-
pid: 0,
290-
output: [],
291-
stdout: "",
292-
stderr: "",
293-
signal: "SIGABRT"
294-
});
295-
296287
await newCmd.handler({ name: projectName, framework: "jq", _: ["new"], $0: "new" });
297288

298289
expect(Util.execSync).toHaveBeenCalledWith("git init", jasmine.any(Object));
299290
expect(Util.execSync).toHaveBeenCalledWith("git add .", jasmine.any(Object));
300-
expect(Util.spawnSync).toHaveBeenCalledWith(
301-
"git",
302-
['commit', '-m', `"Initial commit for project: ${projectName}"`],
303-
{ cwd: path.join(process.cwd(), projectName), stdio: [process.stdin, "ignore", "ignore"] }
304-
);
291+
expect(Util.execSync).toHaveBeenCalledWith("git commit -m \"Initial commit for project\"",
292+
jasmine.any(Object));
305293
expect(Util.log).toHaveBeenCalledWith(
306294
jasmine.stringMatching("Git Initialized and Project '" + projectName + "' Committed")
307295
);

0 commit comments

Comments
 (0)