mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Merge branch 'main' into escape-special-chars-in-shell
This commit is contained in:
commit
8447d3cac5
62 changed files with 2212 additions and 4032 deletions
10
packages/safe-chain/bin/aikido-bun.js
Executable file
10
packages/safe-chain/bin/aikido-bun.js
Executable file
|
|
@ -0,0 +1,10 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { main } from "../src/main.js";
|
||||
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||
|
||||
const packageManagerName = "bun";
|
||||
initializePackageManager(packageManagerName);
|
||||
var exitCode = await main(process.argv.slice(2));
|
||||
|
||||
process.exit(exitCode);
|
||||
10
packages/safe-chain/bin/aikido-bunx.js
Executable file
10
packages/safe-chain/bin/aikido-bunx.js
Executable file
|
|
@ -0,0 +1,10 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { main } from "../src/main.js";
|
||||
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||
|
||||
const packageManagerName = "bunx";
|
||||
initializePackageManager(packageManagerName);
|
||||
var exitCode = await main(process.argv.slice(2));
|
||||
|
||||
process.exit(exitCode);
|
||||
|
|
@ -1,19 +1,10 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { execSync } from "child_process";
|
||||
import { main } from "../src/main.js";
|
||||
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||
|
||||
const packageManagerName = "npm";
|
||||
initializePackageManager(packageManagerName, getNpmVersion());
|
||||
await main(process.argv.slice(2));
|
||||
initializePackageManager(packageManagerName);
|
||||
var exitCode = await main(process.argv.slice(2));
|
||||
|
||||
function getNpmVersion() {
|
||||
try {
|
||||
return execSync("npm --version").toString().trim();
|
||||
} catch {
|
||||
// Default to 0.0.0 if npm is not found
|
||||
// That way we don't use any unsupported features
|
||||
return "0.0.0";
|
||||
}
|
||||
}
|
||||
process.exit(exitCode);
|
||||
|
|
|
|||
|
|
@ -4,5 +4,7 @@ import { main } from "../src/main.js";
|
|||
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||
|
||||
const packageManagerName = "npx";
|
||||
initializePackageManager(packageManagerName, process.versions.node);
|
||||
await main(process.argv.slice(2));
|
||||
initializePackageManager(packageManagerName);
|
||||
var exitCode = await main(process.argv.slice(2));
|
||||
|
||||
process.exit(exitCode);
|
||||
|
|
|
|||
|
|
@ -4,5 +4,7 @@ import { main } from "../src/main.js";
|
|||
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||
|
||||
const packageManagerName = "pnpm";
|
||||
initializePackageManager(packageManagerName, process.versions.node);
|
||||
await main(process.argv.slice(2));
|
||||
initializePackageManager(packageManagerName);
|
||||
var exitCode = await main(process.argv.slice(2));
|
||||
|
||||
process.exit(exitCode);
|
||||
|
|
|
|||
|
|
@ -4,5 +4,7 @@ import { main } from "../src/main.js";
|
|||
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||
|
||||
const packageManagerName = "pnpx";
|
||||
initializePackageManager(packageManagerName, process.versions.node);
|
||||
await main(process.argv.slice(2));
|
||||
initializePackageManager(packageManagerName);
|
||||
var exitCode = await main(process.argv.slice(2));
|
||||
|
||||
process.exit(exitCode);
|
||||
|
|
|
|||
|
|
@ -4,5 +4,7 @@ import { main } from "../src/main.js";
|
|||
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||
|
||||
const packageManagerName = "yarn";
|
||||
initializePackageManager(packageManagerName, process.versions.node);
|
||||
await main(process.argv.slice(2));
|
||||
initializePackageManager(packageManagerName);
|
||||
var exitCode = await main(process.argv.slice(2));
|
||||
|
||||
process.exit(exitCode);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import chalk from "chalk";
|
||||
import { createRequire } from "module";
|
||||
import { ui } from "../src/environment/userInteraction.js";
|
||||
import { setup } from "../src/shell-integration/setup.js";
|
||||
import { teardown } from "../src/shell-integration/teardown.js";
|
||||
|
|
@ -26,6 +27,8 @@ if (command === "setup") {
|
|||
teardown();
|
||||
} else if (command === "setup-ci") {
|
||||
setupCi();
|
||||
} else if (command === "--version" || command === "-v" || command === "-v") {
|
||||
ui.writeInformation(`Current safe-chain version: ${getVersion()}`);
|
||||
} else {
|
||||
ui.writeError(`Unknown command: ${command}.`);
|
||||
ui.emptyLine();
|
||||
|
|
@ -43,13 +46,15 @@ function writeHelp() {
|
|||
ui.writeInformation(
|
||||
`Available commands: ${chalk.cyan("setup")}, ${chalk.cyan(
|
||||
"teardown"
|
||||
)}, ${chalk.cyan("help")}`
|
||||
)}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("help")}, ${chalk.cyan(
|
||||
"--version"
|
||||
)}`
|
||||
);
|
||||
ui.emptyLine();
|
||||
ui.writeInformation(
|
||||
`- ${chalk.cyan(
|
||||
"safe-chain setup"
|
||||
)}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm and pnpx.`
|
||||
)}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, bun and bunx.`
|
||||
);
|
||||
ui.writeInformation(
|
||||
`- ${chalk.cyan(
|
||||
|
|
@ -61,5 +66,16 @@ function writeHelp() {
|
|||
"safe-chain setup-ci"
|
||||
)}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.`
|
||||
);
|
||||
ui.writeInformation(
|
||||
`- ${chalk.cyan(
|
||||
"safe-chain --version"
|
||||
)} (or ${chalk.cyan("-v")}): Display the current version of safe-chain.`
|
||||
);
|
||||
ui.emptyLine();
|
||||
}
|
||||
|
||||
function getVersion() {
|
||||
const require = createRequire(import.meta.url);
|
||||
const packageJson = require("../package.json");
|
||||
return packageJson.version;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"scripts": {
|
||||
"test": "node --test --experimental-test-module-mocks 'src/**/*.spec.js'",
|
||||
"test:watch": "node --test --watch --experimental-test-module-mocks 'src/**/*.spec.js'",
|
||||
"lint": "eslint ."
|
||||
"lint": "oxlint --deny-warnings"
|
||||
},
|
||||
"bin": {
|
||||
"aikido-npm": "bin/aikido-npm.js",
|
||||
|
|
@ -12,6 +12,8 @@
|
|||
"aikido-yarn": "bin/aikido-yarn.js",
|
||||
"aikido-pnpm": "bin/aikido-pnpm.js",
|
||||
"aikido-pnpx": "bin/aikido-pnpx.js",
|
||||
"aikido-bun": "bin/aikido-bun.js",
|
||||
"aikido-bunx": "bin/aikido-bunx.js",
|
||||
"safe-chain": "bin/safe-chain.js"
|
||||
},
|
||||
"type": "module",
|
||||
|
|
@ -26,11 +28,12 @@
|
|||
"keywords": [],
|
||||
"author": "Aikido Security",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), and [pnpx](https://pnpm.io/cli/dlx) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, or pnpx from downloading or running the malware.",
|
||||
"description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), and [bunx](https://bun.sh/docs/cli/bunx) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, or bunx from downloading or running the malware.",
|
||||
"dependencies": {
|
||||
"abbrev": "3.0.1",
|
||||
"chalk": "5.4.1",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"make-fetch-happen": "14.0.3",
|
||||
"node-forge": "1.3.1",
|
||||
"npm-registry-fetch": "18.0.2",
|
||||
"ora": "8.2.0",
|
||||
"semver": "7.7.2"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
// oxlint-disable no-console
|
||||
import chalk from "chalk";
|
||||
import ora from "ora";
|
||||
import { createInterface } from "readline";
|
||||
|
|
|
|||
|
|
@ -4,20 +4,50 @@ import { scanCommand, shouldScanCommand } from "./scanning/index.js";
|
|||
import { ui } from "./environment/userInteraction.js";
|
||||
import { getPackageManager } from "./packagemanager/currentPackageManager.js";
|
||||
import { initializeCliArguments } from "./config/cliArguments.js";
|
||||
import { createSafeChainProxy } from "./registryProxy/registryProxy.js";
|
||||
import chalk from "chalk";
|
||||
|
||||
export async function main(args) {
|
||||
const proxy = createSafeChainProxy();
|
||||
await proxy.startServer();
|
||||
|
||||
try {
|
||||
// This parses all the --safe-chain arguments and removes them from the args array
|
||||
args = initializeCliArguments(args);
|
||||
|
||||
if (shouldScanCommand(args)) {
|
||||
await scanCommand(args);
|
||||
const commandScanResult = await scanCommand(args);
|
||||
|
||||
// Returning the exit code back to the caller allows the promise
|
||||
// to be awaited in the bin files and return the correct exit code
|
||||
if (commandScanResult !== 0) {
|
||||
return commandScanResult;
|
||||
}
|
||||
}
|
||||
|
||||
const packageManagerResult = await getPackageManager().runCommand(args);
|
||||
|
||||
if (!proxy.verifyNoMaliciousPackages()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
ui.emptyLine();
|
||||
ui.writeInformation(
|
||||
`${chalk.green(
|
||||
"✔"
|
||||
)} Safe-chain: Command completed, no malicious packages found.`
|
||||
);
|
||||
|
||||
// Returning the exit code back to the caller allows the promise
|
||||
// to be awaited in the bin files and return the correct exit code
|
||||
return packageManagerResult.status;
|
||||
} catch (error) {
|
||||
ui.writeError("Failed to check for malicious packages:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
var result = getPackageManager().runCommand(args);
|
||||
process.exit(result.status);
|
||||
// Returning the exit code back to the caller allows the promise
|
||||
// to be awaited in the bin files and return the correct exit code
|
||||
return 1;
|
||||
} finally {
|
||||
await proxy.stopServer();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import { ui } from "../../environment/userInteraction.js";
|
||||
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
||||
|
||||
export function createBunPackageManager() {
|
||||
return {
|
||||
runCommand: (args) => runBunCommand("bun", args),
|
||||
|
||||
// For bun, we use the proxy-only approach to block package downloads,
|
||||
// so we don't need to analyze commands.
|
||||
isSupportedCommand: () => false,
|
||||
getDependencyUpdatesForCommand: () => [],
|
||||
};
|
||||
}
|
||||
|
||||
export function createBunxPackageManager() {
|
||||
return {
|
||||
runCommand: (args) => runBunCommand("bunx", args),
|
||||
|
||||
// For bunx, we use the proxy-only approach to block package downloads,
|
||||
// so we don't need to analyze commands.
|
||||
isSupportedCommand: () => false,
|
||||
getDependencyUpdatesForCommand: () => [],
|
||||
};
|
||||
}
|
||||
|
||||
async function runBunCommand(command, args) {
|
||||
try {
|
||||
const result = await safeSpawn(command, args, {
|
||||
stdio: "inherit",
|
||||
env: mergeSafeChainProxyEnvironmentVariables(process.env),
|
||||
});
|
||||
return { status: result.status };
|
||||
} catch (error) {
|
||||
if (error.status) {
|
||||
return { status: error.status };
|
||||
} else {
|
||||
ui.writeError("Error executing command:", error.message);
|
||||
return { status: 1 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,7 @@
|
|||
import {
|
||||
createBunPackageManager,
|
||||
createBunxPackageManager,
|
||||
} from "./bun/createBunPackageManager.js";
|
||||
import { createNpmPackageManager } from "./npm/createPackageManager.js";
|
||||
import { createNpxPackageManager } from "./npx/createPackageManager.js";
|
||||
import {
|
||||
|
|
@ -10,9 +14,9 @@ const state = {
|
|||
packageManagerName: null,
|
||||
};
|
||||
|
||||
export function initializePackageManager(packageManagerName, version) {
|
||||
export function initializePackageManager(packageManagerName) {
|
||||
if (packageManagerName === "npm") {
|
||||
state.packageManagerName = createNpmPackageManager(version);
|
||||
state.packageManagerName = createNpmPackageManager();
|
||||
} else if (packageManagerName === "npx") {
|
||||
state.packageManagerName = createNpxPackageManager();
|
||||
} else if (packageManagerName === "yarn") {
|
||||
|
|
@ -21,6 +25,10 @@ export function initializePackageManager(packageManagerName, version) {
|
|||
state.packageManagerName = createPnpmPackageManager();
|
||||
} else if (packageManagerName === "pnpx") {
|
||||
state.packageManagerName = createPnpxPackageManager();
|
||||
} else if (packageManagerName === "bun") {
|
||||
state.packageManagerName = createBunPackageManager();
|
||||
} else if (packageManagerName === "bunx") {
|
||||
state.packageManagerName = createBunxPackageManager();
|
||||
} else {
|
||||
throw new Error("Unsupported package manager: " + packageManagerName);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +1,27 @@
|
|||
import { commandArgumentScanner } from "./dependencyScanner/commandArgumentScanner.js";
|
||||
import { dryRunScanner } from "./dependencyScanner/dryRunScanner.js";
|
||||
import { nullScanner } from "./dependencyScanner/nullScanner.js";
|
||||
import { runNpm } from "./runNpmCommand.js";
|
||||
import {
|
||||
getNpmCommandForArgs,
|
||||
npmInstallCommand,
|
||||
npmCiCommand,
|
||||
npmInstallTestCommand,
|
||||
npmInstallCiTestCommand,
|
||||
npmUpdateCommand,
|
||||
npmAuditCommand,
|
||||
npmExecCommand,
|
||||
} from "./utils/npmCommands.js";
|
||||
|
||||
export function createNpmPackageManager(version) {
|
||||
// From npm v10.4.0 onwards, the npm commands output detailed information
|
||||
// when using the --dry-run flag.
|
||||
// We use that information to scan for dependency changes.
|
||||
// For older versions of npm we have to rely on parsing the command arguments.
|
||||
const supportedScanners = isPriorToNpm10_4(version)
|
||||
? npm10_3AndBelowSupportedScanners
|
||||
: npm10_4AndAboveSupportedScanners;
|
||||
|
||||
export function createNpmPackageManager() {
|
||||
function isSupportedCommand(args) {
|
||||
const scanner = findDependencyScannerForCommand(supportedScanners, args);
|
||||
const scanner = findDependencyScannerForCommand(
|
||||
commandScannerMapping,
|
||||
args
|
||||
);
|
||||
return scanner.shouldScan(args);
|
||||
}
|
||||
|
||||
function getDependencyUpdatesForCommand(args) {
|
||||
const scanner = findDependencyScannerForCommand(supportedScanners, args);
|
||||
const scanner = findDependencyScannerForCommand(
|
||||
commandScannerMapping,
|
||||
args
|
||||
);
|
||||
return scanner.scan(args);
|
||||
}
|
||||
|
||||
|
|
@ -39,40 +32,12 @@ export function createNpmPackageManager(version) {
|
|||
};
|
||||
}
|
||||
|
||||
const npm10_4AndAboveSupportedScanners = {
|
||||
[npmInstallCommand]: dryRunScanner(),
|
||||
[npmUpdateCommand]: dryRunScanner(),
|
||||
[npmCiCommand]: dryRunScanner(),
|
||||
[npmAuditCommand]: dryRunScanner({
|
||||
skipScanWhen: (args) => !args.includes("fix"),
|
||||
}),
|
||||
[npmExecCommand]: commandArgumentScanner({ ignoreDryRun: true }), // exec command doesn't support dry-run
|
||||
|
||||
// Running dry-run on install-test and install-ci-test will install & run tests.
|
||||
// We only want to know if there are changes in the dependencies.
|
||||
// So we run change the dry-run command to only check the install.
|
||||
[npmInstallTestCommand]: dryRunScanner({ dryRunCommand: npmInstallCommand }),
|
||||
[npmInstallCiTestCommand]: dryRunScanner({ dryRunCommand: npmCiCommand }),
|
||||
};
|
||||
|
||||
const npm10_3AndBelowSupportedScanners = {
|
||||
const commandScannerMapping = {
|
||||
[npmInstallCommand]: commandArgumentScanner(),
|
||||
[npmUpdateCommand]: commandArgumentScanner(),
|
||||
[npmExecCommand]: commandArgumentScanner({ ignoreDryRun: true }), // exec command doesn't support dry-run
|
||||
};
|
||||
|
||||
function isPriorToNpm10_4(version) {
|
||||
try {
|
||||
const [major, minor] = version.split(".").map(Number);
|
||||
if (major < 10) return true;
|
||||
if (major === 10 && minor < 4) return true;
|
||||
return false;
|
||||
} catch {
|
||||
// Default to true: if version parsing fails, assume it's an older version
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function findDependencyScannerForCommand(scanners, args) {
|
||||
const command = getNpmCommandForArgs(args);
|
||||
if (!command) {
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
import { parseDryRunOutput } from "../parsing/parseNpmInstallDryRunOutput.js";
|
||||
import { dryRunNpmCommandAndOutput } from "../runNpmCommand.js";
|
||||
import { hasDryRunArg } from "../utils/npmCommands.js";
|
||||
|
||||
export function dryRunScanner(scannerOptions) {
|
||||
return {
|
||||
scan: (args) => scanDependencies(scannerOptions, args),
|
||||
shouldScan: (args) => shouldScanDependencies(scannerOptions, args),
|
||||
};
|
||||
}
|
||||
function scanDependencies(scannerOptions, args) {
|
||||
let dryRunArgs = args;
|
||||
|
||||
if (scannerOptions?.dryRunCommand) {
|
||||
// Replace the first argument with the dryRunCommand (eg: "install" instead of "install-test")
|
||||
dryRunArgs = [scannerOptions.dryRunCommand, ...args.slice(1)];
|
||||
}
|
||||
|
||||
return checkChangesWithDryRun(dryRunArgs);
|
||||
}
|
||||
|
||||
function shouldScanDependencies(scannerOptions, args) {
|
||||
if (hasDryRunArg(args)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (scannerOptions?.skipScanWhen && scannerOptions.skipScanWhen(args)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkChangesWithDryRun(args) {
|
||||
const dryRunOutput = dryRunNpmCommandAndOutput(args);
|
||||
|
||||
// Dry-run can return a non-zero status code in some cases
|
||||
// e.g., when running "npm audit fix --dry-run", it returns exit code 1
|
||||
// when there are vulnerabilities that can be fixed.
|
||||
if (dryRunOutput.status !== 0 && !canCommandReturnNonZeroOnSuccess(args)) {
|
||||
throw new Error(
|
||||
`Dry-run command failed with exit code ${dryRunOutput.status} and output:\n${dryRunOutput.output}`
|
||||
);
|
||||
}
|
||||
|
||||
if (dryRunOutput.status !== 0 && !dryRunOutput.output) {
|
||||
throw new Error(
|
||||
`Dry-run command failed with exit code ${dryRunOutput.status} and produced no output.`
|
||||
);
|
||||
}
|
||||
|
||||
const parsedOutput = parseDryRunOutput(dryRunOutput.output);
|
||||
|
||||
// reverse the array to have the top-level packages first
|
||||
return parsedOutput.reverse();
|
||||
}
|
||||
|
||||
function canCommandReturnNonZeroOnSuccess(args) {
|
||||
if (args.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// `npm audit fix --dry-run` can return exit code 1 when it succesfully ran and
|
||||
// there were vulnerabilities that could be fixed
|
||||
return args[0] === "audit" && args[1] === "fix";
|
||||
}
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
import { describe, it, mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
describe("dryRunScanner", async () => {
|
||||
const mockWriteError = mock.fn();
|
||||
const mockDryRunNpmCommandAndOutput = mock.fn();
|
||||
|
||||
// Mock ui module
|
||||
mock.module("../../../environment/userInteraction.js", {
|
||||
namedExports: {
|
||||
ui: {
|
||||
writeError: mockWriteError,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Mock dryRunNpmCommandAndOutput function
|
||||
mock.module("../runNpmCommand.js", {
|
||||
namedExports: {
|
||||
dryRunNpmCommandAndOutput: mockDryRunNpmCommandAndOutput,
|
||||
},
|
||||
});
|
||||
|
||||
const { dryRunScanner } = await import("./dryRunScanner.js");
|
||||
|
||||
describe("doesCommandReturnNonZero", () => {
|
||||
// We need to access the internal function for testing
|
||||
// Since it's not exported, we'll test it indirectly through the main functionality
|
||||
|
||||
it("should handle npm audit fix commands that return non-zero", async () => {
|
||||
mockDryRunNpmCommandAndOutput.mock.resetCalls();
|
||||
mockWriteError.mock.resetCalls();
|
||||
mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({
|
||||
status: 1,
|
||||
output: "found 5 vulnerabilities that can be fixed",
|
||||
}));
|
||||
|
||||
const scanner = dryRunScanner();
|
||||
const result = scanner.scan(["audit", "fix"]);
|
||||
|
||||
// Should not throw an error for audit fix commands
|
||||
assert.ok(Array.isArray(result));
|
||||
assert.equal(mockWriteError.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
it("should throw error for unexpected non-zero exit codes", async () => {
|
||||
mockDryRunNpmCommandAndOutput.mock.resetCalls();
|
||||
mockWriteError.mock.resetCalls();
|
||||
mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({
|
||||
status: 1,
|
||||
output: "some error output",
|
||||
}));
|
||||
|
||||
const scanner = dryRunScanner();
|
||||
|
||||
assert.throws(() => {
|
||||
scanner.scan(["install", "lodash"]);
|
||||
}, /Dry-run command failed with exit code 1/);
|
||||
});
|
||||
|
||||
it("should handle zero exit codes normally", async () => {
|
||||
mockDryRunNpmCommandAndOutput.mock.resetCalls();
|
||||
mockWriteError.mock.resetCalls();
|
||||
mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({
|
||||
status: 0,
|
||||
output: "added 1 package",
|
||||
}));
|
||||
|
||||
const scanner = dryRunScanner();
|
||||
const result = scanner.scan(["install", "lodash"]);
|
||||
|
||||
assert.ok(Array.isArray(result));
|
||||
assert.equal(mockWriteError.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
it("should throw error for non-zero exit with no output for audit fix", async () => {
|
||||
mockDryRunNpmCommandAndOutput.mock.resetCalls();
|
||||
mockWriteError.mock.resetCalls();
|
||||
mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({
|
||||
status: 1,
|
||||
output: "",
|
||||
}));
|
||||
|
||||
const scanner = dryRunScanner();
|
||||
|
||||
assert.throws(() => {
|
||||
scanner.scan(["audit", "fix"]);
|
||||
}, /Dry-run command failed with exit code 1/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scanner functionality", () => {
|
||||
it("should use dryRunCommand option when provided", async () => {
|
||||
mockDryRunNpmCommandAndOutput.mock.resetCalls();
|
||||
mockWriteError.mock.resetCalls();
|
||||
mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({
|
||||
status: 0,
|
||||
output: "no changes",
|
||||
}));
|
||||
|
||||
const scanner = dryRunScanner({ dryRunCommand: "install" });
|
||||
scanner.scan(["install-test", "lodash"]);
|
||||
|
||||
// Should call with "install" instead of "install-test"
|
||||
assert.equal(mockDryRunNpmCommandAndOutput.mock.callCount(), 1);
|
||||
const calledArgs =
|
||||
mockDryRunNpmCommandAndOutput.mock.calls[0].arguments[0];
|
||||
assert.deepEqual(calledArgs, ["install", "lodash"]);
|
||||
});
|
||||
|
||||
it("should skip scanning when hasDryRunArg returns true", async () => {
|
||||
mockDryRunNpmCommandAndOutput.mock.resetCalls();
|
||||
mockWriteError.mock.resetCalls();
|
||||
|
||||
const scanner = dryRunScanner();
|
||||
const shouldScan = scanner.shouldScan(["install", "--dry-run"]);
|
||||
|
||||
assert.equal(shouldScan, false);
|
||||
// Should not call dryRunNpmCommandAndOutput since scanning is skipped
|
||||
assert.equal(mockDryRunNpmCommandAndOutput.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
it("should skip scanning when skipScanWhen returns true", async () => {
|
||||
const scanner = dryRunScanner({
|
||||
skipScanWhen: (args) => args.includes("--skip"),
|
||||
});
|
||||
const shouldScan = scanner.shouldScan(["install", "--skip"]);
|
||||
|
||||
assert.equal(shouldScan, false);
|
||||
});
|
||||
|
||||
it("should scan when conditions are met", async () => {
|
||||
const scanner = dryRunScanner();
|
||||
const shouldScan = scanner.shouldScan(["install", "lodash"]);
|
||||
|
||||
assert.equal(shouldScan, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
export function parseDryRunOutput(output) {
|
||||
const lines = output.split(/\r?\n/);
|
||||
const packageChanges = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("add ")) {
|
||||
packageChanges.push(parseAdd(line));
|
||||
} else if (line.startsWith("remove ")) {
|
||||
packageChanges.push(parseRemove(line));
|
||||
} else if (line.startsWith("change ")) {
|
||||
packageChanges.push(parseChange(line));
|
||||
}
|
||||
}
|
||||
|
||||
return packageChanges;
|
||||
}
|
||||
|
||||
function parseAdd(line) {
|
||||
const splitLine = getLineParts(line);
|
||||
const packageName = splitLine[1];
|
||||
const packageVersion = splitLine[splitLine.length - 1];
|
||||
return addedPackage(packageName, packageVersion);
|
||||
}
|
||||
|
||||
function addedPackage(name, version) {
|
||||
return { type: "add", name, version };
|
||||
}
|
||||
|
||||
function parseRemove(line) {
|
||||
const splitLine = getLineParts(line);
|
||||
const packageName = splitLine[1];
|
||||
const packageVersion = splitLine[splitLine.length - 1];
|
||||
return removedPackage(packageName, packageVersion);
|
||||
}
|
||||
|
||||
function removedPackage(name, version) {
|
||||
return { type: "remove", name, version };
|
||||
}
|
||||
|
||||
function parseChange(line) {
|
||||
const splitLine = getLineParts(line);
|
||||
const packageName = splitLine[1];
|
||||
const packageVersion = splitLine[splitLine.length - 1];
|
||||
const oldVersion = splitLine[2];
|
||||
return changedPackage(packageName, packageVersion, oldVersion);
|
||||
}
|
||||
|
||||
function getLineParts(line) {
|
||||
return line
|
||||
.split(" ")
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part !== "");
|
||||
}
|
||||
|
||||
function changedPackage(name, version, oldVersion) {
|
||||
return { type: "change", name, version, oldVersion };
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { parseDryRunOutput } from "./parseNpmInstallDryRunOutput.js";
|
||||
|
||||
describe("parseNpmInstallDryRunOutput", () => {
|
||||
it("should parse added packages", () => {
|
||||
const output = `
|
||||
add @jest/transform 29.7.0
|
||||
add @jest/test-result 29.7.0
|
||||
add @jest/reporters 29.7.0
|
||||
add @jest/console 29.7.0
|
||||
add jest-cli 29.7.0
|
||||
add import-local 3.2.0
|
||||
add @jest/types 29.6.3
|
||||
add @jest/core 29.7.0
|
||||
add jest 29.7.0
|
||||
|
||||
added 267 packages in 831ms
|
||||
|
||||
32 packages are looking for funding
|
||||
run \`npm fund\` for details`;
|
||||
|
||||
const expected = [
|
||||
{ name: "@jest/transform", version: "29.7.0", type: "add" },
|
||||
{ name: "@jest/test-result", version: "29.7.0", type: "add" },
|
||||
{ name: "@jest/reporters", version: "29.7.0", type: "add" },
|
||||
{ name: "@jest/console", version: "29.7.0", type: "add" },
|
||||
{ name: "jest-cli", version: "29.7.0", type: "add" },
|
||||
{ name: "import-local", version: "3.2.0", type: "add" },
|
||||
{ name: "@jest/types", version: "29.6.3", type: "add" },
|
||||
{ name: "@jest/core", version: "29.7.0", type: "add" },
|
||||
{ name: "jest", version: "29.7.0", type: "add" },
|
||||
];
|
||||
|
||||
const result = parseDryRunOutput(output);
|
||||
|
||||
assert.deepEqual(result, expected);
|
||||
});
|
||||
|
||||
it("should parse removed packages", () => {
|
||||
const output = `
|
||||
remove react 19.1.0
|
||||
|
||||
removed 1 package in 115ms`;
|
||||
|
||||
const expected = [{ name: "react", version: "19.1.0", type: "remove" }];
|
||||
|
||||
const result = parseDryRunOutput(output);
|
||||
|
||||
assert.deepEqual(result, expected);
|
||||
});
|
||||
|
||||
it("should parse changed packages", () => {
|
||||
const output = `
|
||||
change react 19.0.0 => 19.1.0
|
||||
|
||||
changed 1 package in 204ms`;
|
||||
|
||||
const expected = [
|
||||
{
|
||||
name: "react",
|
||||
version: "19.1.0",
|
||||
oldVersion: "19.0.0",
|
||||
type: "change",
|
||||
},
|
||||
];
|
||||
|
||||
const result = parseDryRunOutput(output);
|
||||
|
||||
assert.deepEqual(result, expected);
|
||||
});
|
||||
|
||||
it("should parse mixed package changes", () => {
|
||||
const output = `
|
||||
add @jest/transform 29.7.0
|
||||
add @jest/test-result 29.7.0
|
||||
add @jest/reporters 29.7.0
|
||||
add @jest/console 29.7.0
|
||||
add jest-cli 29.7.0
|
||||
add import-local 3.2.0
|
||||
add @jest/types 29.6.3
|
||||
add @jest/core 29.7.0
|
||||
add jest 29.7.0
|
||||
remove react 19.1.0
|
||||
change lodash 4.17.0 => 4.18.0
|
||||
|
||||
removed 1 package in 115ms`;
|
||||
|
||||
const expected = [
|
||||
{ name: "@jest/transform", version: "29.7.0", type: "add" },
|
||||
{ name: "@jest/test-result", version: "29.7.0", type: "add" },
|
||||
{ name: "@jest/reporters", version: "29.7.0", type: "add" },
|
||||
{ name: "@jest/console", version: "29.7.0", type: "add" },
|
||||
{ name: "jest-cli", version: "29.7.0", type: "add" },
|
||||
{ name: "import-local", version: "3.2.0", type: "add" },
|
||||
{ name: "@jest/types", version: "29.6.3", type: "add" },
|
||||
{ name: "@jest/core", version: "29.7.0", type: "add" },
|
||||
{ name: "jest", version: "29.7.0", type: "add" },
|
||||
{ name: "react", version: "19.1.0", type: "remove" },
|
||||
{
|
||||
name: "lodash",
|
||||
version: "4.18.0",
|
||||
oldVersion: "4.17.0",
|
||||
type: "change",
|
||||
},
|
||||
];
|
||||
|
||||
const result = parseDryRunOutput(output);
|
||||
|
||||
assert.deepEqual(result, expected);
|
||||
});
|
||||
|
||||
it("should work with npm v22.0.0", () => {
|
||||
const output = `
|
||||
add @jest/types 29.6.3
|
||||
add @jest/core 29.7.0
|
||||
add jest 29.7.0
|
||||
|
||||
added 257 packages in 791ms
|
||||
|
||||
44 packages are looking for funding
|
||||
run \`npm fund\` for details`;
|
||||
|
||||
const expected = [
|
||||
{ name: "@jest/types", version: "29.6.3", type: "add" },
|
||||
{ name: "@jest/core", version: "29.7.0", type: "add" },
|
||||
{ name: "jest", version: "29.7.0", type: "add" },
|
||||
];
|
||||
|
||||
const result = parseDryRunOutput(output);
|
||||
|
||||
assert.deepEqual(result, expected);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,14 @@
|
|||
import { execSync } from "child_process";
|
||||
import { ui } from "../../environment/userInteraction.js";
|
||||
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
||||
|
||||
export function runNpm(args) {
|
||||
export async function runNpm(args) {
|
||||
try {
|
||||
const npmCommand = `npm ${args.join(" ")}`;
|
||||
execSync(npmCommand, { stdio: "inherit" });
|
||||
const result = await safeSpawn("npm", args, {
|
||||
stdio: "inherit",
|
||||
env: mergeSafeChainProxyEnvironmentVariables(process.env),
|
||||
});
|
||||
return { status: result.status };
|
||||
} catch (error) {
|
||||
if (error.status) {
|
||||
return { status: error.status };
|
||||
|
|
@ -13,17 +17,29 @@ export function runNpm(args) {
|
|||
return { status: 1 };
|
||||
}
|
||||
}
|
||||
return { status: 0 };
|
||||
}
|
||||
|
||||
export function dryRunNpmCommandAndOutput(args) {
|
||||
export async function dryRunNpmCommandAndOutput(args) {
|
||||
try {
|
||||
const npmCommand = `npm ${args.join(" ")} --ignore-scripts --dry-run`;
|
||||
const output = execSync(npmCommand, { stdio: "pipe" });
|
||||
return { status: 0, output: output.toString() };
|
||||
const result = await safeSpawn(
|
||||
"npm",
|
||||
[...args, "--ignore-scripts", "--dry-run"],
|
||||
{
|
||||
stdio: "pipe",
|
||||
env: mergeSafeChainProxyEnvironmentVariables(process.env),
|
||||
}
|
||||
);
|
||||
return {
|
||||
status: result.status,
|
||||
output: result.status === 0 ? result.stdout : result.stderr,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.status) {
|
||||
const output = error.stdout ? error.stdout.toString() : "";
|
||||
const output =
|
||||
error.stdout?.toString() ??
|
||||
error.stderr?.toString() ??
|
||||
error.message ??
|
||||
"";
|
||||
return { status: error.status, output };
|
||||
} else {
|
||||
ui.writeError("Error executing command:", error.message);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,358 @@
|
|||
// This was ran with the abbrev package to generate the abbrevs object below
|
||||
// console.log(abbrev(commands.concat(Object.keys(aliases))));
|
||||
export const abbrevs = {
|
||||
ac: "access",
|
||||
acc: "access",
|
||||
acce: "access",
|
||||
acces: "access",
|
||||
access: "access",
|
||||
add: "add",
|
||||
"add-": "add-user",
|
||||
"add-u": "add-user",
|
||||
"add-us": "add-user",
|
||||
"add-use": "add-user",
|
||||
"add-user": "add-user",
|
||||
addu: "adduser",
|
||||
addus: "adduser",
|
||||
adduse: "adduser",
|
||||
adduser: "adduser",
|
||||
aud: "audit",
|
||||
audi: "audit",
|
||||
audit: "audit",
|
||||
aut: "author",
|
||||
auth: "author",
|
||||
autho: "author",
|
||||
author: "author",
|
||||
b: "bugs",
|
||||
bu: "bugs",
|
||||
bug: "bugs",
|
||||
bugs: "bugs",
|
||||
c: "c",
|
||||
ca: "cache",
|
||||
cac: "cache",
|
||||
cach: "cache",
|
||||
cache: "cache",
|
||||
ci: "ci",
|
||||
cit: "cit",
|
||||
"clean-install": "clean-install",
|
||||
"clean-install-": "clean-install-test",
|
||||
"clean-install-t": "clean-install-test",
|
||||
"clean-install-te": "clean-install-test",
|
||||
"clean-install-tes": "clean-install-test",
|
||||
"clean-install-test": "clean-install-test",
|
||||
com: "completion",
|
||||
comp: "completion",
|
||||
compl: "completion",
|
||||
comple: "completion",
|
||||
complet: "completion",
|
||||
completi: "completion",
|
||||
completio: "completion",
|
||||
completion: "completion",
|
||||
con: "config",
|
||||
conf: "config",
|
||||
confi: "config",
|
||||
config: "config",
|
||||
cr: "create",
|
||||
cre: "create",
|
||||
crea: "create",
|
||||
creat: "create",
|
||||
create: "create",
|
||||
dd: "ddp",
|
||||
ddp: "ddp",
|
||||
ded: "dedupe",
|
||||
dedu: "dedupe",
|
||||
dedup: "dedupe",
|
||||
dedupe: "dedupe",
|
||||
dep: "deprecate",
|
||||
depr: "deprecate",
|
||||
depre: "deprecate",
|
||||
deprec: "deprecate",
|
||||
depreca: "deprecate",
|
||||
deprecat: "deprecate",
|
||||
deprecate: "deprecate",
|
||||
dif: "diff",
|
||||
diff: "diff",
|
||||
"dist-tag": "dist-tag",
|
||||
"dist-tags": "dist-tags",
|
||||
docs: "docs",
|
||||
doct: "doctor",
|
||||
docto: "doctor",
|
||||
doctor: "doctor",
|
||||
ed: "edit",
|
||||
edi: "edit",
|
||||
edit: "edit",
|
||||
exe: "exec",
|
||||
exec: "exec",
|
||||
expla: "explain",
|
||||
explai: "explain",
|
||||
explain: "explain",
|
||||
explo: "explore",
|
||||
explor: "explore",
|
||||
explore: "explore",
|
||||
find: "find",
|
||||
"find-": "find-dupes",
|
||||
"find-d": "find-dupes",
|
||||
"find-du": "find-dupes",
|
||||
"find-dup": "find-dupes",
|
||||
"find-dupe": "find-dupes",
|
||||
"find-dupes": "find-dupes",
|
||||
fu: "fund",
|
||||
fun: "fund",
|
||||
fund: "fund",
|
||||
g: "get",
|
||||
ge: "get",
|
||||
get: "get",
|
||||
help: "help",
|
||||
"help-": "help-search",
|
||||
"help-s": "help-search",
|
||||
"help-se": "help-search",
|
||||
"help-sea": "help-search",
|
||||
"help-sear": "help-search",
|
||||
"help-searc": "help-search",
|
||||
"help-search": "help-search",
|
||||
hl: "hlep",
|
||||
hle: "hlep",
|
||||
hlep: "hlep",
|
||||
ho: "home",
|
||||
hom: "home",
|
||||
home: "home",
|
||||
i: "i",
|
||||
ic: "ic",
|
||||
in: "in",
|
||||
inf: "info",
|
||||
info: "info",
|
||||
ini: "init",
|
||||
init: "init",
|
||||
inn: "innit",
|
||||
inni: "innit",
|
||||
innit: "innit",
|
||||
ins: "ins",
|
||||
inst: "inst",
|
||||
insta: "insta",
|
||||
instal: "instal",
|
||||
install: "install",
|
||||
"install-ci": "install-ci-test",
|
||||
"install-ci-": "install-ci-test",
|
||||
"install-ci-t": "install-ci-test",
|
||||
"install-ci-te": "install-ci-test",
|
||||
"install-ci-tes": "install-ci-test",
|
||||
"install-ci-test": "install-ci-test",
|
||||
"install-cl": "install-clean",
|
||||
"install-cle": "install-clean",
|
||||
"install-clea": "install-clean",
|
||||
"install-clean": "install-clean",
|
||||
"install-t": "install-test",
|
||||
"install-te": "install-test",
|
||||
"install-tes": "install-test",
|
||||
"install-test": "install-test",
|
||||
isnt: "isnt",
|
||||
isnta: "isnta",
|
||||
isntal: "isntal",
|
||||
isntall: "isntall",
|
||||
"isntall-": "isntall-clean",
|
||||
"isntall-c": "isntall-clean",
|
||||
"isntall-cl": "isntall-clean",
|
||||
"isntall-cle": "isntall-clean",
|
||||
"isntall-clea": "isntall-clean",
|
||||
"isntall-clean": "isntall-clean",
|
||||
iss: "issues",
|
||||
issu: "issues",
|
||||
issue: "issues",
|
||||
issues: "issues",
|
||||
it: "it",
|
||||
la: "la",
|
||||
lin: "link",
|
||||
link: "link",
|
||||
lis: "list",
|
||||
list: "list",
|
||||
ll: "ll",
|
||||
ln: "ln",
|
||||
logi: "login",
|
||||
login: "login",
|
||||
logo: "logout",
|
||||
logou: "logout",
|
||||
logout: "logout",
|
||||
ls: "ls",
|
||||
og: "ogr",
|
||||
ogr: "ogr",
|
||||
or: "org",
|
||||
org: "org",
|
||||
ou: "outdated",
|
||||
out: "outdated",
|
||||
outd: "outdated",
|
||||
outda: "outdated",
|
||||
outdat: "outdated",
|
||||
outdate: "outdated",
|
||||
outdated: "outdated",
|
||||
ow: "owner",
|
||||
own: "owner",
|
||||
owne: "owner",
|
||||
owner: "owner",
|
||||
pa: "pack",
|
||||
pac: "pack",
|
||||
pack: "pack",
|
||||
pi: "ping",
|
||||
pin: "ping",
|
||||
ping: "ping",
|
||||
pk: "pkg",
|
||||
pkg: "pkg",
|
||||
pre: "prefix",
|
||||
pref: "prefix",
|
||||
prefi: "prefix",
|
||||
prefix: "prefix",
|
||||
pro: "profile",
|
||||
prof: "profile",
|
||||
profi: "profile",
|
||||
profil: "profile",
|
||||
profile: "profile",
|
||||
pru: "prune",
|
||||
prun: "prune",
|
||||
prune: "prune",
|
||||
pu: "publish",
|
||||
pub: "publish",
|
||||
publ: "publish",
|
||||
publi: "publish",
|
||||
publis: "publish",
|
||||
publish: "publish",
|
||||
q: "query",
|
||||
qu: "query",
|
||||
que: "query",
|
||||
quer: "query",
|
||||
query: "query",
|
||||
r: "r",
|
||||
rb: "rb",
|
||||
reb: "rebuild",
|
||||
rebu: "rebuild",
|
||||
rebui: "rebuild",
|
||||
rebuil: "rebuild",
|
||||
rebuild: "rebuild",
|
||||
rem: "remove",
|
||||
remo: "remove",
|
||||
remov: "remove",
|
||||
remove: "remove",
|
||||
rep: "repo",
|
||||
repo: "repo",
|
||||
res: "restart",
|
||||
rest: "restart",
|
||||
resta: "restart",
|
||||
restar: "restart",
|
||||
restart: "restart",
|
||||
rm: "rm",
|
||||
ro: "root",
|
||||
roo: "root",
|
||||
root: "root",
|
||||
rum: "rum",
|
||||
run: "run",
|
||||
"run-": "run-script",
|
||||
"run-s": "run-script",
|
||||
"run-sc": "run-script",
|
||||
"run-scr": "run-script",
|
||||
"run-scri": "run-script",
|
||||
"run-scrip": "run-script",
|
||||
"run-script": "run-script",
|
||||
s: "s",
|
||||
sb: "sbom",
|
||||
sbo: "sbom",
|
||||
sbom: "sbom",
|
||||
se: "se",
|
||||
sea: "search",
|
||||
sear: "search",
|
||||
searc: "search",
|
||||
search: "search",
|
||||
set: "set",
|
||||
sho: "show",
|
||||
show: "show",
|
||||
shr: "shrinkwrap",
|
||||
shri: "shrinkwrap",
|
||||
shrin: "shrinkwrap",
|
||||
shrink: "shrinkwrap",
|
||||
shrinkw: "shrinkwrap",
|
||||
shrinkwr: "shrinkwrap",
|
||||
shrinkwra: "shrinkwrap",
|
||||
shrinkwrap: "shrinkwrap",
|
||||
si: "sit",
|
||||
sit: "sit",
|
||||
star: "star",
|
||||
stars: "stars",
|
||||
start: "start",
|
||||
sto: "stop",
|
||||
stop: "stop",
|
||||
t: "t",
|
||||
tea: "team",
|
||||
team: "team",
|
||||
tes: "test",
|
||||
test: "test",
|
||||
to: "token",
|
||||
tok: "token",
|
||||
toke: "token",
|
||||
token: "token",
|
||||
ts: "tst",
|
||||
tst: "tst",
|
||||
ud: "udpate",
|
||||
udp: "udpate",
|
||||
udpa: "udpate",
|
||||
udpat: "udpate",
|
||||
udpate: "udpate",
|
||||
un: "un",
|
||||
und: "undeprecate",
|
||||
unde: "undeprecate",
|
||||
undep: "undeprecate",
|
||||
undepr: "undeprecate",
|
||||
undepre: "undeprecate",
|
||||
undeprec: "undeprecate",
|
||||
undepreca: "undeprecate",
|
||||
undeprecat: "undeprecate",
|
||||
undeprecate: "undeprecate",
|
||||
uni: "uninstall",
|
||||
unin: "uninstall",
|
||||
unins: "uninstall",
|
||||
uninst: "uninstall",
|
||||
uninsta: "uninstall",
|
||||
uninstal: "uninstall",
|
||||
uninstall: "uninstall",
|
||||
unl: "unlink",
|
||||
unli: "unlink",
|
||||
unlin: "unlink",
|
||||
unlink: "unlink",
|
||||
unp: "unpublish",
|
||||
unpu: "unpublish",
|
||||
unpub: "unpublish",
|
||||
unpubl: "unpublish",
|
||||
unpubli: "unpublish",
|
||||
unpublis: "unpublish",
|
||||
unpublish: "unpublish",
|
||||
uns: "unstar",
|
||||
unst: "unstar",
|
||||
unsta: "unstar",
|
||||
unstar: "unstar",
|
||||
up: "up",
|
||||
upd: "update",
|
||||
upda: "update",
|
||||
updat: "update",
|
||||
update: "update",
|
||||
upg: "upgrade",
|
||||
upgr: "upgrade",
|
||||
upgra: "upgrade",
|
||||
upgrad: "upgrade",
|
||||
upgrade: "upgrade",
|
||||
ur: "urn",
|
||||
urn: "urn",
|
||||
v: "v",
|
||||
veri: "verison",
|
||||
veris: "verison",
|
||||
veriso: "verison",
|
||||
verison: "verison",
|
||||
vers: "version",
|
||||
versi: "version",
|
||||
versio: "version",
|
||||
version: "version",
|
||||
vi: "view",
|
||||
vie: "view",
|
||||
view: "view",
|
||||
who: "whoami",
|
||||
whoa: "whoami",
|
||||
whoam: "whoami",
|
||||
whoami: "whoami",
|
||||
why: "why",
|
||||
x: "x",
|
||||
};
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// Based on https://github.com/npm/cli/blob/latest/lib/utils/cmd-list.js
|
||||
|
||||
import abbrev from "abbrev";
|
||||
import { abbrevs } from "./abbrevs-generated.js";
|
||||
|
||||
const commands = [
|
||||
"access",
|
||||
|
|
@ -158,8 +158,6 @@ export function deref(c) {
|
|||
return aliases[c];
|
||||
}
|
||||
|
||||
const abbrevs = abbrev(commands.concat(Object.keys(aliases)));
|
||||
|
||||
// first deref the abbrev, if there is one
|
||||
// then resolve any aliases
|
||||
// so `npm install-cl` will resolve to `install-clean` then to `ci`
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import { execSync } from "child_process";
|
||||
import { ui } from "../../environment/userInteraction.js";
|
||||
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
||||
|
||||
export function runNpx(args) {
|
||||
export async function runNpx(args) {
|
||||
try {
|
||||
const npxCommand = `npx ${args.join(" ")}`;
|
||||
execSync(npxCommand, { stdio: "inherit" });
|
||||
const result = await safeSpawn("npx", args, {
|
||||
stdio: "inherit",
|
||||
env: mergeSafeChainProxyEnvironmentVariables(process.env),
|
||||
});
|
||||
return { status: result.status };
|
||||
} catch (error) {
|
||||
if (error.status) {
|
||||
return { status: error.status };
|
||||
|
|
@ -13,5 +17,4 @@ export function runNpx(args) {
|
|||
return { status: 1 };
|
||||
}
|
||||
}
|
||||
return { status: 0 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,20 @@
|
|||
import { ui } from "../../environment/userInteraction.js";
|
||||
import { safeSpawnSync } from "../../utils/safeSpawn.js";
|
||||
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
||||
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||
|
||||
export function runPnpmCommand(args, toolName = "pnpm") {
|
||||
export async function runPnpmCommand(args, toolName = "pnpm") {
|
||||
try {
|
||||
let result;
|
||||
if (toolName === "pnpm") {
|
||||
result = safeSpawnSync("pnpm", args, { stdio: "inherit" });
|
||||
result = await safeSpawn("pnpm", args, {
|
||||
stdio: "inherit",
|
||||
env: mergeSafeChainProxyEnvironmentVariables(process.env),
|
||||
});
|
||||
} else if (toolName === "pnpx") {
|
||||
result = safeSpawnSync("pnpx", args, { stdio: "inherit" });
|
||||
result = await safeSpawn("pnpx", args, {
|
||||
stdio: "inherit",
|
||||
env: mergeSafeChainProxyEnvironmentVariables(process.env),
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Unsupported tool name for aikido-pnpm: ${toolName}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,17 @@
|
|||
import { execSync } from "child_process";
|
||||
import { ui } from "../../environment/userInteraction.js";
|
||||
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
||||
|
||||
export function runYarnCommand(args) {
|
||||
export async function runYarnCommand(args) {
|
||||
try {
|
||||
const npxCommand = `yarn ${args.join(" ")}`;
|
||||
execSync(npxCommand, { stdio: "inherit" });
|
||||
const env = mergeSafeChainProxyEnvironmentVariables(process.env);
|
||||
await fixYarnProxyEnvironmentVariables(env);
|
||||
|
||||
const result = await safeSpawn("yarn", args, {
|
||||
stdio: "inherit",
|
||||
env,
|
||||
});
|
||||
return { status: result.status };
|
||||
} catch (error) {
|
||||
if (error.status) {
|
||||
return { status: error.status };
|
||||
|
|
@ -13,5 +20,34 @@ export function runYarnCommand(args) {
|
|||
return { status: 1 };
|
||||
}
|
||||
}
|
||||
return { status: 0 };
|
||||
}
|
||||
|
||||
async function fixYarnProxyEnvironmentVariables(env) {
|
||||
// Yarn ignores standard proxy environment variable HTTPS_PROXY
|
||||
// It does respect NODE_EXTRA_CA_CERTS for custom CA certificates though.
|
||||
// Don't use YARN_HTTPS_CA_FILE_PATH though, as it causes to ignore all system CAs
|
||||
|
||||
// Yarn v2/v3 and v4+ use different environment variables for proxy and CA certs
|
||||
// When setting all variables, yarn returns an error about conflicting variables
|
||||
// - v2/v3: "Usage Error: Unrecognized or legacy configuration settings found: httpsCaFilePath"
|
||||
// - v4+: "Usage Error: Unrecognized or legacy configuration settings found: caFilePath"
|
||||
|
||||
const version = await yarnVersion();
|
||||
const majorVersion = parseInt(version.split(".")[0]);
|
||||
|
||||
if (majorVersion >= 4) {
|
||||
env.YARN_HTTPS_PROXY = env.HTTPS_PROXY;
|
||||
} else if (majorVersion === 2 || majorVersion === 3) {
|
||||
env.YARN_HTTPS_PROXY = env.HTTPS_PROXY;
|
||||
}
|
||||
}
|
||||
|
||||
async function yarnVersion() {
|
||||
const result = await safeSpawn("yarn", ["--version"], {
|
||||
stdio: "pipe",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error("Failed to get yarn version");
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
import { describe, it, beforeEach, afterEach, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
|
||||
describe("runYarnCommand", () => {
|
||||
let runYarnCommand;
|
||||
let capturedEnv;
|
||||
let yarnVersion;
|
||||
|
||||
beforeEach(async () => {
|
||||
capturedEnv = null;
|
||||
yarnVersion = "4.1.0"; // Default to v4
|
||||
|
||||
// Mock safeSpawn to capture env and control yarn version
|
||||
mock.module("../../utils/safeSpawn.js", {
|
||||
namedExports: {
|
||||
safeSpawn: async (command, args, options) => {
|
||||
if (args.includes("--version")) {
|
||||
// Mock yarn version check
|
||||
return { status: 0, stdout: yarnVersion };
|
||||
}
|
||||
// Capture the env for assertions
|
||||
capturedEnv = options.env;
|
||||
return { status: 0 };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Mock mergeSafeChainProxyEnvironmentVariables to return test env
|
||||
mock.module("../../registryProxy/registryProxy.js", {
|
||||
namedExports: {
|
||||
mergeSafeChainProxyEnvironmentVariables: (env) => {
|
||||
return {
|
||||
...env,
|
||||
HTTPS_PROXY: "http://localhost:8080",
|
||||
NODE_EXTRA_CA_CERTS: "/path/to/ca-cert.pem",
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Mock ui to prevent console output
|
||||
mock.module("../../environment/userInteraction.js", {
|
||||
namedExports: {
|
||||
ui: {
|
||||
writeError: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const module = await import("./runYarnCommand.js");
|
||||
runYarnCommand = module.runYarnCommand;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
it("should set YARN_HTTPS_PROXY for Yarn v4+", async () => {
|
||||
yarnVersion = "4.1.0";
|
||||
await runYarnCommand(["add", "lodash"]);
|
||||
|
||||
assert.strictEqual(
|
||||
capturedEnv.YARN_HTTPS_PROXY,
|
||||
"http://localhost:8080",
|
||||
"YARN_HTTPS_PROXY should be set to the HTTPS_PROXY value"
|
||||
);
|
||||
assert.strictEqual(
|
||||
capturedEnv.YARN_HTTPS_CA_FILE_PATH,
|
||||
undefined,
|
||||
"YARN_HTTPS_CA_FILE_PATH should NOT be set to avoid overriding system CAs"
|
||||
);
|
||||
});
|
||||
|
||||
it("should set YARN_HTTPS_PROXY for Yarn v3", async () => {
|
||||
yarnVersion = "3.6.4";
|
||||
await runYarnCommand(["add", "lodash"]);
|
||||
|
||||
assert.strictEqual(
|
||||
capturedEnv.YARN_HTTPS_PROXY,
|
||||
"http://localhost:8080",
|
||||
"YARN_HTTPS_PROXY should be set to the HTTPS_PROXY value"
|
||||
);
|
||||
assert.strictEqual(
|
||||
capturedEnv.YARN_CA_FILE_PATH,
|
||||
undefined,
|
||||
"YARN_CA_FILE_PATH should NOT be set to avoid overriding system CAs"
|
||||
);
|
||||
});
|
||||
|
||||
it("should set YARN_HTTPS_PROXY for Yarn v2", async () => {
|
||||
yarnVersion = "2.4.3";
|
||||
await runYarnCommand(["add", "lodash"]);
|
||||
|
||||
assert.strictEqual(
|
||||
capturedEnv.YARN_HTTPS_PROXY,
|
||||
"http://localhost:8080",
|
||||
"YARN_HTTPS_PROXY should be set to the HTTPS_PROXY value"
|
||||
);
|
||||
assert.strictEqual(
|
||||
capturedEnv.YARN_CA_FILE_PATH,
|
||||
undefined,
|
||||
"YARN_CA_FILE_PATH should NOT be set to avoid overriding system CAs"
|
||||
);
|
||||
});
|
||||
|
||||
it("should not set Yarn-specific proxy vars for Yarn v1", async () => {
|
||||
yarnVersion = "1.22.19";
|
||||
await runYarnCommand(["add", "lodash"]);
|
||||
|
||||
assert.strictEqual(
|
||||
capturedEnv.YARN_HTTPS_PROXY,
|
||||
undefined,
|
||||
"YARN_HTTPS_PROXY should not be set for Yarn v1"
|
||||
);
|
||||
assert.strictEqual(
|
||||
capturedEnv.YARN_HTTPS_CA_FILE_PATH,
|
||||
undefined,
|
||||
"YARN_HTTPS_CA_FILE_PATH should not be set for Yarn v1"
|
||||
);
|
||||
assert.strictEqual(
|
||||
capturedEnv.YARN_CA_FILE_PATH,
|
||||
undefined,
|
||||
"YARN_CA_FILE_PATH should not be set for Yarn v1"
|
||||
);
|
||||
});
|
||||
|
||||
it("should preserve NODE_EXTRA_CA_CERTS for all Yarn versions", async () => {
|
||||
for (const version of ["4.1.0", "3.6.4", "2.4.3", "1.22.19"]) {
|
||||
yarnVersion = version;
|
||||
await runYarnCommand(["add", "lodash"]);
|
||||
|
||||
assert.strictEqual(
|
||||
capturedEnv.NODE_EXTRA_CA_CERTS,
|
||||
"/path/to/ca-cert.pem",
|
||||
`NODE_EXTRA_CA_CERTS should be preserved for Yarn ${version}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should preserve HTTPS_PROXY for all Yarn versions", async () => {
|
||||
for (const version of ["4.1.0", "3.6.4", "2.4.3", "1.22.19"]) {
|
||||
yarnVersion = version;
|
||||
await runYarnCommand(["add", "lodash"]);
|
||||
|
||||
assert.strictEqual(
|
||||
capturedEnv.HTTPS_PROXY,
|
||||
"http://localhost:8080",
|
||||
`HTTPS_PROXY should be preserved for Yarn ${version}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
114
packages/safe-chain/src/registryProxy/certUtils.js
Normal file
114
packages/safe-chain/src/registryProxy/certUtils.js
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import forge from "node-forge";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
|
||||
const certFolder = path.join(os.homedir(), ".safe-chain", "certs");
|
||||
const ca = loadCa();
|
||||
|
||||
const certCache = new Map();
|
||||
|
||||
export function getCaCertPath() {
|
||||
return path.join(certFolder, "ca-cert.pem");
|
||||
}
|
||||
|
||||
export function generateCertForHost(hostname) {
|
||||
let existingCert = certCache.get(hostname);
|
||||
if (existingCert) {
|
||||
return existingCert;
|
||||
}
|
||||
|
||||
const keys = forge.pki.rsa.generateKeyPair(2048);
|
||||
const cert = forge.pki.createCertificate();
|
||||
cert.publicKey = keys.publicKey;
|
||||
cert.serialNumber = "01";
|
||||
cert.validity.notBefore = new Date();
|
||||
cert.validity.notAfter = new Date();
|
||||
cert.validity.notAfter.setHours(cert.validity.notBefore.getHours() + 1);
|
||||
|
||||
const attrs = [{ name: "commonName", value: hostname }];
|
||||
cert.setSubject(attrs);
|
||||
cert.setIssuer(ca.certificate.subject.attributes);
|
||||
cert.setExtensions([
|
||||
{
|
||||
name: "subjectAltName",
|
||||
altNames: [
|
||||
{
|
||||
type: 2, // DNS
|
||||
value: hostname,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "keyUsage",
|
||||
digitalSignature: true,
|
||||
keyEncipherment: true,
|
||||
},
|
||||
]);
|
||||
cert.sign(ca.privateKey, forge.md.sha256.create());
|
||||
|
||||
const result = {
|
||||
privateKey: forge.pki.privateKeyToPem(keys.privateKey),
|
||||
certificate: forge.pki.certificateToPem(cert),
|
||||
};
|
||||
|
||||
certCache.set(hostname, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function loadCa() {
|
||||
const keyPath = path.join(certFolder, "ca-key.pem");
|
||||
const certPath = path.join(certFolder, "ca-cert.pem");
|
||||
|
||||
if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
|
||||
const privateKeyPem = fs.readFileSync(keyPath, "utf8");
|
||||
const certPem = fs.readFileSync(certPath, "utf8");
|
||||
const privateKey = forge.pki.privateKeyFromPem(privateKeyPem);
|
||||
const certificate = forge.pki.certificateFromPem(certPem);
|
||||
|
||||
// Don't return a cert that is valid for less than 1 hour
|
||||
const oneHourFromNow = new Date(Date.now() + 60 * 60 * 1000);
|
||||
if (certificate.validity.notAfter > oneHourFromNow) {
|
||||
return { privateKey, certificate };
|
||||
}
|
||||
}
|
||||
|
||||
const { privateKey, certificate } = generateCa();
|
||||
fs.mkdirSync(certFolder, { recursive: true });
|
||||
fs.writeFileSync(keyPath, forge.pki.privateKeyToPem(privateKey));
|
||||
fs.writeFileSync(certPath, forge.pki.certificateToPem(certificate));
|
||||
return { privateKey, certificate };
|
||||
}
|
||||
|
||||
function generateCa() {
|
||||
const keys = forge.pki.rsa.generateKeyPair(2048);
|
||||
const cert = forge.pki.createCertificate();
|
||||
cert.publicKey = keys.publicKey;
|
||||
cert.serialNumber = "01";
|
||||
cert.validity.notBefore = new Date();
|
||||
cert.validity.notAfter = new Date();
|
||||
cert.validity.notAfter.setDate(cert.validity.notBefore.getDate() + 1);
|
||||
|
||||
const attrs = [{ name: "commonName", value: "safe-chain proxy" }];
|
||||
cert.setSubject(attrs);
|
||||
cert.setIssuer(attrs);
|
||||
cert.setExtensions([
|
||||
{
|
||||
name: "basicConstraints",
|
||||
cA: true,
|
||||
},
|
||||
{
|
||||
name: "keyUsage",
|
||||
keyCertSign: true,
|
||||
digitalSignature: true,
|
||||
keyEncipherment: true,
|
||||
},
|
||||
]);
|
||||
cert.sign(keys.privateKey, forge.md.sha256.create());
|
||||
|
||||
return {
|
||||
privateKey: keys.privateKey,
|
||||
certificate: cert,
|
||||
};
|
||||
}
|
||||
96
packages/safe-chain/src/registryProxy/mitmRequestHandler.js
Normal file
96
packages/safe-chain/src/registryProxy/mitmRequestHandler.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import https from "https";
|
||||
import { generateCertForHost } from "./certUtils.js";
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
|
||||
export function mitmConnect(req, clientSocket, isAllowed) {
|
||||
const { hostname } = new URL(`http://${req.url}`);
|
||||
|
||||
clientSocket.on("error", () => {
|
||||
// NO-OP
|
||||
// This can happen if the client TCP socket sends RST instead of FIN.
|
||||
// Not subscribing to 'close' event will cause node to throw and crash.
|
||||
});
|
||||
|
||||
const server = createHttpsServer(hostname, isAllowed);
|
||||
|
||||
// Establish the connection
|
||||
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
|
||||
// Hand off the socket to the HTTPS server
|
||||
server.emit("connection", clientSocket);
|
||||
}
|
||||
|
||||
function createHttpsServer(hostname, isAllowed) {
|
||||
const cert = generateCertForHost(hostname);
|
||||
|
||||
async function handleRequest(req, res) {
|
||||
const pathAndQuery = getRequestPathAndQuery(req.url);
|
||||
const targetUrl = `https://${hostname}${pathAndQuery}`;
|
||||
|
||||
if (!(await isAllowed(targetUrl))) {
|
||||
res.writeHead(403, "Forbidden - blocked by safe-chain");
|
||||
res.end("Blocked by safe-chain");
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect request body
|
||||
forwardRequest(req, hostname, res);
|
||||
}
|
||||
|
||||
return https.createServer(
|
||||
{
|
||||
key: cert.privateKey,
|
||||
cert: cert.certificate,
|
||||
},
|
||||
handleRequest
|
||||
);
|
||||
}
|
||||
|
||||
function getRequestPathAndQuery(url) {
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl.pathname + parsedUrl.search + parsedUrl.hash;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function forwardRequest(req, hostname, res) {
|
||||
const proxyReq = createProxyRequest(hostname, req, res);
|
||||
|
||||
proxyReq.on("error", () => {
|
||||
res.writeHead(502);
|
||||
res.end("Bad Gateway");
|
||||
});
|
||||
|
||||
req.on("data", (chunk) => {
|
||||
proxyReq.write(chunk);
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
proxyReq.end();
|
||||
});
|
||||
}
|
||||
|
||||
function createProxyRequest(hostname, req, res) {
|
||||
const options = {
|
||||
hostname: hostname,
|
||||
port: 443,
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: { ...req.headers },
|
||||
};
|
||||
|
||||
delete options.headers.host;
|
||||
|
||||
const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy;
|
||||
if (httpsProxy) {
|
||||
options.agent = new HttpsProxyAgent(httpsProxy);
|
||||
}
|
||||
|
||||
const proxyReq = https.request(options, (proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||
proxyRes.pipe(res);
|
||||
});
|
||||
|
||||
return proxyReq;
|
||||
}
|
||||
48
packages/safe-chain/src/registryProxy/parsePackageFromUrl.js
Normal file
48
packages/safe-chain/src/registryProxy/parsePackageFromUrl.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
export const knownRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"];
|
||||
|
||||
export function parsePackageFromUrl(url) {
|
||||
let packageName, version, registry;
|
||||
|
||||
for (const knownRegistry of knownRegistries) {
|
||||
if (url.includes(knownRegistry)) {
|
||||
registry = knownRegistry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!registry || !url.endsWith(".tgz")) {
|
||||
return { packageName, version };
|
||||
}
|
||||
|
||||
const registryIndex = url.indexOf(registry);
|
||||
const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash
|
||||
|
||||
const separatorIndex = afterRegistry.indexOf("/-/");
|
||||
if (separatorIndex === -1) {
|
||||
return { packageName, version };
|
||||
}
|
||||
|
||||
packageName = afterRegistry.substring(0, separatorIndex);
|
||||
const filename = afterRegistry.substring(
|
||||
separatorIndex + 3,
|
||||
afterRegistry.length - 4
|
||||
); // Remove /-/ and .tgz
|
||||
|
||||
// Extract version from filename
|
||||
// For scoped packages like @babel/core, the filename is core-7.21.4.tgz
|
||||
// For regular packages like lodash, the filename is lodash-4.17.21.tgz
|
||||
if (packageName.startsWith("@")) {
|
||||
const scopedPackageName = packageName.substring(
|
||||
packageName.lastIndexOf("/") + 1
|
||||
);
|
||||
if (filename.startsWith(scopedPackageName + "-")) {
|
||||
version = filename.substring(scopedPackageName.length + 1);
|
||||
}
|
||||
} else {
|
||||
if (filename.startsWith(packageName + "-")) {
|
||||
version = filename.substring(packageName.length + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return { packageName, version };
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { parsePackageFromUrl } from "./parsePackageFromUrl.js";
|
||||
|
||||
describe("parsePackageFromUrl", () => {
|
||||
const testCases = [
|
||||
// Regular packages
|
||||
{
|
||||
url: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
expected: { packageName: "lodash", version: "4.17.21" },
|
||||
},
|
||||
{
|
||||
url: "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
|
||||
expected: { packageName: "express", version: "4.18.2" },
|
||||
},
|
||||
// Packages with hyphens in name
|
||||
{
|
||||
url: "https://registry.npmjs.org/safe-chain-test/-/safe-chain-test-1.0.0.tgz",
|
||||
expected: { packageName: "safe-chain-test", version: "1.0.0" },
|
||||
},
|
||||
{
|
||||
url: "https://registry.npmjs.org/web-vitals/-/web-vitals-3.5.0.tgz",
|
||||
expected: { packageName: "web-vitals", version: "3.5.0" },
|
||||
},
|
||||
// Preview/prerelease versions
|
||||
{
|
||||
url: "https://registry.npmjs.org/safe-chain-test/-/safe-chain-test-0.0.1-security.tgz",
|
||||
expected: { packageName: "safe-chain-test", version: "0.0.1-security" },
|
||||
},
|
||||
{
|
||||
url: "https://registry.npmjs.org/lodash/-/lodash-5.0.0-beta.1.tgz",
|
||||
expected: { packageName: "lodash", version: "5.0.0-beta.1" },
|
||||
},
|
||||
{
|
||||
url: "https://registry.npmjs.org/react/-/react-18.3.0-canary-abc123.tgz",
|
||||
expected: { packageName: "react", version: "18.3.0-canary-abc123" },
|
||||
},
|
||||
// Scoped packages
|
||||
{
|
||||
url: "https://registry.npmjs.org/@babel/core/-/core-7.21.4.tgz",
|
||||
expected: { packageName: "@babel/core", version: "7.21.4" },
|
||||
},
|
||||
{
|
||||
url: "https://registry.npmjs.org/@types/node/-/node-20.10.5.tgz",
|
||||
expected: { packageName: "@types/node", version: "20.10.5" },
|
||||
},
|
||||
{
|
||||
url: "https://registry.npmjs.org/@angular/common/-/common-17.0.8.tgz",
|
||||
expected: { packageName: "@angular/common", version: "17.0.8" },
|
||||
},
|
||||
// Scoped packages with hyphens
|
||||
{
|
||||
url: "https://registry.npmjs.org/@safe-chain/test-package/-/test-package-2.1.0.tgz",
|
||||
expected: { packageName: "@safe-chain/test-package", version: "2.1.0" },
|
||||
},
|
||||
{
|
||||
url: "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.465.0.tgz",
|
||||
expected: { packageName: "@aws-sdk/client-s3", version: "3.465.0" },
|
||||
},
|
||||
// Scoped packages with preview versions
|
||||
{
|
||||
url: "https://registry.npmjs.org/@babel/core/-/core-8.0.0-alpha.1.tgz",
|
||||
expected: { packageName: "@babel/core", version: "8.0.0-alpha.1" },
|
||||
},
|
||||
{
|
||||
url: "https://registry.npmjs.org/@safe-chain/security-test/-/security-test-1.0.0-security.tgz",
|
||||
expected: {
|
||||
packageName: "@safe-chain/security-test",
|
||||
version: "1.0.0-security",
|
||||
},
|
||||
},
|
||||
// Yarn registry
|
||||
{
|
||||
url: "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz",
|
||||
expected: { packageName: "lodash", version: "4.17.21" },
|
||||
},
|
||||
{
|
||||
url: "https://registry.yarnpkg.com/@babel/core/-/core-7.21.4.tgz",
|
||||
expected: { packageName: "@babel/core", version: "7.21.4" },
|
||||
},
|
||||
// Invalid URLs should return undefined values
|
||||
{
|
||||
url: "https://example.com/package.tgz",
|
||||
expected: { packageName: undefined, version: undefined },
|
||||
},
|
||||
// URL to get package info, not tarball
|
||||
{
|
||||
url: "https://registry.npmjs.org/lodash",
|
||||
expected: { packageName: undefined, version: undefined },
|
||||
},
|
||||
// Complex version patterns
|
||||
{
|
||||
url: "https://registry.npmjs.org/package-with-many-hyphens/-/package-with-many-hyphens-1.0.0-rc.1+build.123.tgz",
|
||||
expected: {
|
||||
packageName: "package-with-many-hyphens",
|
||||
version: "1.0.0-rc.1+build.123",
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "https://registry.npmjs.org/@scope/package-name-with-hyphens/-/package-name-with-hyphens-2.0.0-beta.2.tgz",
|
||||
expected: {
|
||||
packageName: "@scope/package-name-with-hyphens",
|
||||
version: "2.0.0-beta.2",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ url, expected }, index) => {
|
||||
it(`should parse URL ${index + 1}: ${url}`, () => {
|
||||
const result = parsePackageFromUrl(url);
|
||||
assert.deepEqual(result, expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
69
packages/safe-chain/src/registryProxy/plainHttpProxy.js
Normal file
69
packages/safe-chain/src/registryProxy/plainHttpProxy.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import * as http from "http";
|
||||
import * as https from "https";
|
||||
|
||||
export function handleHttpProxyRequest(req, res) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
// The protocol for the plainHttpProxy should usually only be http:
|
||||
// but when the client for some reason sends an https: request directly
|
||||
// instead of using the CONNECT method, we should handle it gracefully.
|
||||
let protocol;
|
||||
if (url.protocol === "http:") {
|
||||
protocol = http;
|
||||
} else if (url.protocol === "https:") {
|
||||
protocol = https;
|
||||
} else {
|
||||
res.writeHead(502);
|
||||
res.end(`Bad Gateway: Unsupported protocol ${url.protocol}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const proxyRequest = protocol
|
||||
.request(
|
||||
req.url,
|
||||
{ method: req.method, headers: req.headers },
|
||||
(proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||
proxyRes.pipe(res);
|
||||
|
||||
proxyRes.on("error", () => {
|
||||
// Proxy response stream error
|
||||
// Clean up client response stream
|
||||
if (res.writable) {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
proxyRes.on("close", () => {
|
||||
// Clean up if the proxy response stream closes
|
||||
if (res.writable) {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
)
|
||||
.on("error", (err) => {
|
||||
res.writeHead(502);
|
||||
res.end(`Bad Gateway: ${err.message}`);
|
||||
});
|
||||
|
||||
req.on("error", () => {
|
||||
// Client request stream error
|
||||
// Abort the proxy request
|
||||
proxyRequest.destroy();
|
||||
});
|
||||
|
||||
res.on("error", () => {
|
||||
// Client response stream error (client disconnected)
|
||||
// Clean up proxy streams
|
||||
proxyRequest.destroy();
|
||||
});
|
||||
|
||||
res.on("close", () => {
|
||||
// Client disconnected
|
||||
// Abort the proxy request to avoid unnecessary work
|
||||
proxyRequest.destroy();
|
||||
});
|
||||
|
||||
req.pipe(proxyRequest);
|
||||
}
|
||||
160
packages/safe-chain/src/registryProxy/registryProxy.js
Normal file
160
packages/safe-chain/src/registryProxy/registryProxy.js
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import * as http from "http";
|
||||
import { tunnelRequest } from "./tunnelRequestHandler.js";
|
||||
import { mitmConnect } from "./mitmRequestHandler.js";
|
||||
import { handleHttpProxyRequest } from "./plainHttpProxy.js";
|
||||
import { getCaCertPath } from "./certUtils.js";
|
||||
import { auditChanges } from "../scanning/audit/index.js";
|
||||
import { knownRegistries, parsePackageFromUrl } from "./parsePackageFromUrl.js";
|
||||
import { ui } from "../environment/userInteraction.js";
|
||||
import chalk from "chalk";
|
||||
|
||||
const SERVER_STOP_TIMEOUT_MS = 1000;
|
||||
const state = {
|
||||
port: null,
|
||||
blockedRequests: [],
|
||||
};
|
||||
|
||||
export function createSafeChainProxy() {
|
||||
const server = createProxyServer();
|
||||
|
||||
return {
|
||||
startServer: () => startServer(server),
|
||||
stopServer: () => stopServer(server),
|
||||
verifyNoMaliciousPackages,
|
||||
};
|
||||
}
|
||||
|
||||
function getSafeChainProxyEnvironmentVariables() {
|
||||
if (!state.port) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
HTTPS_PROXY: `http://localhost:${state.port}`,
|
||||
GLOBAL_AGENT_HTTP_PROXY: `http://localhost:${state.port}`,
|
||||
NODE_EXTRA_CA_CERTS: getCaCertPath(),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeSafeChainProxyEnvironmentVariables(env) {
|
||||
const proxyEnv = getSafeChainProxyEnvironmentVariables();
|
||||
|
||||
for (const key of Object.keys(env)) {
|
||||
// If we were to simply copy all env variables, we might overwrite
|
||||
// the proxy settings set by safe-chain when casing varies (e.g. http_proxy vs HTTP_PROXY)
|
||||
// So we only copy the variable if it's not already set in a different case
|
||||
const upperKey = key.toUpperCase();
|
||||
|
||||
if (!proxyEnv[upperKey]) {
|
||||
proxyEnv[key] = env[key];
|
||||
}
|
||||
}
|
||||
|
||||
return proxyEnv;
|
||||
}
|
||||
|
||||
function createProxyServer() {
|
||||
const server = http.createServer(
|
||||
// This handles direct HTTP requests (non-CONNECT requests)
|
||||
// This is normally http-only traffic, but we also handle
|
||||
// https for clients that don't properly use CONNECT
|
||||
handleHttpProxyRequest
|
||||
);
|
||||
|
||||
// This handles HTTPS requests via the CONNECT method
|
||||
server.on("connect", handleConnect);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
function startServer(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Passing port 0 makes the OS assign an available port
|
||||
server.listen(0, () => {
|
||||
const address = server.address();
|
||||
if (address && typeof address === "object") {
|
||||
state.port = address.port;
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error("Failed to start proxy server"));
|
||||
}
|
||||
});
|
||||
|
||||
server.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function stopServer(server) {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
server.close(() => {
|
||||
resolve();
|
||||
});
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
setTimeout(() => resolve(), SERVER_STOP_TIMEOUT_MS);
|
||||
});
|
||||
}
|
||||
|
||||
function handleConnect(req, clientSocket, head) {
|
||||
// CONNECT method is used for HTTPS requests
|
||||
// It establishes a tunnel to the server identified by the request URL
|
||||
|
||||
if (knownRegistries.some((reg) => req.url.includes(reg))) {
|
||||
// For npm and yarn registries, we want to intercept and inspect the traffic
|
||||
// so we can block packages with malware
|
||||
mitmConnect(req, clientSocket, isAllowedUrl);
|
||||
} else {
|
||||
// For other hosts, just tunnel the request to the destination tcp socket
|
||||
tunnelRequest(req, clientSocket, head);
|
||||
}
|
||||
}
|
||||
|
||||
async function isAllowedUrl(url) {
|
||||
const { packageName, version } = parsePackageFromUrl(url);
|
||||
|
||||
// packageName and version are undefined when the URL is not a package download
|
||||
// In that case, we can allow the request to proceed
|
||||
if (!packageName || !version) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const auditResult = await auditChanges([
|
||||
{ name: packageName, version, type: "add" },
|
||||
]);
|
||||
|
||||
if (!auditResult.isAllowed) {
|
||||
state.blockedRequests.push({ packageName, version, url });
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function verifyNoMaliciousPackages() {
|
||||
if (state.blockedRequests.length === 0) {
|
||||
// No malicious packages were blocked, so nothing to block
|
||||
return true;
|
||||
}
|
||||
|
||||
ui.emptyLine();
|
||||
|
||||
ui.writeInformation(
|
||||
`Safe-chain: ${chalk.bold(
|
||||
`blocked ${state.blockedRequests.length} malicious package downloads`
|
||||
)}:`
|
||||
);
|
||||
|
||||
for (const req of state.blockedRequests) {
|
||||
ui.writeInformation(` - ${req.packageName}@${req.version} (${req.url})`);
|
||||
}
|
||||
|
||||
ui.emptyLine();
|
||||
ui.writeError("Exiting without installing malicious packages.");
|
||||
ui.emptyLine();
|
||||
|
||||
return false;
|
||||
}
|
||||
114
packages/safe-chain/src/registryProxy/tunnelRequestHandler.js
Normal file
114
packages/safe-chain/src/registryProxy/tunnelRequestHandler.js
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import * as net from "net";
|
||||
import { ui } from "../environment/userInteraction.js";
|
||||
|
||||
export function tunnelRequest(req, clientSocket, head) {
|
||||
const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy;
|
||||
|
||||
if (httpsProxy) {
|
||||
// If an HTTPS proxy is set, tunnel the request via the proxy
|
||||
// This is the system proxy, not the safe-chain proxy
|
||||
// The package manager will run via the safe-chain proxy
|
||||
// The safe-chain proxy will then send the request to the system proxy
|
||||
// Typical flow: package manager -> safe-chain proxy -> system proxy -> destination
|
||||
|
||||
// There are 2 processes involved in this:
|
||||
// 1. Safe-chain process: has HTTPS_PROXY set to system proxy
|
||||
// 2. Package manager process: has HTTPS_PROXY set to safe-chain proxy
|
||||
|
||||
tunnelRequestViaProxy(req, clientSocket, head, httpsProxy);
|
||||
} else {
|
||||
tunnelRequestToDestination(req, clientSocket, head);
|
||||
}
|
||||
}
|
||||
|
||||
function tunnelRequestToDestination(req, clientSocket, head) {
|
||||
const { port, hostname } = new URL(`http://${req.url}`);
|
||||
|
||||
clientSocket.on("error", () => {
|
||||
// NO-OP
|
||||
// This can happen if the client TCP socket sends RST instead of FIN.
|
||||
// Not subscribing to 'close' event will cause node to throw and crash.
|
||||
});
|
||||
|
||||
const serverSocket = net.connect(port || 443, hostname, () => {
|
||||
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
serverSocket.write(head);
|
||||
serverSocket.pipe(clientSocket);
|
||||
clientSocket.pipe(serverSocket);
|
||||
});
|
||||
|
||||
serverSocket.on("error", (err) => {
|
||||
ui.writeError(
|
||||
`Safe-chain: error connecting to ${hostname}:${port} - ${err.message}`
|
||||
);
|
||||
if (clientSocket.writable) {
|
||||
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) {
|
||||
const { port, hostname } = new URL(`http://${req.url}`);
|
||||
const proxy = new URL(proxyUrl);
|
||||
|
||||
// Connect to proxy server
|
||||
const proxySocket = net.connect({
|
||||
host: proxy.hostname,
|
||||
port: proxy.port,
|
||||
});
|
||||
|
||||
proxySocket.on("connect", () => {
|
||||
// Send CONNECT request to proxy
|
||||
const connectRequest = [
|
||||
`CONNECT ${hostname}:${port || 443} HTTP/1.1`,
|
||||
`Host: ${hostname}:${port || 443}`,
|
||||
"",
|
||||
"",
|
||||
].join("\r\n");
|
||||
|
||||
proxySocket.write(connectRequest);
|
||||
});
|
||||
|
||||
let isConnected = false;
|
||||
proxySocket.once("data", (data) => {
|
||||
const response = data.toString();
|
||||
|
||||
// Check if CONNECT succeeded (HTTP/1.1 200)
|
||||
if (response.startsWith("HTTP/1.1 200")) {
|
||||
isConnected = true;
|
||||
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
proxySocket.write(head);
|
||||
proxySocket.pipe(clientSocket);
|
||||
clientSocket.pipe(proxySocket);
|
||||
} else {
|
||||
ui.writeError(
|
||||
`Safe-chain: proxy CONNECT failed: ${response.split("\r\n")[0]}`
|
||||
);
|
||||
if (clientSocket.writable) {
|
||||
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||
}
|
||||
if (proxySocket.writable) {
|
||||
proxySocket.end();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
proxySocket.on("error", (err) => {
|
||||
if (!isConnected) {
|
||||
ui.writeError(
|
||||
`Safe-chain: error connecting to proxy ${proxy.hostname}:${
|
||||
proxy.port || 8080
|
||||
} - ${err.message}`
|
||||
);
|
||||
if (clientSocket.writable) {
|
||||
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
clientSocket.on("error", () => {
|
||||
if (proxySocket.writable) {
|
||||
proxySocket.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -61,10 +61,11 @@ export async function scanCommand(args) {
|
|||
}
|
||||
|
||||
if (!audit || audit.isAllowed) {
|
||||
spinner.succeed("Safe-chain: No malicious packages detected.");
|
||||
spinner.stop();
|
||||
return 0;
|
||||
} else {
|
||||
printMaliciousChanges(audit.disallowedChanges, spinner);
|
||||
await onMalwareFound();
|
||||
return await onMalwareFound();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -88,11 +89,11 @@ async function onMalwareFound() {
|
|||
|
||||
if (continueInstall) {
|
||||
ui.writeWarning("Continuing with the installation despite the risks...");
|
||||
return;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
ui.writeError("Exiting without installing malicious packages.");
|
||||
ui.emptyLine();
|
||||
process.exit(1);
|
||||
return 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { describe, it, mock } from "node:test";
|
||||
import { beforeEach, describe, it, mock } from "node:test";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import {
|
||||
MALWARE_ACTION_PROMPT,
|
||||
|
|
@ -13,6 +13,7 @@ describe("scanCommand", async () => {
|
|||
setText: () => {},
|
||||
succeed: () => {},
|
||||
fail: () => {},
|
||||
stop: () => {},
|
||||
}));
|
||||
const mockConfirm = mock.fn(() => true);
|
||||
let malwareAction = MALWARE_ACTION_PROMPT;
|
||||
|
|
@ -87,30 +88,37 @@ describe("scanCommand", async () => {
|
|||
|
||||
const { scanCommand } = await import("./index.js");
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset malware action back to prompt mode for other tests
|
||||
malwareAction = MALWARE_ACTION_PROMPT;
|
||||
});
|
||||
|
||||
it("should succeed when there are no changes", async () => {
|
||||
let successMessageWasSet = false;
|
||||
let progressWasStopped = false;
|
||||
mockStartProcess.mock.mockImplementationOnce(() => ({
|
||||
setText: () => {},
|
||||
succeed: () => {
|
||||
successMessageWasSet = true;
|
||||
},
|
||||
succeed: () => {},
|
||||
fail: () => {},
|
||||
stop: () => {
|
||||
progressWasStopped = true;
|
||||
},
|
||||
}));
|
||||
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => []);
|
||||
|
||||
await scanCommand(["install", "lodash"]);
|
||||
|
||||
assert.equal(successMessageWasSet, true);
|
||||
assert.equal(progressWasStopped, true);
|
||||
});
|
||||
|
||||
it("should succeed when changes are not malicious", async () => {
|
||||
let successMessageWasSet = false;
|
||||
let progressWasStopped = false;
|
||||
mockStartProcess.mock.mockImplementationOnce(() => ({
|
||||
setText: () => {},
|
||||
succeed: () => {
|
||||
successMessageWasSet = true;
|
||||
},
|
||||
succeed: () => {},
|
||||
fail: () => {},
|
||||
stop: () => {
|
||||
progressWasStopped = true;
|
||||
},
|
||||
}));
|
||||
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [
|
||||
{ name: "lodash", version: "4.17.21" },
|
||||
|
|
@ -118,7 +126,7 @@ describe("scanCommand", async () => {
|
|||
|
||||
await scanCommand(["install", "lodash"]);
|
||||
|
||||
assert.equal(successMessageWasSet, true);
|
||||
assert.equal(progressWasStopped, true);
|
||||
});
|
||||
|
||||
it("should throw an error when timing out", async () => {
|
||||
|
|
@ -129,6 +137,7 @@ describe("scanCommand", async () => {
|
|||
fail: () => {
|
||||
failureMessageWasSet = true;
|
||||
},
|
||||
stop: () => {},
|
||||
}));
|
||||
getScanTimeoutMock.mock.mockImplementationOnce(() => 100);
|
||||
mockGetDependencyUpdatesForCommand.mock.mockImplementation(async () => {
|
||||
|
|
@ -149,6 +158,7 @@ describe("scanCommand", async () => {
|
|||
fail: () => {
|
||||
failureMessageWasSet = true;
|
||||
},
|
||||
stop: () => {},
|
||||
}));
|
||||
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [
|
||||
{ name: "malicious", version: "1.0.0" },
|
||||
|
|
@ -173,6 +183,7 @@ describe("scanCommand", async () => {
|
|||
fail: (message) => {
|
||||
failureMessages.push(message);
|
||||
},
|
||||
stop: () => {},
|
||||
}));
|
||||
getScanTimeoutMock.mock.mockImplementationOnce(() => 100);
|
||||
mockGetDependencyUpdatesForCommand.mock.mockImplementation(async () => {
|
||||
|
|
@ -194,46 +205,29 @@ describe("scanCommand", async () => {
|
|||
it("should exit immediately when malicious changes are detected in block mode", async () => {
|
||||
// Set malware action to block mode for this test
|
||||
malwareAction = MALWARE_ACTION_BLOCK;
|
||||
|
||||
|
||||
// Reset mock call count
|
||||
mockConfirm.mock.resetCalls();
|
||||
|
||||
|
||||
let failureMessageWasSet = false;
|
||||
let exitCode = null;
|
||||
|
||||
|
||||
mockStartProcess.mock.mockImplementationOnce(() => ({
|
||||
setText: () => {},
|
||||
succeed: () => {},
|
||||
fail: () => {
|
||||
failureMessageWasSet = true;
|
||||
},
|
||||
stop: () => {},
|
||||
}));
|
||||
|
||||
|
||||
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [
|
||||
{ name: "malicious", version: "1.0.0" },
|
||||
]);
|
||||
|
||||
// Mock process.exit
|
||||
const originalExit = process.exit;
|
||||
process.exit = mock.fn((code) => {
|
||||
exitCode = code;
|
||||
throw new Error("Process exit called"); // Prevent actual exit
|
||||
});
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
scanCommand(["install", "malicious"]),
|
||||
/Process exit called/
|
||||
);
|
||||
} finally {
|
||||
// Restore original process.exit
|
||||
process.exit = originalExit;
|
||||
// Reset malware action back to prompt mode for other tests
|
||||
malwareAction = MALWARE_ACTION_PROMPT;
|
||||
}
|
||||
const result = await scanCommand(["install", "malicious"]);
|
||||
|
||||
assert.equal(failureMessageWasSet, true);
|
||||
assert.equal(exitCode, 1);
|
||||
assert.equal(result, 1);
|
||||
// Confirm should not have been called in block mode
|
||||
assert.equal(mockConfirm.mock.callCount(), 0);
|
||||
});
|
||||
|
|
@ -241,19 +235,19 @@ describe("scanCommand", async () => {
|
|||
it("should exit immediately when malicious changes are detected in block mode without prompting", async () => {
|
||||
// Set malware action to block mode for this test
|
||||
malwareAction = MALWARE_ACTION_BLOCK;
|
||||
|
||||
|
||||
// Reset mock call count
|
||||
mockConfirm.mock.resetCalls();
|
||||
|
||||
let processExited = false;
|
||||
|
||||
let userWasPrompted = false;
|
||||
|
||||
|
||||
mockStartProcess.mock.mockImplementationOnce(() => ({
|
||||
setText: () => {},
|
||||
succeed: () => {},
|
||||
fail: () => {},
|
||||
stop: () => {},
|
||||
}));
|
||||
|
||||
|
||||
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [
|
||||
{ name: "malicious", version: "1.0.0" },
|
||||
]);
|
||||
|
|
@ -263,26 +257,9 @@ describe("scanCommand", async () => {
|
|||
return false;
|
||||
});
|
||||
|
||||
// Mock process.exit
|
||||
const originalExit = process.exit;
|
||||
process.exit = mock.fn(() => {
|
||||
processExited = true;
|
||||
throw new Error("Process exit called"); // Prevent actual exit
|
||||
});
|
||||
const result = await scanCommand(["install", "malicious"]);
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
scanCommand(["install", "malicious"]),
|
||||
/Process exit called/
|
||||
);
|
||||
} finally {
|
||||
// Restore original process.exit
|
||||
process.exit = originalExit;
|
||||
// Reset malware action back to prompt mode for other tests
|
||||
malwareAction = MALWARE_ACTION_PROMPT;
|
||||
}
|
||||
|
||||
assert.equal(processExited, true);
|
||||
assert.equal(result, 1);
|
||||
assert.equal(userWasPrompted, false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,7 +8,13 @@ import {
|
|||
} from "../config/configFile.js";
|
||||
import { ui } from "../environment/userInteraction.js";
|
||||
|
||||
let cachedMalwareDatabase = null;
|
||||
|
||||
export async function openMalwareDatabase() {
|
||||
if (cachedMalwareDatabase) {
|
||||
return cachedMalwareDatabase;
|
||||
}
|
||||
|
||||
const malwareDatabase = await getMalwareDatabase();
|
||||
|
||||
function getPackageStatus(name, version) {
|
||||
|
|
@ -25,13 +31,16 @@ export async function openMalwareDatabase() {
|
|||
return packageData.reason;
|
||||
}
|
||||
|
||||
return {
|
||||
// This implicitely caches the malware database
|
||||
// that's closed over by the getPackageStatus function
|
||||
cachedMalwareDatabase = {
|
||||
getPackageStatus,
|
||||
isMalware: (name, version) => {
|
||||
const status = getPackageStatus(name, version);
|
||||
return isMalwareStatus(status);
|
||||
},
|
||||
};
|
||||
return cachedMalwareDatabase;
|
||||
}
|
||||
|
||||
async function getMalwareDatabase() {
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@ export const knownAikidoTools = [
|
|||
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
|
||||
{ tool: "pnpm", aikidoCommand: "aikido-pnpm" },
|
||||
{ tool: "pnpx", aikidoCommand: "aikido-pnpx" },
|
||||
// When adding a new tool here, also update the expected alias in the tests (setup.spec.js, teardown.spec.js)
|
||||
// and add the documentation for the new tool in the README.md
|
||||
{ tool: "bun", aikidoCommand: "aikido-bun" },
|
||||
{ tool: "bunx", aikidoCommand: "aikido-bunx" },
|
||||
// When adding a new tool here, also update the documentation for the new tool in the README.md
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -18,15 +19,15 @@ export const knownAikidoTools = [
|
|||
* Example: "npm, npx, yarn, pnpm, and pnpx commands"
|
||||
*/
|
||||
export function getPackageManagerList() {
|
||||
const tools = knownAikidoTools.map(t => t.tool);
|
||||
const tools = knownAikidoTools.map((t) => t.tool);
|
||||
if (tools.length <= 1) {
|
||||
return `${tools[0] || ''} commands`;
|
||||
return `${tools[0] || ""} commands`;
|
||||
}
|
||||
if (tools.length === 2) {
|
||||
return `${tools[0]} and ${tools[1]} commands`;
|
||||
}
|
||||
const lastTool = tools.pop();
|
||||
return `${tools.join(', ')}, and ${lastTool} commands`;
|
||||
return `${tools.join(", ")}, and ${lastTool} commands`;
|
||||
}
|
||||
|
||||
export function doesExecutableExistOnSystem(executableName) {
|
||||
|
|
@ -47,7 +48,7 @@ export function removeLinesMatchingPattern(filePath, pattern, eol) {
|
|||
eol = eol || os.EOL;
|
||||
|
||||
const fileContent = fs.readFileSync(filePath, "utf-8");
|
||||
const lines = fileContent.split(/[\r\n\u2028\u2029]/);
|
||||
const lines = fileContent.split(/\r?\n|\r|\u2028|\u2029/);
|
||||
const updatedLines = lines.filter((line) => !shouldRemoveLine(line, pattern));
|
||||
fs.writeFileSync(filePath, updatedLines.join(eol), "utf-8");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ describe("removeLinesMatchingPatternTests", () => {
|
|||
namedExports: {
|
||||
EOL: "\r\n", // Simulate Windows line endings
|
||||
tmpdir: tmpdir,
|
||||
platform: () => "linux"
|
||||
}
|
||||
platform: () => "linux",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -31,54 +31,59 @@ describe("removeLinesMatchingPatternTests", () => {
|
|||
mock.reset();
|
||||
});
|
||||
|
||||
|
||||
it("should handle mixed line endings without wiping entire file", async () => {
|
||||
// Import helpers after setting up the mock
|
||||
const { removeLinesMatchingPattern } = await import("./helpers.js");
|
||||
|
||||
|
||||
// Create a file with Unix line endings but os.EOL expects Windows
|
||||
const fileContent = [
|
||||
"# keep this line",
|
||||
"alias npm='remove-this'",
|
||||
"alias npm='remove-this'",
|
||||
"# keep this line too",
|
||||
"alias yarn='remove-this-too'",
|
||||
"# final line to keep"
|
||||
"# final line to keep",
|
||||
].join("\n"); // File has Unix line endings
|
||||
|
||||
|
||||
fs.writeFileSync(testFile, fileContent, "utf-8");
|
||||
|
||||
|
||||
// Try to remove lines containing 'alias'
|
||||
const pattern = /alias.*=/;
|
||||
removeLinesMatchingPattern(testFile, pattern);
|
||||
|
||||
|
||||
const result = fs.readFileSync(testFile, "utf-8");
|
||||
|
||||
|
||||
// This test will fail because the function splits on '\r\n' but file uses '\n'
|
||||
// So it treats the entire content as one line and if any part matches, removes everything
|
||||
assert.ok(result.includes("keep this line"), "Should preserve non-matching lines");
|
||||
assert.ok(result.includes("final line to keep"), "Should preserve final line");
|
||||
assert.ok(
|
||||
result.includes("keep this line"),
|
||||
"Should preserve non-matching lines"
|
||||
);
|
||||
assert.ok(
|
||||
result.includes("final line to keep"),
|
||||
"Should preserve final line"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle mixed line endings with short matching content", async () => {
|
||||
// Import helpers after setting up the mock
|
||||
const { removeLinesMatchingPattern } = await import("./helpers.js");
|
||||
|
||||
// Create a file with Unix line endings, but make the entire content short
|
||||
|
||||
// Create a file with Unix line endings, but make the entire content short
|
||||
// to bypass the maxLineLength protection
|
||||
const fileContent = [
|
||||
"# keep1",
|
||||
"alias x=y", // Short alias line that should be removed
|
||||
"# keep2"
|
||||
"# keep2",
|
||||
].join("\n"); // File has Unix line endings, total length < 100 chars
|
||||
|
||||
|
||||
fs.writeFileSync(testFile, fileContent, "utf-8");
|
||||
|
||||
|
||||
// Try to remove lines containing 'alias'
|
||||
const pattern = /alias/;
|
||||
removeLinesMatchingPattern(testFile, pattern);
|
||||
|
||||
|
||||
const result = fs.readFileSync(testFile, "utf-8");
|
||||
|
||||
|
||||
// This should now be protected by the newline detection
|
||||
assert.ok(result.includes("keep1"), "Should preserve first line");
|
||||
assert.ok(result.includes("keep2"), "Should preserve third line");
|
||||
|
|
@ -87,27 +92,93 @@ describe("removeLinesMatchingPatternTests", () => {
|
|||
it("should handle Unicode line separators that bypass newline detection", async () => {
|
||||
// Import helpers after setting up the mock
|
||||
const { removeLinesMatchingPattern } = await import("./helpers.js");
|
||||
|
||||
|
||||
// Use Unicode line separator (U+2028) and paragraph separator (U+2029)
|
||||
// These are considered line breaks but aren't \n or \r
|
||||
const fileContent = [
|
||||
"keep this",
|
||||
"alias test=value",
|
||||
"keep that"
|
||||
].join("\u2028"); // Unicode line separator
|
||||
|
||||
const fileContent = ["keep this", "alias test=value", "keep that"].join(
|
||||
"\u2028"
|
||||
); // Unicode line separator
|
||||
|
||||
fs.writeFileSync(testFile, fileContent, "utf-8");
|
||||
|
||||
|
||||
// Try to remove lines containing 'alias'
|
||||
const pattern = /alias/;
|
||||
removeLinesMatchingPattern(testFile, pattern);
|
||||
|
||||
|
||||
const result = fs.readFileSync(testFile, "utf-8");
|
||||
|
||||
|
||||
// This could still wipe everything if split() treats it as one line
|
||||
// but the content doesn't contain \n or \r so passes the newline check
|
||||
assert.ok(result.includes("keep this"), "Should preserve first part");
|
||||
assert.ok(result.includes("keep that"), "Should preserve last part");
|
||||
});
|
||||
|
||||
it("should handle Windows CRLF line endings without creating empty lines", async () => {
|
||||
// Import helpers after setting up the mock
|
||||
const { removeLinesMatchingPattern } = await import("./helpers.js");
|
||||
|
||||
// Create a file with Windows CRLF line endings
|
||||
const fileContent = [
|
||||
"# comment 1",
|
||||
"alias npm='aikido-npm'",
|
||||
"# comment 2",
|
||||
"export PATH=$PATH:/usr/local/bin",
|
||||
"",
|
||||
"# comment 3",
|
||||
].join("\r\n"); // Windows line endings
|
||||
|
||||
fs.writeFileSync(testFile, fileContent, "utf-8");
|
||||
|
||||
// Try to remove lines containing 'alias'
|
||||
const pattern = /alias/;
|
||||
removeLinesMatchingPattern(testFile, pattern, "\r\n");
|
||||
|
||||
const result = fs.readFileSync(testFile, "utf-8");
|
||||
|
||||
// Should preserve non-matching lines without adding empty lines
|
||||
assert.ok(result.includes("# comment 1"), "Should preserve first comment");
|
||||
assert.ok(result.includes("# comment 2"), "Should preserve second comment");
|
||||
assert.ok(result.includes("# comment 3"), "Should preserve third comment");
|
||||
assert.ok(result.includes("export PATH"), "Should preserve export line");
|
||||
assert.ok(!result.includes("alias npm"), "Should remove alias line");
|
||||
|
||||
// The key test: when we split on \r\n, we should get exactly 4 lines
|
||||
// Bug: if split(/[\r\n]/) was used, it creates empty lines between each real line
|
||||
// because \r\n becomes two separators, resulting in: ["# comment 1", "", "# comment 2", "", "export...", "", "# comment 3", ""]
|
||||
const resultLines = result.split("\r\n");
|
||||
assert.strictEqual(resultLines.length, 5, "Should have exactly 5 lines");
|
||||
});
|
||||
|
||||
it("should not remove empty lines on unix line endings", async () => {
|
||||
// Import helpers after setting up the mock
|
||||
const { removeLinesMatchingPattern } = await import("./helpers.js");
|
||||
|
||||
// Create a file with Unix line endings and empty lines
|
||||
const fileContent = [
|
||||
"# comment 1",
|
||||
"alias npm='aikido-npm'",
|
||||
"# comment 2",
|
||||
"export PATH=$PATH:/usr/local/bin",
|
||||
"",
|
||||
"# comment 3",
|
||||
].join("\n"); // Unix line endings
|
||||
|
||||
fs.writeFileSync(testFile, fileContent, "utf-8");
|
||||
|
||||
// Try to remove lines containing 'alias'
|
||||
const pattern = /alias/;
|
||||
removeLinesMatchingPattern(testFile, pattern, "\n");
|
||||
|
||||
const result = fs.readFileSync(testFile, "utf-8");
|
||||
|
||||
// Should preserve non-matching lines including empty lines
|
||||
assert.ok(result.includes("# comment 1"), "Should preserve first comment");
|
||||
assert.ok(result.includes("# comment 2"), "Should preserve second comment");
|
||||
assert.ok(result.includes("# comment 3"), "Should preserve third comment");
|
||||
assert.ok(result.includes("export PATH"), "Should preserve export line");
|
||||
assert.ok(!result.includes("alias npm"), "Should remove alias line");
|
||||
|
||||
const resultLines = result.split("\n");
|
||||
assert.strictEqual(resultLines.length, 5, "Should have exactly 5 lines");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -46,6 +46,14 @@ function pnpx
|
|||
wrapSafeChainCommand "pnpx" "aikido-pnpx" $argv
|
||||
end
|
||||
|
||||
function bun
|
||||
wrapSafeChainCommand "bun" "aikido-bun" $argv
|
||||
end
|
||||
|
||||
function bunx
|
||||
wrapSafeChainCommand "bunx" "aikido-bunx" $argv
|
||||
end
|
||||
|
||||
function npm
|
||||
# If args is just -v or --version and nothing else, just run the `npm -v` command
|
||||
# This is because nvm uses this to check the version of npm
|
||||
|
|
|
|||
|
|
@ -42,6 +42,14 @@ function pnpx() {
|
|||
wrapSafeChainCommand "pnpx" "aikido-pnpx" "$@"
|
||||
}
|
||||
|
||||
function bun() {
|
||||
wrapSafeChainCommand "bun" "aikido-bun" "$@"
|
||||
}
|
||||
|
||||
function bunx() {
|
||||
wrapSafeChainCommand "bunx" "aikido-bunx" "$@"
|
||||
}
|
||||
|
||||
function npm() {
|
||||
if [[ "$1" == "-v" || "$1" == "--version" ]] && [[ $# -eq 1 ]]; then
|
||||
# If args is just -v or --version and nothing else, just run the npm version command
|
||||
|
|
|
|||
|
|
@ -68,6 +68,14 @@ function pnpx {
|
|||
Invoke-WrappedCommand "pnpx" "aikido-pnpx" $args
|
||||
}
|
||||
|
||||
function bun {
|
||||
Invoke-WrappedCommand "bun" "aikido-bun" $args
|
||||
}
|
||||
|
||||
function bunx {
|
||||
Invoke-WrappedCommand "bunx" "aikido-bunx" $args
|
||||
}
|
||||
|
||||
function npm {
|
||||
# If args is just -v or --version and nothing else, just run the npm version command
|
||||
# This is because nvm uses this to check the version of npm
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ function escapeArg(arg) {
|
|||
// and escape characters that are special even inside double quotes
|
||||
if (shellMetaChars.test(arg)) {
|
||||
// Inside double quotes, we need to escape: " $ ` \
|
||||
return '"' + arg.replace(/(["`$\\])/g, '\\$1') + '"';
|
||||
return '"' + arg.replace(/(["`$\\])/g, "\\$1") + '"';
|
||||
}
|
||||
return arg;
|
||||
}
|
||||
|
|
@ -50,11 +50,23 @@ export async function safeSpawn(command, args, options = {}) {
|
|||
child = spawn(fullPath, args, options);
|
||||
}
|
||||
|
||||
// When stdio is piped, we need to collect the output
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout?.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
resolve({
|
||||
status: code,
|
||||
stdout: Buffer.from(""),
|
||||
stderr: Buffer.from(""),
|
||||
stdout: stdout,
|
||||
stderr: stderr,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { describe, it, beforeEach, afterEach, mock } from "node:test";
|
|||
import assert from "node:assert";
|
||||
|
||||
describe("safeSpawn", () => {
|
||||
let safeSpawnSync, safeSpawn;
|
||||
let safeSpawn;
|
||||
let spawnCalls = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
@ -11,31 +11,30 @@ describe("safeSpawn", () => {
|
|||
// Mock child_process module to capture what command string gets built
|
||||
mock.module("child_process", {
|
||||
namedExports: {
|
||||
spawnSync: (command, options) => {
|
||||
spawnCalls.push({ command, options });
|
||||
return {
|
||||
status: 0,
|
||||
stdout: Buffer.from(""),
|
||||
stderr: Buffer.from(""),
|
||||
};
|
||||
},
|
||||
spawn: (command, options) => {
|
||||
spawnCalls.push({ command, options });
|
||||
return {
|
||||
on: (event, callback) => {
|
||||
if (event === 'close') {
|
||||
if (event === "close") {
|
||||
// Simulate immediate success
|
||||
setTimeout(() => callback(0), 0);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
execSync: (cmd, opts) => {
|
||||
// Simulate 'command -v' returning full path
|
||||
const match = cmd.match(/command -v (.+)/);
|
||||
if (match) {
|
||||
return `/usr/bin/${match[1]}\n`;
|
||||
}
|
||||
return "";
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Import after mocking
|
||||
const safeSpawnModule = await import("./safeSpawn.js");
|
||||
safeSpawnSync = safeSpawnModule.safeSpawnSync;
|
||||
safeSpawn = safeSpawnModule.safeSpawn;
|
||||
});
|
||||
|
||||
|
|
@ -43,76 +42,68 @@ describe("safeSpawn", () => {
|
|||
mock.reset();
|
||||
});
|
||||
|
||||
// Helper to run either sync or async variant
|
||||
async function runSafeSpawn(variant, command, args, options) {
|
||||
if (variant === "sync") {
|
||||
return safeSpawnSync(command, args, options);
|
||||
} else {
|
||||
return await safeSpawn(command, args, options);
|
||||
}
|
||||
}
|
||||
it("should pass basic command and arguments correctly", async () => {
|
||||
await safeSpawn("echo", ["hello"]);
|
||||
|
||||
for (let variant of ["sync", "async"]) {
|
||||
it(`should pass basic command and arguments correctly (${variant})`, async () => {
|
||||
await runSafeSpawn(variant, "echo", ["hello"]);
|
||||
assert.strictEqual(spawnCalls.length, 1);
|
||||
assert.strictEqual(spawnCalls[0].command, "echo hello");
|
||||
assert.strictEqual(spawnCalls[0].options.shell, true);
|
||||
});
|
||||
|
||||
assert.strictEqual(spawnCalls.length, 1);
|
||||
assert.strictEqual(spawnCalls[0].command, "echo hello");
|
||||
assert.strictEqual(spawnCalls[0].options.shell, true);
|
||||
});
|
||||
it("should escape arguments containing spaces", async () => {
|
||||
await safeSpawn("echo", ["hello world"]);
|
||||
|
||||
it(`should escape arguments containing spaces (${variant})`, async () => {
|
||||
await runSafeSpawn(variant, "echo", ["hello world"]);
|
||||
assert.strictEqual(spawnCalls.length, 1);
|
||||
// Argument should be escaped to prevent shell interpretation
|
||||
assert.strictEqual(spawnCalls[0].command, 'echo "hello world"');
|
||||
assert.strictEqual(spawnCalls[0].options.shell, true);
|
||||
});
|
||||
|
||||
assert.strictEqual(spawnCalls.length, 1);
|
||||
// Argument should be escaped to prevent shell interpretation
|
||||
assert.strictEqual(spawnCalls[0].command, 'echo "hello world"');
|
||||
assert.strictEqual(spawnCalls[0].options.shell, true);
|
||||
});
|
||||
it("should prevent shell injection attacks", async () => {
|
||||
await safeSpawn("ls", ["; rm test123.txt"]);
|
||||
|
||||
it(`should prevent shell injection attacks (${variant})`, async () => {
|
||||
await runSafeSpawn(variant, "ls", ["; rm test123.txt"]);
|
||||
assert.strictEqual(spawnCalls.length, 1);
|
||||
// Malicious command should be escaped to prevent execution
|
||||
assert.strictEqual(spawnCalls[0].command, 'ls "; rm test123.txt"');
|
||||
assert.strictEqual(spawnCalls[0].options.shell, true);
|
||||
});
|
||||
|
||||
assert.strictEqual(spawnCalls.length, 1);
|
||||
// Malicious command should be escaped to prevent execution
|
||||
assert.strictEqual(spawnCalls[0].command, 'ls "; rm test123.txt"');
|
||||
assert.strictEqual(spawnCalls[0].options.shell, true);
|
||||
});
|
||||
it("should escape single quotes in arguments", async () => {
|
||||
await safeSpawn("echo", ["don't break"]);
|
||||
|
||||
it(`should escape single quotes in arguments (${variant})`, async () => {
|
||||
await runSafeSpawn(variant, "echo", ["don't break"]);
|
||||
assert.strictEqual(spawnCalls.length, 1);
|
||||
// Single quote should be properly escaped with double quotes
|
||||
assert.strictEqual(spawnCalls[0].command, 'echo "don\'t break"');
|
||||
assert.strictEqual(spawnCalls[0].options.shell, true);
|
||||
});
|
||||
|
||||
assert.strictEqual(spawnCalls.length, 1);
|
||||
// Single quote should be properly escaped with double quotes
|
||||
assert.strictEqual(spawnCalls[0].command, 'echo "don\'t break"');
|
||||
assert.strictEqual(spawnCalls[0].options.shell, true);
|
||||
});
|
||||
it("should handle double quotes with simpler escaping", async () => {
|
||||
await safeSpawn("echo", ['say "hello"']);
|
||||
|
||||
it(`should handle double quotes with simpler escaping (${variant})`, async () => {
|
||||
await runSafeSpawn(variant, "echo", ['say "hello"']);
|
||||
assert.strictEqual(spawnCalls.length, 1);
|
||||
// If we switch to double quotes, this should be: "say \"hello\""
|
||||
assert.strictEqual(spawnCalls[0].command, 'echo "say \\"hello\\""');
|
||||
assert.strictEqual(spawnCalls[0].options.shell, true);
|
||||
});
|
||||
|
||||
assert.strictEqual(spawnCalls.length, 1);
|
||||
// If we switch to double quotes, this should be: "say \"hello\""
|
||||
assert.strictEqual(spawnCalls[0].command, 'echo "say \\"hello\\""');
|
||||
assert.strictEqual(spawnCalls[0].options.shell, true);
|
||||
});
|
||||
it("should not escape arguments with only safe characters", async () => {
|
||||
await safeSpawn("npm", ["install", "axios", "--save"]);
|
||||
|
||||
it(`should not escape arguments with only safe characters (${variant})`, async () => {
|
||||
await runSafeSpawn(variant, "npm", ["install", "axios", "--save"]);
|
||||
assert.strictEqual(spawnCalls.length, 1);
|
||||
// Safe arguments (alphanumeric, dash, underscore, dot, slash) shouldn't be quoted
|
||||
assert.strictEqual(spawnCalls[0].command, "npm install axios --save");
|
||||
assert.strictEqual(spawnCalls[0].options.shell, true);
|
||||
});
|
||||
|
||||
assert.strictEqual(spawnCalls.length, 1);
|
||||
// Safe arguments (alphanumeric, dash, underscore, dot, slash) shouldn't be quoted
|
||||
assert.strictEqual(spawnCalls[0].command, "npm install axios --save");
|
||||
assert.strictEqual(spawnCalls[0].options.shell, true);
|
||||
});
|
||||
it(`should escape ampersand character`, async () => {
|
||||
await safeSpawn("npx", ["cypress", "run", "--env", "password=foo&bar"]);
|
||||
|
||||
it(`should escape ampersand character (${variant})`, async () => {
|
||||
await runSafeSpawn(variant, "npx", ["cypress", "run", "--env", "password=foo&bar"]);
|
||||
|
||||
assert.strictEqual(spawnCalls.length, 1);
|
||||
// & should be escaped by wrapping the arg in quotes
|
||||
assert.strictEqual(spawnCalls[0].command, 'npx cypress run --env "password=foo&bar"');
|
||||
assert.strictEqual(spawnCalls[0].options.shell, true);
|
||||
});
|
||||
}
|
||||
});
|
||||
assert.strictEqual(spawnCalls.length, 1);
|
||||
// & should be escaped by wrapping the arg in quotes
|
||||
assert.strictEqual(
|
||||
spawnCalls[0].command,
|
||||
'npx cypress run --env "password=foo&bar"'
|
||||
);
|
||||
assert.strictEqual(spawnCalls[0].options.shell, true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue