Add rush command wrapper and tests

This commit is contained in:
James McMeeking 2026-04-02 12:31:02 +01:00
parent 308ccb3d2b
commit 5690e55d99
No known key found for this signature in database
GPG key ID: C69A11061EE15228
12 changed files with 403 additions and 7 deletions

View file

@ -0,0 +1,14 @@
#!/usr/bin/env node
import { main } from "../src/main.js";
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js";
setEcoSystem(ECOSYSTEM_JS);
const packageManagerName = "rush";
initializePackageManager(packageManagerName);
(async () => {
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

View file

@ -96,7 +96,7 @@ function writeHelp() {
ui.writeInformation(
`- ${chalk.cyan(
"safe-chain setup",
)}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, bun, bunx, pip and pip3.`,
)}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, rush, bun, bunx, pip and pip3.`,
);
ui.writeInformation(
`- ${chalk.cyan(

View file

@ -13,6 +13,7 @@
"aikido-yarn": "bin/aikido-yarn.js",
"aikido-pnpm": "bin/aikido-pnpm.js",
"aikido-pnpx": "bin/aikido-pnpx.js",
"aikido-rush": "bin/aikido-rush.js",
"aikido-bun": "bin/aikido-bun.js",
"aikido-bunx": "bin/aikido-bunx.js",
"aikido-uv": "bin/aikido-uv.js",
@ -36,7 +37,7 @@
"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/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), and [pip](https://pip.pypa.io/) 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, bunx, uv, or pip/pip3 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), [rush](https://rushjs.io/), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), and [pip](https://pip.pypa.io/) 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, rush, bun, bunx, uv, or pip/pip3 from downloading or running the malware.",
"dependencies": {
"archiver": "^7.0.1",
"certifi": "14.5.15",

View file

@ -13,6 +13,7 @@ import { createPipPackageManager } from "./pip/createPackageManager.js";
import { createUvPackageManager } from "./uv/createUvPackageManager.js";
import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js";
import { createPipXPackageManager } from "./pipx/createPipXPackageManager.js";
import { createRushPackageManager } from "./rush/createRushPackageManager.js";
/**
* @type {{packageManagerName: PackageManager | null}}
@ -64,6 +65,8 @@ export function initializePackageManager(packageManagerName, context) {
state.packageManagerName = createPoetryPackageManager();
} else if (packageManagerName === "pipx") {
state.packageManagerName = createPipXPackageManager();
} else if (packageManagerName === "rush") {
state.packageManagerName = createRushPackageManager();
} else {
throw new Error("Unsupported package manager: " + packageManagerName);
}

View file

@ -0,0 +1,134 @@
import { runRushCommand } from "./runRushCommand.js";
import { resolvePackageVersion } from "../../api/npmApi.js";
/**
* @returns {import("../currentPackageManager.js").PackageManager}
*/
export function createRushPackageManager() {
return {
runCommand: runRushCommand,
// We pre-scan rush add commands and rely on MITM for install/update flows.
isSupportedCommand: (args) => getRushCommand(args) === "add",
getDependencyUpdatesForCommand: scanRushAddCommand,
};
}
/**
* @param {string[]} args
* @returns {Promise<import("../currentPackageManager.js").GetDependencyUpdatesResult[]>}
*/
async function scanRushAddCommand(args) {
if (getRushCommand(args) !== "add") {
return [];
}
const packageSpecs = extractRushAddPackageSpecs(args);
const changes = [];
for (const spec of packageSpecs) {
const parsed = parsePackageSpec(spec);
if (!parsed) {
continue;
}
const exactVersion = await resolvePackageVersion(parsed.name, parsed.version);
if (!exactVersion) {
continue;
}
changes.push({
name: parsed.name,
version: exactVersion,
type: "add",
});
}
return changes;
}
/**
* @param {string[]} args
* @returns {string | undefined}
*/
function getRushCommand(args) {
if (!args || args.length === 0) {
return undefined;
}
return args[0]?.toLowerCase();
}
/**
* @param {string[]} args
* @returns {string[]}
*/
function extractRushAddPackageSpecs(args) {
const packageSpecs = [];
for (let i = 1; i < args.length; i++) {
const arg = args[i];
if (!arg) {
continue;
}
if (arg === "--package" || arg === "-p") {
const next = args[i + 1];
if (next && !next.startsWith("-")) {
packageSpecs.push(next);
i += 1;
}
continue;
}
if (arg.startsWith("--package=")) {
const value = arg.slice("--package=".length);
if (value) {
packageSpecs.push(value);
}
continue;
}
if (!arg.startsWith("-")) {
packageSpecs.push(arg);
}
}
return packageSpecs;
}
/**
* @param {string} spec
* @returns {{name: string, version: string | null} | null}
*/
function parsePackageSpec(spec) {
const value = removeAlias(spec.trim());
if (!value) {
return null;
}
const lastAtIndex = value.lastIndexOf("@");
if (lastAtIndex > 0) {
return {
name: value.slice(0, lastAtIndex),
version: value.slice(lastAtIndex + 1),
};
}
return {
name: value,
version: null,
};
}
/**
* @param {string} spec
* @returns {string}
*/
function removeAlias(spec) {
const aliasIndex = spec.indexOf("@npm:");
if (aliasIndex !== -1) {
return spec.slice(aliasIndex + 5);
}
return spec;
}

View file

@ -0,0 +1,66 @@
import { test, mock } from "node:test";
import assert from "node:assert";
test("createRushPackageManager", async (t) => {
mock.module("../../api/npmApi.js", {
namedExports: {
resolvePackageVersion: async (name, version) => {
if (name === "safe-chain-test") {
return "0.0.1-security";
}
if (name === "@scope/tool") {
return version || "2.0.0";
}
return null;
},
},
});
try {
const { createRushPackageManager } = await import("./createRushPackageManager.js");
await t.test("should create package manager with required interface", () => {
const pm = createRushPackageManager();
assert.ok(pm);
assert.strictEqual(typeof pm.runCommand, "function");
assert.strictEqual(typeof pm.isSupportedCommand, "function");
assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function");
});
await t.test("should scan rush add commands", () => {
const pm = createRushPackageManager();
assert.strictEqual(pm.isSupportedCommand(["add", "--package", "safe-chain-test"]), true);
assert.strictEqual(pm.isSupportedCommand(["install"]), false);
});
await t.test("should parse rush add package specs and resolve versions", async () => {
const pm = createRushPackageManager();
const changes = await pm.getDependencyUpdatesForCommand([
"add",
"--package",
"safe-chain-test",
"--package=@scope/tool@1.2.3",
]);
assert.deepStrictEqual(changes, [
{ name: "safe-chain-test", version: "0.0.1-security", type: "add" },
{ name: "@scope/tool", version: "1.2.3", type: "add" },
]);
});
await t.test("should return no changes for non-add commands", async () => {
const pm = createRushPackageManager();
const changes = await pm.getDependencyUpdatesForCommand(["install"]);
assert.deepStrictEqual(changes, []);
});
} finally {
mock.reset();
}
});

View file

@ -0,0 +1,63 @@
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { safeSpawn } from "../../utils/safeSpawn.js";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/**
* @param {string[]} args
* @returns {Promise<{status: number}>}
*/
export async function runRushCommand(args) {
try {
const env = mergeSafeChainProxyEnvironmentVariables(process.env);
normalizeProxyEnvironmentVariables(env);
const result = await safeSpawn("rush", args, {
stdio: "inherit",
env,
});
return { status: result.status };
} catch (/** @type any */ error) {
return reportCommandExecutionFailure(error, "rush");
}
}
/**
* Ensure proxy settings are visible to package manager variants that rely on
* lowercase or npm/yarn-specific environment variables.
*
* @param {Record<string, string>} env
*/
function normalizeProxyEnvironmentVariables(env) {
if (env.HTTPS_PROXY && !env.HTTP_PROXY) {
env.HTTP_PROXY = env.HTTPS_PROXY;
}
if (env.HTTP_PROXY && !env.http_proxy) {
env.http_proxy = env.HTTP_PROXY;
}
if (env.HTTPS_PROXY && !env.https_proxy) {
env.https_proxy = env.HTTPS_PROXY;
}
if (env.HTTP_PROXY && !env.npm_config_proxy) {
env.npm_config_proxy = env.HTTP_PROXY;
}
if (env.HTTPS_PROXY && !env.npm_config_https_proxy) {
env.npm_config_https_proxy = env.HTTPS_PROXY;
}
if (env.HTTP_PROXY && !env.NPM_CONFIG_PROXY) {
env.NPM_CONFIG_PROXY = env.HTTP_PROXY;
}
if (env.HTTPS_PROXY && !env.NPM_CONFIG_HTTPS_PROXY) {
env.NPM_CONFIG_HTTPS_PROXY = env.HTTPS_PROXY;
}
if (env.HTTPS_PROXY && !env.YARN_HTTPS_PROXY) {
env.YARN_HTTPS_PROXY = env.HTTPS_PROXY;
}
}

View file

@ -0,0 +1,99 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
describe("runRushCommand", () => {
let runRushCommand;
let safeSpawnMock;
let mergeCalls;
let nextSpawnStatus;
let nextSpawnError;
beforeEach(async () => {
mergeCalls = [];
nextSpawnStatus = 0;
nextSpawnError = null;
safeSpawnMock = mock.fn(async () => {
if (nextSpawnError) {
const error = nextSpawnError;
nextSpawnError = null;
throw error;
}
return { status: nextSpawnStatus };
});
mock.module("../../utils/safeSpawn.js", {
namedExports: {
safeSpawn: safeSpawnMock,
},
});
mock.module("../../registryProxy/registryProxy.js", {
namedExports: {
mergeSafeChainProxyEnvironmentVariables: (env) => {
mergeCalls.push(env);
return {
...env,
HTTPS_PROXY: "http://localhost:8080",
};
},
},
});
// commandErrors reports through ui on failures, so provide a no-op mock
mock.module("../../environment/userInteraction.js", {
namedExports: {
ui: {
writeError: () => {},
},
},
});
const mod = await import("./runRushCommand.js");
runRushCommand = mod.runRushCommand;
});
afterEach(() => {
mock.reset();
});
it("spawns rush with merged proxy env", async () => {
const res = await runRushCommand(["install"]);
assert.strictEqual(res.status, 0);
assert.strictEqual(safeSpawnMock.mock.calls.length, 1);
const [command, args, options] = safeSpawnMock.mock.calls[0].arguments;
assert.strictEqual(command, "rush");
assert.deepStrictEqual(args, ["install"]);
assert.strictEqual(options.stdio, "inherit");
assert.strictEqual(options.env.HTTPS_PROXY, "http://localhost:8080");
assert.strictEqual(options.env.HTTP_PROXY, "http://localhost:8080");
assert.strictEqual(options.env.https_proxy, "http://localhost:8080");
assert.strictEqual(options.env.http_proxy, "http://localhost:8080");
assert.strictEqual(options.env.npm_config_https_proxy, "http://localhost:8080");
assert.strictEqual(options.env.npm_config_proxy, "http://localhost:8080");
assert.strictEqual(options.env.NPM_CONFIG_HTTPS_PROXY, "http://localhost:8080");
assert.strictEqual(options.env.NPM_CONFIG_PROXY, "http://localhost:8080");
assert.strictEqual(options.env.YARN_HTTPS_PROXY, "http://localhost:8080");
assert.ok(mergeCalls.length >= 1, "proxy env merge should be called");
});
it("returns spawn result status", async () => {
nextSpawnStatus = 7;
const res = await runRushCommand(["update"]);
assert.strictEqual(res.status, 7);
});
it("reports failures with rush target", async () => {
nextSpawnError = Object.assign(new Error("spawn failed"), {
code: "ENOENT",
});
const res = await runRushCommand(["install"]);
assert.strictEqual(res.status, 1);
});
});

View file

@ -48,6 +48,12 @@ export const knownAikidoTools = [
ecoSystem: ECOSYSTEM_JS,
internalPackageManagerName: "pnpx",
},
{
tool: "rush",
aikidoCommand: "aikido-rush",
ecoSystem: ECOSYSTEM_JS,
internalPackageManagerName: "rush",
},
{
tool: "bun",
aikidoCommand: "aikido-bun",

View file

@ -48,8 +48,9 @@ describe("Setup CI shell integration", () => {
knownAikidoTools: [
{ tool: "npm", aikidoCommand: "aikido-npm" },
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
{ tool: "rush", aikidoCommand: "aikido-rush" },
],
getPackageManagerList: () => "npm, yarn",
getPackageManagerList: () => "npm, yarn, rush",
getShimsDir: () => mockShimsDir,
},
});
@ -115,6 +116,10 @@ describe("Setup CI shell integration", () => {
const yarnShimPath = path.join(mockShimsDir, "yarn");
assert.ok(fs.existsSync(yarnShimPath), "yarn shim should exist");
// Check if rush shim was created
const rushShimPath = path.join(mockShimsDir, "rush");
assert.ok(fs.existsSync(rushShimPath), "rush shim should exist");
// Check content of npm shim
const npmShimContent = fs.readFileSync(npmShimPath, "utf-8");
assert.ok(npmShimContent.includes("aikido-npm"), "npm shim should contain aikido-npm");
@ -137,6 +142,9 @@ describe("Setup CI shell integration", () => {
const yarnShimPath = path.join(mockShimsDir, "yarn.cmd");
assert.ok(fs.existsSync(yarnShimPath), "yarn.cmd shim should exist");
const rushShimPath = path.join(mockShimsDir, "rush.cmd");
assert.ok(fs.existsSync(rushShimPath), "rush.cmd shim should exist");
// Check content of npm.cmd shim
const npmShimContent = fs.readFileSync(npmShimPath, "utf-8");
assert.ok(npmShimContent.includes("aikido-npm"), "npm.cmd should contain aikido-npm");