@aikidosec/safe-chain 1.1.5 → 1.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/package.json +1 -1
- package/src/api/npmApi.js +4 -0
- package/src/config/cliArguments.js +15 -15
- package/src/config/settings.js +7 -7
- package/src/environment/userInteraction.js +29 -30
- package/src/main.js +15 -0
- package/src/registryProxy/mitmRequestHandler.js +28 -1
- package/src/registryProxy/plainHttpProxy.js +7 -2
- package/src/registryProxy/registryProxy.js +1 -1
- package/src/registryProxy/tunnelRequestHandler.js +15 -6
- package/src/scanning/index.js +4 -19
- package/src/utils/safeSpawn.js +63 -8
package/README.md
CHANGED
|
@@ -41,6 +41,7 @@ Installing the Aikido Safe Chain is easy. You just need 3 simple steps:
|
|
|
41
41
|
When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, or `bunx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. If any malware is detected, it will prompt you to exit the command.
|
|
42
42
|
|
|
43
43
|
You can check the installed version by running:
|
|
44
|
+
|
|
44
45
|
```shell
|
|
45
46
|
safe-chain --version
|
|
46
47
|
```
|
|
@@ -75,17 +76,16 @@ To uninstall the Aikido Safe Chain, you can run the following command:
|
|
|
75
76
|
|
|
76
77
|
# Configuration
|
|
77
78
|
|
|
78
|
-
##
|
|
79
|
+
## Logging
|
|
79
80
|
|
|
80
|
-
You can control
|
|
81
|
+
You can control the output from Aikido Safe Chain using the `--safe-chain-logging` flag:
|
|
81
82
|
|
|
82
|
-
- `--safe-chain-
|
|
83
|
-
- `--safe-chain-malware-action=prompt` - Prompts the user to decide whether to continue despite the malware detection
|
|
83
|
+
- `--safe-chain-logging=silent` - Suppresses all Aikido Safe Chain output except when malware is blocked. The package manager output is written to stdout as normal, and Safe Chain only writes a short message if it has blocked malware and causes the process to exit.
|
|
84
84
|
|
|
85
85
|
Example usage:
|
|
86
86
|
|
|
87
87
|
```shell
|
|
88
|
-
npm install
|
|
88
|
+
npm install express --safe-chain-logging=silent
|
|
89
89
|
```
|
|
90
90
|
|
|
91
91
|
# Usage in CI/CD
|
package/package.json
CHANGED
package/src/api/npmApi.js
CHANGED
|
@@ -25,6 +25,10 @@ export async function resolvePackageVersion(packageName, versionRange) {
|
|
|
25
25
|
return distTags[versionRange];
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
if (!packageInfo.versions) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
28
32
|
// If the version range is not a dist-tag, we need to resolve the highest version matching the range.
|
|
29
33
|
// This is useful for ranges like "^1.0.0" or "~2.3.4".
|
|
30
34
|
const availableVersions = Object.keys(packageInfo.versions);
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
const state = {
|
|
2
|
-
|
|
2
|
+
loggingLevel: undefined,
|
|
3
3
|
};
|
|
4
4
|
|
|
5
5
|
const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-";
|
|
6
6
|
|
|
7
7
|
export function initializeCliArguments(args) {
|
|
8
8
|
// Reset state on each call
|
|
9
|
-
state.
|
|
9
|
+
state.loggingLevel = undefined;
|
|
10
10
|
|
|
11
11
|
const safeChainArgs = [];
|
|
12
12
|
const remainingArgs = [];
|
|
@@ -19,21 +19,11 @@ export function initializeCliArguments(args) {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
setLoggingLevel(safeChainArgs);
|
|
23
23
|
|
|
24
24
|
return remainingArgs;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
function setMalwareAction(args) {
|
|
28
|
-
const safeChainMalwareActionArg = SAFE_CHAIN_ARG_PREFIX + "malware-action=";
|
|
29
|
-
|
|
30
|
-
const action = getLastArgEqualsValue(args, safeChainMalwareActionArg);
|
|
31
|
-
if (!action) {
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
state.malwareAction = action.toLowerCase();
|
|
35
|
-
}
|
|
36
|
-
|
|
37
27
|
function getLastArgEqualsValue(args, prefix) {
|
|
38
28
|
for (var i = args.length - 1; i >= 0; i--) {
|
|
39
29
|
const arg = args[i];
|
|
@@ -45,6 +35,16 @@ function getLastArgEqualsValue(args, prefix) {
|
|
|
45
35
|
return undefined;
|
|
46
36
|
}
|
|
47
37
|
|
|
48
|
-
|
|
49
|
-
|
|
38
|
+
function setLoggingLevel(args) {
|
|
39
|
+
const safeChainLoggingArg = SAFE_CHAIN_ARG_PREFIX + "logging=";
|
|
40
|
+
|
|
41
|
+
const level = getLastArgEqualsValue(args, safeChainLoggingArg);
|
|
42
|
+
if (!level) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
state.loggingLevel = level.toLowerCase();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getLoggingLevel() {
|
|
49
|
+
return state.loggingLevel;
|
|
50
50
|
}
|
package/src/config/settings.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import * as cliArguments from "./cliArguments.js";
|
|
2
2
|
|
|
3
|
-
export function
|
|
4
|
-
const
|
|
3
|
+
export function getLoggingLevel() {
|
|
4
|
+
const level = cliArguments.getLoggingLevel();
|
|
5
5
|
|
|
6
|
-
if (
|
|
7
|
-
return
|
|
6
|
+
if (level === LOGGING_SILENT) {
|
|
7
|
+
return LOGGING_SILENT;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
return
|
|
10
|
+
return LOGGING_NORMAL;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export const
|
|
14
|
-
export const
|
|
13
|
+
export const LOGGING_SILENT = "silent";
|
|
14
|
+
export const LOGGING_NORMAL = "normal";
|
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
// oxlint-disable no-console
|
|
2
2
|
import chalk from "chalk";
|
|
3
3
|
import ora from "ora";
|
|
4
|
-
import { createInterface } from "readline";
|
|
5
4
|
import { isCi } from "./environment.js";
|
|
5
|
+
import { getLoggingLevel, LOGGING_SILENT } from "../config/settings.js";
|
|
6
|
+
|
|
7
|
+
function isSilentMode() {
|
|
8
|
+
return getLoggingLevel() === LOGGING_SILENT;
|
|
9
|
+
}
|
|
6
10
|
|
|
7
11
|
function emptyLine() {
|
|
12
|
+
if (isSilentMode()) return;
|
|
13
|
+
|
|
8
14
|
writeInformation("");
|
|
9
15
|
}
|
|
10
16
|
|
|
11
17
|
function writeInformation(message, ...optionalParams) {
|
|
18
|
+
if (isSilentMode()) return;
|
|
19
|
+
|
|
12
20
|
console.log(message, ...optionalParams);
|
|
13
21
|
}
|
|
14
22
|
|
|
15
23
|
function writeWarning(message, ...optionalParams) {
|
|
24
|
+
if (isSilentMode()) return;
|
|
25
|
+
|
|
16
26
|
if (!isCi()) {
|
|
17
27
|
message = chalk.yellow(message);
|
|
18
28
|
}
|
|
@@ -26,7 +36,24 @@ function writeError(message, ...optionalParams) {
|
|
|
26
36
|
console.error(message, ...optionalParams);
|
|
27
37
|
}
|
|
28
38
|
|
|
39
|
+
function writeExitWithoutInstallingMaliciousPackages() {
|
|
40
|
+
let message = "Safe-chain: Exiting without installing malicious packages.";
|
|
41
|
+
if (!isCi()) {
|
|
42
|
+
message = chalk.red(message);
|
|
43
|
+
}
|
|
44
|
+
console.error(message);
|
|
45
|
+
}
|
|
46
|
+
|
|
29
47
|
function startProcess(message) {
|
|
48
|
+
if (isSilentMode()) {
|
|
49
|
+
return {
|
|
50
|
+
succeed: () => {},
|
|
51
|
+
fail: () => {},
|
|
52
|
+
stop: () => {},
|
|
53
|
+
setText: () => {},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
30
57
|
if (isCi()) {
|
|
31
58
|
return {
|
|
32
59
|
succeed: (message) => {
|
|
@@ -59,39 +86,11 @@ function startProcess(message) {
|
|
|
59
86
|
}
|
|
60
87
|
}
|
|
61
88
|
|
|
62
|
-
async function confirm(config) {
|
|
63
|
-
if (isCi()) {
|
|
64
|
-
return Promise.resolve(config.default);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const rl = createInterface({
|
|
68
|
-
input: process.stdin,
|
|
69
|
-
output: process.stdout,
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
return new Promise((resolve) => {
|
|
73
|
-
const defaultText = config.default ? " (Y/n)" : " (y/N)";
|
|
74
|
-
rl.question(`${config.message}${defaultText} `, (answer) => {
|
|
75
|
-
rl.close();
|
|
76
|
-
|
|
77
|
-
const normalizedAnswer = answer.trim().toLowerCase();
|
|
78
|
-
|
|
79
|
-
if (normalizedAnswer === "y" || normalizedAnswer === "yes") {
|
|
80
|
-
resolve(true);
|
|
81
|
-
} else if (normalizedAnswer === "n" || normalizedAnswer === "no") {
|
|
82
|
-
resolve(false);
|
|
83
|
-
} else {
|
|
84
|
-
resolve(config.default);
|
|
85
|
-
}
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
|
|
90
89
|
export const ui = {
|
|
91
90
|
writeInformation,
|
|
92
91
|
writeWarning,
|
|
93
92
|
writeError,
|
|
93
|
+
writeExitWithoutInstallingMaliciousPackages,
|
|
94
94
|
emptyLine,
|
|
95
95
|
startProcess,
|
|
96
|
-
confirm,
|
|
97
96
|
};
|
package/src/main.js
CHANGED
|
@@ -11,6 +11,21 @@ export async function main(args) {
|
|
|
11
11
|
const proxy = createSafeChainProxy();
|
|
12
12
|
await proxy.startServer();
|
|
13
13
|
|
|
14
|
+
// Global error handlers to log unhandled errors
|
|
15
|
+
process.on("uncaughtException", (error) => {
|
|
16
|
+
ui.writeError(`Safe-chain: Uncaught exception: ${error.message}`);
|
|
17
|
+
ui.writeVerbose(`Stack trace: ${error.stack}`);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
process.on("unhandledRejection", (reason) => {
|
|
22
|
+
ui.writeError(`Safe-chain: Unhandled promise rejection: ${reason}`);
|
|
23
|
+
if (reason instanceof Error) {
|
|
24
|
+
ui.writeVerbose(`Stack trace: ${reason.stack}`);
|
|
25
|
+
}
|
|
26
|
+
process.exit(1);
|
|
27
|
+
});
|
|
28
|
+
|
|
14
29
|
try {
|
|
15
30
|
// This parses all the --safe-chain arguments and removes them from the args array
|
|
16
31
|
args = initializeCliArguments(args);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import https from "https";
|
|
2
2
|
import { generateCertForHost } from "./certUtils.js";
|
|
3
3
|
import { HttpsProxyAgent } from "https-proxy-agent";
|
|
4
|
+
import { ui } from "../environment/userInteraction.js";
|
|
4
5
|
|
|
5
6
|
export function mitmConnect(req, clientSocket, isAllowed) {
|
|
6
7
|
const { hostname } = new URL(`http://${req.url}`);
|
|
@@ -13,6 +14,15 @@ export function mitmConnect(req, clientSocket, isAllowed) {
|
|
|
13
14
|
|
|
14
15
|
const server = createHttpsServer(hostname, isAllowed);
|
|
15
16
|
|
|
17
|
+
server.on("error", (err) => {
|
|
18
|
+
ui.writeError(`Safe-chain: HTTPS server error: ${err.message}`);
|
|
19
|
+
if (!clientSocket.headersSent) {
|
|
20
|
+
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
|
21
|
+
} else if (clientSocket.writable) {
|
|
22
|
+
clientSocket.end();
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
16
26
|
// Establish the connection
|
|
17
27
|
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
18
28
|
|
|
@@ -37,13 +47,15 @@ function createHttpsServer(hostname, isAllowed) {
|
|
|
37
47
|
forwardRequest(req, hostname, res);
|
|
38
48
|
}
|
|
39
49
|
|
|
40
|
-
|
|
50
|
+
const server = https.createServer(
|
|
41
51
|
{
|
|
42
52
|
key: cert.privateKey,
|
|
43
53
|
cert: cert.certificate,
|
|
44
54
|
},
|
|
45
55
|
handleRequest
|
|
46
56
|
);
|
|
57
|
+
|
|
58
|
+
return server;
|
|
47
59
|
}
|
|
48
60
|
|
|
49
61
|
function getRequestPathAndQuery(url) {
|
|
@@ -62,6 +74,11 @@ function forwardRequest(req, hostname, res) {
|
|
|
62
74
|
res.end("Bad Gateway");
|
|
63
75
|
});
|
|
64
76
|
|
|
77
|
+
req.on("error", (err) => {
|
|
78
|
+
ui.writeError(`Safe-chain: Error reading client request: ${err.message}`);
|
|
79
|
+
proxyReq.destroy();
|
|
80
|
+
});
|
|
81
|
+
|
|
65
82
|
req.on("data", (chunk) => {
|
|
66
83
|
proxyReq.write(chunk);
|
|
67
84
|
});
|
|
@@ -88,6 +105,16 @@ function createProxyRequest(hostname, req, res) {
|
|
|
88
105
|
}
|
|
89
106
|
|
|
90
107
|
const proxyReq = https.request(options, (proxyRes) => {
|
|
108
|
+
proxyRes.on("error", (err) => {
|
|
109
|
+
ui.writeError(
|
|
110
|
+
`Safe-chain: Error reading upstream response: ${err.message}`
|
|
111
|
+
);
|
|
112
|
+
if (!res.headersSent) {
|
|
113
|
+
res.writeHead(502);
|
|
114
|
+
res.end("Bad Gateway");
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
91
118
|
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
|
92
119
|
proxyRes.pipe(res);
|
|
93
120
|
});
|
|
@@ -43,8 +43,13 @@ export function handleHttpProxyRequest(req, res) {
|
|
|
43
43
|
}
|
|
44
44
|
)
|
|
45
45
|
.on("error", (err) => {
|
|
46
|
-
res.
|
|
47
|
-
|
|
46
|
+
if (!res.headersSent) {
|
|
47
|
+
res.writeHead(502);
|
|
48
|
+
res.end(`Bad Gateway: ${err.message}`);
|
|
49
|
+
} else {
|
|
50
|
+
// Headers already sent, just destroy the response
|
|
51
|
+
res.destroy();
|
|
52
|
+
}
|
|
48
53
|
});
|
|
49
54
|
|
|
50
55
|
req.on("error", () => {
|
|
@@ -24,12 +24,6 @@ export function tunnelRequest(req, clientSocket, head) {
|
|
|
24
24
|
function tunnelRequestToDestination(req, clientSocket, head) {
|
|
25
25
|
const { port, hostname } = new URL(`http://${req.url}`);
|
|
26
26
|
|
|
27
|
-
clientSocket.on("error", () => {
|
|
28
|
-
// NO-OP
|
|
29
|
-
// This can happen if the client TCP socket sends RST instead of FIN.
|
|
30
|
-
// Not subscribing to 'close' event will cause node to throw and crash.
|
|
31
|
-
});
|
|
32
|
-
|
|
33
27
|
const serverSocket = net.connect(port || 443, hostname, () => {
|
|
34
28
|
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
35
29
|
serverSocket.write(head);
|
|
@@ -37,6 +31,14 @@ function tunnelRequestToDestination(req, clientSocket, head) {
|
|
|
37
31
|
clientSocket.pipe(serverSocket);
|
|
38
32
|
});
|
|
39
33
|
|
|
34
|
+
clientSocket.on("error", () => {
|
|
35
|
+
// This can happen if the client TCP socket sends RST instead of FIN.
|
|
36
|
+
// Not subscribing to 'error' event will cause node to throw and crash.
|
|
37
|
+
if (serverSocket.writable) {
|
|
38
|
+
serverSocket.end();
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
40
42
|
serverSocket.on("error", (err) => {
|
|
41
43
|
ui.writeError(
|
|
42
44
|
`Safe-chain: error connecting to ${hostname}:${port} - ${err.message}`
|
|
@@ -103,6 +105,13 @@ function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) {
|
|
|
103
105
|
if (clientSocket.writable) {
|
|
104
106
|
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
|
105
107
|
}
|
|
108
|
+
} else {
|
|
109
|
+
ui.writeError(
|
|
110
|
+
`Safe-chain: proxy socket error after connection - ${err.message}`
|
|
111
|
+
);
|
|
112
|
+
if (clientSocket.writable) {
|
|
113
|
+
clientSocket.end();
|
|
114
|
+
}
|
|
106
115
|
}
|
|
107
116
|
});
|
|
108
117
|
|
package/src/scanning/index.js
CHANGED
|
@@ -4,7 +4,6 @@ import { setTimeout } from "timers/promises";
|
|
|
4
4
|
import chalk from "chalk";
|
|
5
5
|
import { getPackageManager } from "../packagemanager/currentPackageManager.js";
|
|
6
6
|
import { ui } from "../environment/userInteraction.js";
|
|
7
|
-
import { getMalwareAction, MALWARE_ACTION_PROMPT } from "../config/settings.js";
|
|
8
7
|
|
|
9
8
|
export function shouldScanCommand(args) {
|
|
10
9
|
if (!args || args.length === 0) {
|
|
@@ -65,7 +64,8 @@ export async function scanCommand(args) {
|
|
|
65
64
|
return 0;
|
|
66
65
|
} else {
|
|
67
66
|
printMaliciousChanges(audit.disallowedChanges, spinner);
|
|
68
|
-
|
|
67
|
+
onMalwareFound();
|
|
68
|
+
return 1;
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
|
|
@@ -77,23 +77,8 @@ function printMaliciousChanges(changes, spinner) {
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
function onMalwareFound() {
|
|
81
81
|
ui.emptyLine();
|
|
82
|
-
|
|
83
|
-
if (getMalwareAction() === MALWARE_ACTION_PROMPT) {
|
|
84
|
-
const continueInstall = await ui.confirm({
|
|
85
|
-
message:
|
|
86
|
-
"Malicious packages were found. Do you want to continue with the installation?",
|
|
87
|
-
default: false,
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
if (continueInstall) {
|
|
91
|
-
ui.writeWarning("Continuing with the installation despite the risks...");
|
|
92
|
-
return 0;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
ui.writeError("Exiting without installing malicious packages.");
|
|
82
|
+
ui.writeExitWithoutInstallingMaliciousPackages();
|
|
97
83
|
ui.emptyLine();
|
|
98
|
-
return 1;
|
|
99
84
|
}
|
package/src/utils/safeSpawn.js
CHANGED
|
@@ -1,22 +1,77 @@
|
|
|
1
|
-
import { spawn } from "child_process";
|
|
1
|
+
import { spawn, execSync } from "child_process";
|
|
2
|
+
import os from "os";
|
|
2
3
|
|
|
3
|
-
function
|
|
4
|
-
// If argument contains
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
function sanitizeShellArgument(arg) {
|
|
5
|
+
// If argument contains shell metacharacters, wrap in double quotes
|
|
6
|
+
// and escape characters that are special even inside double quotes
|
|
7
|
+
if (hasShellMetaChars(arg)) {
|
|
8
|
+
// Inside double quotes, we need to escape: " $ ` \
|
|
9
|
+
return '"' + escapeDoubleQuoteContent(arg) + '"';
|
|
7
10
|
}
|
|
8
11
|
return arg;
|
|
9
12
|
}
|
|
10
13
|
|
|
14
|
+
function hasShellMetaChars(arg) {
|
|
15
|
+
// Shell metacharacters that need escaping
|
|
16
|
+
// These characters have special meaning in shells and need to be quoted
|
|
17
|
+
// Whenever one of these characters is present, we should quote the argument
|
|
18
|
+
// Characters: space, ", &, ', |, ;, <, >, (, ), $, `, \, !, *, ?, [, ], {, }, ~, #
|
|
19
|
+
const shellMetaChars = /[ "&'|;<>()$`\\!*?[\]{}~#]/;
|
|
20
|
+
return shellMetaChars.test(arg);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function escapeDoubleQuoteContent(arg) {
|
|
24
|
+
// Escape special characters for shell safety
|
|
25
|
+
// This escapes ", $, `, and \ by prefixing them with a backslash
|
|
26
|
+
return arg.replace(/(["`$\\])/g, "\\$1");
|
|
27
|
+
}
|
|
28
|
+
|
|
11
29
|
function buildCommand(command, args) {
|
|
12
|
-
|
|
30
|
+
if (args.length === 0) {
|
|
31
|
+
return command;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const escapedArgs = args.map(sanitizeShellArgument);
|
|
35
|
+
|
|
13
36
|
return `${command} ${escapedArgs.join(" ")}`;
|
|
14
37
|
}
|
|
15
38
|
|
|
39
|
+
function resolveCommandPath(command) {
|
|
40
|
+
// command will be "npm", "yarn", etc.
|
|
41
|
+
// Use 'command -v' to find the full path
|
|
42
|
+
const fullPath = execSync(`command -v ${command}`, {
|
|
43
|
+
encoding: "utf8",
|
|
44
|
+
shell: true,
|
|
45
|
+
}).trim();
|
|
46
|
+
|
|
47
|
+
if (!fullPath) {
|
|
48
|
+
throw new Error(`Command not found: ${command}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return fullPath;
|
|
52
|
+
}
|
|
53
|
+
|
|
16
54
|
export async function safeSpawn(command, args, options = {}) {
|
|
17
|
-
|
|
55
|
+
// The command is always one of our supported package managers.
|
|
56
|
+
// It should always be alphanumeric or _ or -
|
|
57
|
+
// Reject any command names with suspicious characters
|
|
58
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(command)) {
|
|
59
|
+
throw new Error(`Invalid command name: ${command}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
18
62
|
return new Promise((resolve, reject) => {
|
|
19
|
-
|
|
63
|
+
// Windows requires shell: true because .bat and .cmd files are not executable
|
|
64
|
+
// without a terminal. On Unix/macOS, we resolve the full path first, then use
|
|
65
|
+
// array args (safer, no escaping needed).
|
|
66
|
+
// See: https://nodejs.org/api/child_process.html#child_processspawncommand-args-options
|
|
67
|
+
let child;
|
|
68
|
+
if (os.platform() === "win32") {
|
|
69
|
+
const fullCommand = buildCommand(command, args);
|
|
70
|
+
child = spawn(fullCommand, { ...options, shell: true });
|
|
71
|
+
} else {
|
|
72
|
+
const fullPath = resolveCommandPath(command);
|
|
73
|
+
child = spawn(fullPath, args, options);
|
|
74
|
+
}
|
|
20
75
|
|
|
21
76
|
// When stdio is piped, we need to collect the output
|
|
22
77
|
let stdout = "";
|