From 5690e55d99be78d683196c55bb679c74eb2c699c Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Thu, 2 Apr 2026 12:31:02 +0100 Subject: [PATCH 01/13] Add rush command wrapper and tests --- README.md | 9 +- package-lock.json | 1 + packages/safe-chain/bin/aikido-rush.js | 14 ++ packages/safe-chain/bin/safe-chain.js | 2 +- packages/safe-chain/package.json | 3 +- .../packagemanager/currentPackageManager.js | 3 + .../rush/createRushPackageManager.js | 134 ++++++++++++++++++ .../rush/createRushPackageManager.spec.js | 66 +++++++++ .../src/packagemanager/rush/runRushCommand.js | 63 ++++++++ .../rush/runRushCommand.spec.js | 99 +++++++++++++ .../src/shell-integration/helpers.js | 6 + .../src/shell-integration/setup-ci.spec.js | 10 +- 12 files changed, 403 insertions(+), 7 deletions(-) create mode 100755 packages/safe-chain/bin/aikido-rush.js create mode 100644 packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js create mode 100644 packages/safe-chain/src/packagemanager/rush/createRushPackageManager.spec.js create mode 100644 packages/safe-chain/src/packagemanager/rush/runRushCommand.js create mode 100644 packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js diff --git a/README.md b/README.md index e173b662..956526be 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Aikido Safe Chain supports the following package managers: - πŸ“¦ **yarn** - πŸ“¦ **pnpm** - πŸ“¦ **pnpx** +- πŸ“¦ **rush** - πŸ“¦ **bun** - πŸ“¦ **bunx** - πŸ“¦ **pip** @@ -66,7 +67,7 @@ You can find all available versions on the [releases page](https://github.com/Ai ### Verify the installation 1. **❗Restart your terminal** to start using the Aikido Safe Chain. - - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, poetry, uv and pipx are loaded correctly. If you do not restart your terminal, the aliases will not be available. + - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, rush, bun, bunx, pip, pip3, poetry, uv and pipx are loaded correctly. If you do not restart your terminal, the aliases will not be available. 2. **Verify the installation** by running the verification command: @@ -97,7 +98,7 @@ You can find all available versions on the [releases page](https://github.com/Ai - The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware. -When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry` and `pipx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command. +When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry` and `pipx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command. You can check the installed version by running: @@ -109,7 +110,7 @@ safe-chain --version ### Malware Blocking -The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, uv, poetry or pipx commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. +The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, rush, bun, bunx, pip, pip3, uv, poetry or pipx commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. ### Minimum package age @@ -127,7 +128,7 @@ By default, the minimum package age is 48 hours. This provides an additional sec ### Shell Integration -The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and Python package managers (pip, uv, poetry, pipx). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support: +The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, rush, bun, bunx, and Python package managers (pip, uv, poetry, pipx). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support: - βœ… **Bash** - βœ… **Zsh** diff --git a/package-lock.json b/package-lock.json index ea8c4105..75d73b83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4026,6 +4026,7 @@ "aikido-poetry": "bin/aikido-poetry.js", "aikido-python": "bin/aikido-python.js", "aikido-python3": "bin/aikido-python3.js", + "aikido-rush": "bin/aikido-rush.js", "aikido-uv": "bin/aikido-uv.js", "aikido-yarn": "bin/aikido-yarn.js", "safe-chain": "bin/safe-chain.js" diff --git a/packages/safe-chain/bin/aikido-rush.js b/packages/safe-chain/bin/aikido-rush.js new file mode 100755 index 00000000..b5d8094e --- /dev/null +++ b/packages/safe-chain/bin/aikido-rush.js @@ -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); +})(); diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index 8d942e47..a3f80b15 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -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( diff --git a/packages/safe-chain/package.json b/packages/safe-chain/package.json index d4f3501c..dae27c34 100644 --- a/packages/safe-chain/package.json +++ b/packages/safe-chain/package.json @@ -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", diff --git a/packages/safe-chain/src/packagemanager/currentPackageManager.js b/packages/safe-chain/src/packagemanager/currentPackageManager.js index af297dc4..45d897e0 100644 --- a/packages/safe-chain/src/packagemanager/currentPackageManager.js +++ b/packages/safe-chain/src/packagemanager/currentPackageManager.js @@ -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); } diff --git a/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js b/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js new file mode 100644 index 00000000..1a4aebb3 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js @@ -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} + */ +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; +} diff --git a/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.spec.js b/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.spec.js new file mode 100644 index 00000000..5c02f52f --- /dev/null +++ b/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.spec.js @@ -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(); + } +}); diff --git a/packages/safe-chain/src/packagemanager/rush/runRushCommand.js b/packages/safe-chain/src/packagemanager/rush/runRushCommand.js new file mode 100644 index 00000000..ebc3bf17 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/rush/runRushCommand.js @@ -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} 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; + } +} diff --git a/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js b/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js new file mode 100644 index 00000000..97676e41 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js @@ -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); + }); +}); diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index 18ba52e7..5791abaf 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -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", diff --git a/packages/safe-chain/src/shell-integration/setup-ci.spec.js b/packages/safe-chain/src/shell-integration/setup-ci.spec.js index b4371573..bbd05dcb 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.spec.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.spec.js @@ -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"); From 6f976f6a2b90b2c218a93f2dca480764d8da6ce5 Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Thu, 2 Apr 2026 13:03:01 +0100 Subject: [PATCH 02/13] Address PR comments --- .../rush/createRushPackageManager.js | 30 ++++++++----- .../src/packagemanager/rush/runRushCommand.js | 44 +++++++++++-------- .../rush/runRushCommand.spec.js | 18 ++++++++ 3 files changed, 62 insertions(+), 30 deletions(-) diff --git a/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js b/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js index 1a4aebb3..16c5815b 100644 --- a/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js +++ b/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js @@ -22,23 +22,29 @@ async function scanRushAddCommand(args) { return []; } - const packageSpecs = extractRushAddPackageSpecs(args); - const changes = []; + const parsedSpecs = extractRushAddPackageSpecs(args) + .map((spec) => parsePackageSpec(spec)) + .filter((spec) => spec !== null); + + const resolvedVersions = await Promise.all( + parsedSpecs.map(async (parsed) => { + const exactVersion = await resolvePackageVersion(parsed.name, parsed.version); + return { + parsed, + exactVersion, + }; + }), + ); - for (const spec of packageSpecs) { - const parsed = parsePackageSpec(spec); - if (!parsed) { - continue; - } - - const exactVersion = await resolvePackageVersion(parsed.name, parsed.version); - if (!exactVersion) { + const changes = []; + for (const resolved of resolvedVersions) { + if (!resolved.exactVersion) { continue; } changes.push({ - name: parsed.name, - version: exactVersion, + name: resolved.parsed.name, + version: resolved.exactVersion, type: "add", }); } diff --git a/packages/safe-chain/src/packagemanager/rush/runRushCommand.js b/packages/safe-chain/src/packagemanager/rush/runRushCommand.js index ebc3bf17..f6ba3cca 100644 --- a/packages/safe-chain/src/packagemanager/rush/runRushCommand.js +++ b/packages/safe-chain/src/packagemanager/rush/runRushCommand.js @@ -8,8 +8,9 @@ import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; */ export async function runRushCommand(args) { try { - const env = mergeSafeChainProxyEnvironmentVariables(process.env); - normalizeProxyEnvironmentVariables(env); + const env = normalizeProxyEnvironmentVariables( + mergeSafeChainProxyEnvironmentVariables(process.env), + ); const result = await safeSpawn("rush", args, { stdio: "inherit", @@ -27,37 +28,44 @@ export async function runRushCommand(args) { * lowercase or npm/yarn-specific environment variables. * * @param {Record} env + * @returns {Record} */ function normalizeProxyEnvironmentVariables(env) { - if (env.HTTPS_PROXY && !env.HTTP_PROXY) { - env.HTTP_PROXY = env.HTTPS_PROXY; + const normalized = { + ...env, + }; + + if (normalized.HTTPS_PROXY && !normalized.HTTP_PROXY) { + normalized.HTTP_PROXY = normalized.HTTPS_PROXY; } - if (env.HTTP_PROXY && !env.http_proxy) { - env.http_proxy = env.HTTP_PROXY; + if (normalized.HTTP_PROXY && !normalized.http_proxy) { + normalized.http_proxy = normalized.HTTP_PROXY; } - if (env.HTTPS_PROXY && !env.https_proxy) { - env.https_proxy = env.HTTPS_PROXY; + if (normalized.HTTPS_PROXY && !normalized.https_proxy) { + normalized.https_proxy = normalized.HTTPS_PROXY; } - if (env.HTTP_PROXY && !env.npm_config_proxy) { - env.npm_config_proxy = env.HTTP_PROXY; + if (normalized.HTTP_PROXY && !normalized.npm_config_proxy) { + normalized.npm_config_proxy = normalized.HTTP_PROXY; } - if (env.HTTPS_PROXY && !env.npm_config_https_proxy) { - env.npm_config_https_proxy = env.HTTPS_PROXY; + if (normalized.HTTPS_PROXY && !normalized.npm_config_https_proxy) { + normalized.npm_config_https_proxy = normalized.HTTPS_PROXY; } - if (env.HTTP_PROXY && !env.NPM_CONFIG_PROXY) { - env.NPM_CONFIG_PROXY = env.HTTP_PROXY; + if (normalized.HTTP_PROXY && !normalized.NPM_CONFIG_PROXY) { + normalized.NPM_CONFIG_PROXY = normalized.HTTP_PROXY; } - if (env.HTTPS_PROXY && !env.NPM_CONFIG_HTTPS_PROXY) { - env.NPM_CONFIG_HTTPS_PROXY = env.HTTPS_PROXY; + if (normalized.HTTPS_PROXY && !normalized.NPM_CONFIG_HTTPS_PROXY) { + normalized.NPM_CONFIG_HTTPS_PROXY = normalized.HTTPS_PROXY; } - if (env.HTTPS_PROXY && !env.YARN_HTTPS_PROXY) { - env.YARN_HTTPS_PROXY = env.HTTPS_PROXY; + if (normalized.HTTPS_PROXY && !normalized.YARN_HTTPS_PROXY) { + normalized.YARN_HTTPS_PROXY = normalized.HTTPS_PROXY; } + + return normalized; } diff --git a/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js b/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js index 97676e41..b21087e4 100644 --- a/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js +++ b/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js @@ -5,11 +5,13 @@ describe("runRushCommand", () => { let runRushCommand; let safeSpawnMock; let mergeCalls; + let mergeResultEnv; let nextSpawnStatus; let nextSpawnError; beforeEach(async () => { mergeCalls = []; + mergeResultEnv = null; nextSpawnStatus = 0; nextSpawnError = null; safeSpawnMock = mock.fn(async () => { @@ -32,6 +34,10 @@ describe("runRushCommand", () => { namedExports: { mergeSafeChainProxyEnvironmentVariables: (env) => { mergeCalls.push(env); + if (mergeResultEnv) { + return mergeResultEnv; + } + return { ...env, HTTPS_PROXY: "http://localhost:8080", @@ -96,4 +102,16 @@ describe("runRushCommand", () => { assert.strictEqual(res.status, 1); }); + + it("does not mutate merged env object", async () => { + mergeResultEnv = { + HTTPS_PROXY: "http://localhost:8080", + }; + + await runRushCommand(["install"]); + + assert.deepStrictEqual(mergeResultEnv, { + HTTPS_PROXY: "http://localhost:8080", + }); + }); }); From 98a1ba7d103368ab2d4c19facb77f927926afaa1 Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Fri, 1 May 2026 17:04:28 +0100 Subject: [PATCH 03/13] Add rushx support too Co-authored-by: Copilot --- README.md | 9 +++++---- docs/shell-integration.md | 8 ++++---- packages/safe-chain/bin/aikido-rushx.js | 14 ++++++++++++++ packages/safe-chain/bin/safe-chain.js | 2 +- packages/safe-chain/package.json | 3 ++- .../packagemanager/currentPackageManager.js | 3 +++ .../rush/createRushPackageManager.js | 2 +- .../src/packagemanager/rush/runRushCommand.js | 7 ++++--- .../packagemanager/rush/runRushCommand.spec.js | 8 ++++---- .../rushx/createRushxPackageManager.js | 18 ++++++++++++++++++ .../rushx/createRushxPackageManager.spec.js | 14 ++++++++++++++ .../src/shell-integration/helpers.js | 6 ++++++ .../src/shell-integration/setup-ci.spec.js | 10 +--------- .../startup-scripts/init-fish.fish | 8 ++++++++ .../startup-scripts/init-posix.sh | 8 ++++++++ .../startup-scripts/init-pwsh.ps1 | 8 ++++++++ 16 files changed, 101 insertions(+), 27 deletions(-) create mode 100755 packages/safe-chain/bin/aikido-rushx.js create mode 100644 packages/safe-chain/src/packagemanager/rushx/createRushxPackageManager.js create mode 100644 packages/safe-chain/src/packagemanager/rushx/createRushxPackageManager.spec.js diff --git a/README.md b/README.md index a3f7a872..41785e17 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Aikido Safe Chain supports the following package managers: - πŸ“¦ **pnpm** - πŸ“¦ **pnpx** - πŸ“¦ **rush** +- πŸ“¦ **rushx** - πŸ“¦ **bun** - πŸ“¦ **bunx** - πŸ“¦ **pip** @@ -76,7 +77,7 @@ You can find all available versions on the [releases page](https://github.com/Ai ### Verify the installation 1. **❗Restart your terminal** to start using the Aikido Safe Chain. - - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, rush, bun, bunx, pip, pip3, poetry, uv, uvx and pipx are loaded correctly. If you do not restart your terminal, the aliases will not be available. + - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, pip, pip3, poetry, uv, uvx and pipx are loaded correctly. If you do not restart your terminal, the aliases will not be available. 2. **Verify the installation** by running the verification command: @@ -107,7 +108,7 @@ You can find all available versions on the [releases page](https://github.com/Ai - The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware. -When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command. +When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command. You can check the installed version by running: @@ -119,7 +120,7 @@ safe-chain --version ### Malware Blocking -The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, rush, bun, bunx, pip, pip3, uv, uvx, poetry or pipx commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. +The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, pip, pip3, uv, uvx, poetry or pipx commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. ### Minimum package age @@ -138,7 +139,7 @@ By default, the minimum package age is 48 hours. This provides an additional sec ### Shell Integration -The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, rush, bun, bunx, and Python package managers (pip, uv, uvx, poetry, pipx). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support: +The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, and Python package managers (pip, uv, uvx, poetry, pipx). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support: - βœ… **Bash** - βœ… **Zsh** diff --git a/docs/shell-integration.md b/docs/shell-integration.md index 2e36d0ab..d6cc0e0a 100644 --- a/docs/shell-integration.md +++ b/docs/shell-integration.md @@ -2,7 +2,7 @@ ## Overview -The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry`, `pipx`) with Aikido's security scanning functionality. It also intercepts Python module invocations for pip when available: `python -m pip`, `python -m pip3`, `python3 -m pip`, `python3 -m pip3`. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents. +The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry`, `pipx`) with Aikido's security scanning functionality. It also intercepts Python module invocations for pip when available: `python -m pip`, `python -m pip3`, `python3 -m pip`, `python3 -m pip3`. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents. ## Supported Shells @@ -28,7 +28,7 @@ This command: - Copies necessary startup scripts to Safe Chain's installation directory (`~/.safe-chain/scripts`) - Detects all supported shells on your system -- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` +- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` - Adds lightweight interceptors so `python -m pip[...]` and `python3 -m pip[...]` route through Safe Chain when invoked by name ❗ After running this command, **you must restart your terminal** for the changes to take effect. This ensures that the startup scripts are sourced correctly. @@ -78,7 +78,7 @@ The system modifies the following files to source Safe Chain startup scripts: This means the shell functions are working but the Aikido commands aren't installed or available in your PATH: - Make sure Aikido Safe Chain is properly installed on your system -- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-bun`, `aikido-bunx`, `aikido-pip`, `aikido-pip3`, `aikido-uv`, `aikido-uvx`, `aikido-poetry` and `aikido-pipx` commands exist +- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-rush`, `aikido-rushx`, `aikido-bun`, `aikido-bunx`, `aikido-pip`, `aikido-pip3`, `aikido-uv`, `aikido-uvx`, `aikido-poetry` and `aikido-pipx` commands exist - Check that these commands are in your system's PATH ### Manual Verification @@ -121,7 +121,7 @@ npm() { } ``` -Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes. +Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes. To intercept Python module invocations for pip without altering Python itself, you can add small forwarding functions: diff --git a/packages/safe-chain/bin/aikido-rushx.js b/packages/safe-chain/bin/aikido-rushx.js new file mode 100755 index 00000000..dfa168c6 --- /dev/null +++ b/packages/safe-chain/bin/aikido-rushx.js @@ -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 = "rushx"; +initializePackageManager(packageManagerName); + +(async () => { + var exitCode = await main(process.argv.slice(2)); + process.exit(exitCode); +})(); diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index e1f801c0..900bd837 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -108,7 +108,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, rush, bun, bunx, pip and pip3.`, + )}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, pip and pip3.`, ); ui.writeInformation( `- ${chalk.cyan( diff --git a/packages/safe-chain/package.json b/packages/safe-chain/package.json index 42766d78..f7ae9338 100644 --- a/packages/safe-chain/package.json +++ b/packages/safe-chain/package.json @@ -14,6 +14,7 @@ "aikido-pnpm": "bin/aikido-pnpm.js", "aikido-pnpx": "bin/aikido-pnpx.js", "aikido-rush": "bin/aikido-rush.js", + "aikido-rushx": "bin/aikido-rushx.js", "aikido-bun": "bin/aikido-bun.js", "aikido-bunx": "bin/aikido-bunx.js", "aikido-uv": "bin/aikido-uv.js", @@ -38,7 +39,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), [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, uvx, 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/), [rushx](https://rushjs.io/pages/commands/rushx/), [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, rushx, bun, bunx, uv, uvx, or pip/pip3 from downloading or running the malware.", "dependencies": { "certifi": "14.5.15", "chalk": "5.4.1", diff --git a/packages/safe-chain/src/packagemanager/currentPackageManager.js b/packages/safe-chain/src/packagemanager/currentPackageManager.js index ee68ee19..90050d38 100644 --- a/packages/safe-chain/src/packagemanager/currentPackageManager.js +++ b/packages/safe-chain/src/packagemanager/currentPackageManager.js @@ -14,6 +14,7 @@ import { createUvPackageManager } from "./uv/createUvPackageManager.js"; import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js"; import { createPipXPackageManager } from "./pipx/createPipXPackageManager.js"; import { createRushPackageManager } from "./rush/createRushPackageManager.js"; +import { createRushxPackageManager } from "./rushx/createRushxPackageManager.js"; import { createUvxPackageManager } from "./uvx/createUvxPackageManager.js"; /** @@ -70,6 +71,8 @@ export function initializePackageManager(packageManagerName, context) { state.packageManagerName = createPipXPackageManager(); } else if (packageManagerName === "rush") { state.packageManagerName = createRushPackageManager(); + } else if (packageManagerName === "rushx") { + state.packageManagerName = createRushxPackageManager(); } else { throw new Error("Unsupported package manager: " + packageManagerName); } diff --git a/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js b/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js index 16c5815b..d51a832c 100644 --- a/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js +++ b/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js @@ -6,7 +6,7 @@ import { resolvePackageVersion } from "../../api/npmApi.js"; */ export function createRushPackageManager() { return { - runCommand: runRushCommand, + runCommand: (args) => runRushCommand("rush", args), // We pre-scan rush add commands and rely on MITM for install/update flows. isSupportedCommand: (args) => getRushCommand(args) === "add", getDependencyUpdatesForCommand: scanRushAddCommand, diff --git a/packages/safe-chain/src/packagemanager/rush/runRushCommand.js b/packages/safe-chain/src/packagemanager/rush/runRushCommand.js index f6ba3cca..ed43c237 100644 --- a/packages/safe-chain/src/packagemanager/rush/runRushCommand.js +++ b/packages/safe-chain/src/packagemanager/rush/runRushCommand.js @@ -3,23 +3,24 @@ import { safeSpawn } from "../../utils/safeSpawn.js"; import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; /** + * @param {"rush" | "rushx"} executableName * @param {string[]} args * @returns {Promise<{status: number}>} */ -export async function runRushCommand(args) { +export async function runRushCommand(executableName, args) { try { const env = normalizeProxyEnvironmentVariables( mergeSafeChainProxyEnvironmentVariables(process.env), ); - const result = await safeSpawn("rush", args, { + const result = await safeSpawn(executableName, args, { stdio: "inherit", env, }); return { status: result.status }; } catch (/** @type any */ error) { - return reportCommandExecutionFailure(error, "rush"); + return reportCommandExecutionFailure(error, executableName); } } diff --git a/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js b/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js index b21087e4..daabcabf 100644 --- a/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js +++ b/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js @@ -64,7 +64,7 @@ describe("runRushCommand", () => { }); it("spawns rush with merged proxy env", async () => { - const res = await runRushCommand(["install"]); + const res = await runRushCommand("rush", ["install"]); assert.strictEqual(res.status, 0); assert.strictEqual(safeSpawnMock.mock.calls.length, 1); @@ -88,7 +88,7 @@ describe("runRushCommand", () => { it("returns spawn result status", async () => { nextSpawnStatus = 7; - const res = await runRushCommand(["update"]); + const res = await runRushCommand("rush", ["update"]); assert.strictEqual(res.status, 7); }); @@ -98,7 +98,7 @@ describe("runRushCommand", () => { code: "ENOENT", }); - const res = await runRushCommand(["install"]); + const res = await runRushCommand("rush", ["install"]); assert.strictEqual(res.status, 1); }); @@ -108,7 +108,7 @@ describe("runRushCommand", () => { HTTPS_PROXY: "http://localhost:8080", }; - await runRushCommand(["install"]); + await runRushCommand("rush", ["install"]); assert.deepStrictEqual(mergeResultEnv, { HTTPS_PROXY: "http://localhost:8080", diff --git a/packages/safe-chain/src/packagemanager/rushx/createRushxPackageManager.js b/packages/safe-chain/src/packagemanager/rushx/createRushxPackageManager.js new file mode 100644 index 00000000..af89d214 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/rushx/createRushxPackageManager.js @@ -0,0 +1,18 @@ +import { runRushCommand } from "../rush/runRushCommand.js"; + +/** + * @returns {import("../currentPackageManager.js").PackageManager} + */ +export function createRushxPackageManager() { + return { + /** + * @param {string[]} args + */ + runCommand: (args) => { + return runRushCommand("rushx", args); + }, + // For rushx, rely solely on MITM. + isSupportedCommand: () => false, + getDependencyUpdatesForCommand: () => [], + }; +} diff --git a/packages/safe-chain/src/packagemanager/rushx/createRushxPackageManager.spec.js b/packages/safe-chain/src/packagemanager/rushx/createRushxPackageManager.spec.js new file mode 100644 index 00000000..20b4a322 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/rushx/createRushxPackageManager.spec.js @@ -0,0 +1,14 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { createRushxPackageManager } from "./createRushxPackageManager.js"; + +test("createRushxPackageManager returns valid package manager interface", () => { + const pm = createRushxPackageManager(); + + assert.ok(pm); + assert.strictEqual(typeof pm.runCommand, "function"); + assert.strictEqual(typeof pm.isSupportedCommand, "function"); + assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function"); + assert.strictEqual(pm.isSupportedCommand(), false); + assert.deepStrictEqual(pm.getDependencyUpdatesForCommand(), []); +}); diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index f61ff98d..dd10f3f3 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -54,6 +54,12 @@ export const knownAikidoTools = [ ecoSystem: ECOSYSTEM_JS, internalPackageManagerName: "rush", }, + { + tool: "rushx", + aikidoCommand: "aikido-rushx", + ecoSystem: ECOSYSTEM_JS, + internalPackageManagerName: "rushx", + }, { tool: "bun", aikidoCommand: "aikido-bun", diff --git a/packages/safe-chain/src/shell-integration/setup-ci.spec.js b/packages/safe-chain/src/shell-integration/setup-ci.spec.js index 44381242..7af41d65 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.spec.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.spec.js @@ -48,9 +48,8 @@ describe("Setup CI shell integration", () => { knownAikidoTools: [ { tool: "npm", aikidoCommand: "aikido-npm" }, { tool: "yarn", aikidoCommand: "aikido-yarn" }, - { tool: "rush", aikidoCommand: "aikido-rush" }, ], - getPackageManagerList: () => "npm, yarn, rush", + getPackageManagerList: () => "npm, yarn", }, }); @@ -108,10 +107,6 @@ 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"); @@ -138,9 +133,6 @@ 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"); diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish index 06960ef3..728aff1a 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish @@ -19,6 +19,14 @@ function pnpx wrapSafeChainCommand "pnpx" $argv end +function rush + wrapSafeChainCommand "rush" $argv +end + +function rushx + wrapSafeChainCommand "rushx" $argv +end + function bun wrapSafeChainCommand "bun" $argv end diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh index 452e62d0..cde8f484 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh @@ -28,6 +28,14 @@ function pnpx() { wrapSafeChainCommand "pnpx" "$@" } +function rush() { + wrapSafeChainCommand "rush" "$@" +} + +function rushx() { + wrapSafeChainCommand "rushx" "$@" +} + function bun() { wrapSafeChainCommand "bun" "$@" } diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 index f65deb9e..7aad2fcf 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 @@ -22,6 +22,14 @@ function pnpx { Invoke-WrappedCommand "pnpx" $args $MyInvocation.Line $MyInvocation.OffsetInLine } +function rush { + Invoke-WrappedCommand "rush" $args $MyInvocation.Line $MyInvocation.OffsetInLine +} + +function rushx { + Invoke-WrappedCommand "rushx" $args $MyInvocation.Line $MyInvocation.OffsetInLine +} + function bun { Invoke-WrappedCommand "bun" $args $MyInvocation.Line $MyInvocation.OffsetInLine } From 08ae1ef732a40340d523a01b184289bd7840d12e Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Fri, 8 May 2026 11:08:58 +0100 Subject: [PATCH 04/13] Pull parsing logic into distinct file and remove invalid continue --- .../rush/createRushPackageManager.js | 80 +------------------ .../parsing/parsePackagesFromRushAddArgs.js | 71 ++++++++++++++++ .../parsePackagesFromRushAddArgs.spec.js | 49 ++++++++++++ 3 files changed, 122 insertions(+), 78 deletions(-) create mode 100644 packages/safe-chain/src/packagemanager/rush/parsing/parsePackagesFromRushAddArgs.js create mode 100644 packages/safe-chain/src/packagemanager/rush/parsing/parsePackagesFromRushAddArgs.spec.js diff --git a/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js b/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js index d51a832c..85ec4d58 100644 --- a/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js +++ b/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js @@ -1,5 +1,6 @@ import { runRushCommand } from "./runRushCommand.js"; import { resolvePackageVersion } from "../../api/npmApi.js"; +import { parsePackagesFromRushAddArgs } from "./parsing/parsePackagesFromRushAddArgs.js"; /** * @returns {import("../currentPackageManager.js").PackageManager} @@ -22,9 +23,7 @@ async function scanRushAddCommand(args) { return []; } - const parsedSpecs = extractRushAddPackageSpecs(args) - .map((spec) => parsePackageSpec(spec)) - .filter((spec) => spec !== null); + const parsedSpecs = parsePackagesFromRushAddArgs(args.slice(1)); const resolvedVersions = await Promise.all( parsedSpecs.map(async (parsed) => { @@ -63,78 +62,3 @@ function getRushCommand(args) { 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; -} diff --git a/packages/safe-chain/src/packagemanager/rush/parsing/parsePackagesFromRushAddArgs.js b/packages/safe-chain/src/packagemanager/rush/parsing/parsePackagesFromRushAddArgs.js new file mode 100644 index 00000000..3e820856 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/rush/parsing/parsePackagesFromRushAddArgs.js @@ -0,0 +1,71 @@ +/** + * @param {string[]} args + * @returns {{name: string, version: string | null}[]} + */ +export function parsePackagesFromRushAddArgs(args) { + const packageSpecs = []; + + for (let i = 0; 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); + } + } + } + + return packageSpecs + .map((spec) => parsePackageSpec(spec)) + .filter((spec) => spec !== null); +} + +/** + * @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; +} diff --git a/packages/safe-chain/src/packagemanager/rush/parsing/parsePackagesFromRushAddArgs.spec.js b/packages/safe-chain/src/packagemanager/rush/parsing/parsePackagesFromRushAddArgs.spec.js new file mode 100644 index 00000000..0607c82c --- /dev/null +++ b/packages/safe-chain/src/packagemanager/rush/parsing/parsePackagesFromRushAddArgs.spec.js @@ -0,0 +1,49 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { parsePackagesFromRushAddArgs } from "./parsePackagesFromRushAddArgs.js"; + +describe("parsePackagesFromRushAddArgs", () => { + it("returns an empty array when no packages are provided", () => { + const result = parsePackagesFromRushAddArgs([]); + + assert.deepEqual(result, []); + }); + + it("parses packages from --package arguments", () => { + const result = parsePackagesFromRushAddArgs([ + "--package", + "axios@1.9.0", + "--package", + "@scope/tool@2.0.0", + ]); + + assert.deepEqual(result, [ + { name: "axios", version: "1.9.0" }, + { name: "@scope/tool", version: "2.0.0" }, + ]); + }); + + it("parses packages from -p arguments", () => { + const result = parsePackagesFromRushAddArgs(["-p", "axios"]); + + assert.deepEqual(result, [{ name: "axios", version: null }]); + }); + + it("parses packages from --package=value arguments", () => { + const result = parsePackagesFromRushAddArgs(["--package=axios@^1.9.0"]); + + assert.deepEqual(result, [{ name: "axios", version: "^1.9.0" }]); + }); + + it("ignores positional packages because rush add requires --package", () => { + const result = parsePackagesFromRushAddArgs(["axios", "--dev"]); + + assert.deepEqual(result, []); + }); + + it("parses aliases", () => { + const result = parsePackagesFromRushAddArgs(["--package", "server@npm:axios@1.9.0"]); + + assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]); + }); +}); From 5f561141857c9324e33d423bfd70b40267307043 Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Fri, 8 May 2026 11:24:17 +0100 Subject: [PATCH 05/13] Add e2e tests Note: rushx only dispatches package.json scripts, so it's probably not necessary to add it as a distinct manager at all. --- test/e2e/Dockerfile | 2 + test/e2e/rush.e2e.spec.js | 105 +++++++++++++++++++++++++++++++++++++ test/e2e/rushx.e2e.spec.js | 100 +++++++++++++++++++++++++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 test/e2e/rush.e2e.spec.js create mode 100644 test/e2e/rushx.e2e.spec.js diff --git a/test/e2e/Dockerfile b/test/e2e/Dockerfile index 3de600ca..c448b094 100644 --- a/test/e2e/Dockerfile +++ b/test/e2e/Dockerfile @@ -25,6 +25,7 @@ ARG NODE_VERSION=latest ARG NPM_VERSION=latest ARG YARN_VERSION=latest ARG PNPM_VERSION=latest +ARG RUSH_VERSION=latest ARG PYTHON_VERSION=3 SHELL ["/bin/bash", "-c"] @@ -46,6 +47,7 @@ RUN volta install node@${NODE_VERSION} RUN volta install npm@${NPM_VERSION} RUN volta install yarn@${YARN_VERSION} RUN volta install pnpm@${PNPM_VERSION} +RUN npm install -g @microsoft/rush@${RUSH_VERSION} # Install Bun RUN curl -fsSL https://bun.sh/install | bash diff --git a/test/e2e/rush.e2e.spec.js b/test/e2e/rush.e2e.spec.js new file mode 100644 index 00000000..efe7eadc --- /dev/null +++ b/test/e2e/rush.e2e.spec.js @@ -0,0 +1,105 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +describe("E2E: rush coverage", () => { + let container; + + before(async () => { + DockerTestContainer.buildImage(); + }); + + beforeEach(async () => { + container = new DockerTestContainer(); + await container.start(); + + const installationShell = await container.openShell("zsh"); + await installationShell.runCommand("safe-chain setup"); + await setupRushWorkspace(installationShell); + }); + + afterEach(async () => { + if (container) { + await container.stop(); + container = null; + } + }); + + it("safe-chain successfully adds safe packages", async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "cd /testapp/apps/test-app && rush add --package axios@1.13.0 --exact --skip-update --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it("safe-chain blocks rush add of malicious packages", async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "cd /testapp/apps/test-app && rush add --package safe-chain-test --skip-update" + ); + + assert.ok( + result.output.includes("Malicious changes detected:"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("- safe-chain-test"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Output did not include expected text. Output was:\n${result.output}` + ); + + const packageJson = await shell.runCommand( + "cat /testapp/apps/test-app/package.json" + ); + + assert.ok( + !packageJson.output.includes("safe-chain-test"), + `Malicious package was added despite safe-chain protection. Output was:\n${packageJson.output}` + ); + }); +}); + +async function setupRushWorkspace(shell) { + await shell.runCommand("mkdir -p /testapp/common/config/rush /testapp/apps/test-app"); + await shell.runCommand(`cat > /testapp/common/config/rush/rush.json <<'EOF' +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush.schema.json", + "rushVersion": "5.175.1", + "pnpmVersion": "11.0.6", + "nodeSupportedVersionRange": ">=18.0.0", + "projectFolderMinDepth": 1, + "projectFolderMaxDepth": 2, + "gitPolicy": {}, + "repository": { + "url": "https://example.com/testapp.git", + "defaultBranch": "main" + }, + "eventHooks": { + "preRushInstall": [], + "postRushInstall": [], + "preRushBuild": [], + "postRushBuild": [] + }, + "projects": [ + { + "packageName": "test-app", + "projectFolder": "apps/test-app" + } + ] +} +EOF`); + await shell.runCommand(`cat > /testapp/apps/test-app/package.json <<'EOF' +{ + "name": "test-app", + "version": "1.0.0" +} +EOF`); +} diff --git a/test/e2e/rushx.e2e.spec.js b/test/e2e/rushx.e2e.spec.js new file mode 100644 index 00000000..aaadf4e6 --- /dev/null +++ b/test/e2e/rushx.e2e.spec.js @@ -0,0 +1,100 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +describe("E2E: rushx coverage", () => { + let container; + + before(async () => { + DockerTestContainer.buildImage(); + }); + + beforeEach(async () => { + container = new DockerTestContainer(); + await container.start(); + + const installationShell = await container.openShell("zsh"); + await installationShell.runCommand("safe-chain setup"); + await setupRushWorkspace(installationShell); + }); + + afterEach(async () => { + if (container) { + await container.stop(); + container = null; + } + }); + + it("safe-chain successfully scans safe package downloads from rushx scripts", async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "cd /testapp/apps/test-app && rushx install-safe --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it("safe-chain blocks malicious package downloads from rushx scripts", async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "cd /testapp/apps/test-app && rushx install-malicious" + ); + + assert.ok( + result.output.includes("blocked 1 malicious package downloads"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("- safe-chain-test"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); +}); + +async function setupRushWorkspace(shell) { + await shell.runCommand("mkdir -p /testapp/common/config/rush /testapp/apps/test-app"); + await shell.runCommand(`cat > /testapp/common/config/rush/rush.json <<'EOF' +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush.schema.json", + "rushVersion": "5.175.1", + "pnpmVersion": "11.0.6", + "nodeSupportedVersionRange": ">=18.0.0", + "projectFolderMinDepth": 1, + "projectFolderMaxDepth": 2, + "gitPolicy": {}, + "repository": { + "url": "https://example.com/testapp.git", + "defaultBranch": "main" + }, + "eventHooks": { + "preRushInstall": [], + "postRushInstall": [], + "preRushBuild": [], + "postRushBuild": [] + }, + "projects": [ + { + "packageName": "test-app", + "projectFolder": "apps/test-app" + } + ] +} +EOF`); + await shell.runCommand(`cat > /testapp/apps/test-app/package.json <<'EOF' +{ + "name": "test-app", + "version": "1.0.0", + "scripts": { + "install-safe": "npm install axios@1.13.0", + "install-malicious": "npm install safe-chain-test@0.0.1-security" + } +} +EOF`); +} From 55f2123f5c2e3e4eb1cc19a16865ed7f747c8f52 Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Fri, 8 May 2026 11:25:07 +0100 Subject: [PATCH 06/13] Remove the normalisation bits added in error --- .../src/packagemanager/rush/runRushCommand.js | 43 +++---------------- .../rush/runRushCommand.spec.js | 7 --- 2 files changed, 6 insertions(+), 44 deletions(-) diff --git a/packages/safe-chain/src/packagemanager/rush/runRushCommand.js b/packages/safe-chain/src/packagemanager/rush/runRushCommand.js index ed43c237..f2b249f1 100644 --- a/packages/safe-chain/src/packagemanager/rush/runRushCommand.js +++ b/packages/safe-chain/src/packagemanager/rush/runRushCommand.js @@ -9,7 +9,7 @@ import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; */ export async function runRushCommand(executableName, args) { try { - const env = normalizeProxyEnvironmentVariables( + const env = prepareRushEnvironmentVariables( mergeSafeChainProxyEnvironmentVariables(process.env), ); @@ -25,48 +25,17 @@ export async function runRushCommand(executableName, args) { } /** - * Ensure proxy settings are visible to package manager variants that rely on - * lowercase or npm/yarn-specific environment variables. - * * @param {Record} env * @returns {Record} */ -function normalizeProxyEnvironmentVariables(env) { - const normalized = { +function prepareRushEnvironmentVariables(env) { + const prepared = { ...env, }; - if (normalized.HTTPS_PROXY && !normalized.HTTP_PROXY) { - normalized.HTTP_PROXY = normalized.HTTPS_PROXY; + if (prepared.HTTPS_PROXY && !prepared.HTTP_PROXY) { + prepared.HTTP_PROXY = prepared.HTTPS_PROXY; } - if (normalized.HTTP_PROXY && !normalized.http_proxy) { - normalized.http_proxy = normalized.HTTP_PROXY; - } - - if (normalized.HTTPS_PROXY && !normalized.https_proxy) { - normalized.https_proxy = normalized.HTTPS_PROXY; - } - - if (normalized.HTTP_PROXY && !normalized.npm_config_proxy) { - normalized.npm_config_proxy = normalized.HTTP_PROXY; - } - - if (normalized.HTTPS_PROXY && !normalized.npm_config_https_proxy) { - normalized.npm_config_https_proxy = normalized.HTTPS_PROXY; - } - - if (normalized.HTTP_PROXY && !normalized.NPM_CONFIG_PROXY) { - normalized.NPM_CONFIG_PROXY = normalized.HTTP_PROXY; - } - - if (normalized.HTTPS_PROXY && !normalized.NPM_CONFIG_HTTPS_PROXY) { - normalized.NPM_CONFIG_HTTPS_PROXY = normalized.HTTPS_PROXY; - } - - if (normalized.HTTPS_PROXY && !normalized.YARN_HTTPS_PROXY) { - normalized.YARN_HTTPS_PROXY = normalized.HTTPS_PROXY; - } - - return normalized; + return prepared; } diff --git a/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js b/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js index daabcabf..343fb1e8 100644 --- a/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js +++ b/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js @@ -75,13 +75,6 @@ describe("runRushCommand", () => { 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"); }); From 7ce44b4c628f28d43616e5193f96705093b04b33 Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Fri, 8 May 2026 13:12:40 +0100 Subject: [PATCH 07/13] Remove the unecessary proxy setting --- .../src/packagemanager/rush/runRushCommand.js | 22 +------------------ .../rush/runRushCommand.spec.js | 1 - 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/packages/safe-chain/src/packagemanager/rush/runRushCommand.js b/packages/safe-chain/src/packagemanager/rush/runRushCommand.js index f2b249f1..340e3f65 100644 --- a/packages/safe-chain/src/packagemanager/rush/runRushCommand.js +++ b/packages/safe-chain/src/packagemanager/rush/runRushCommand.js @@ -9,13 +9,9 @@ import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; */ export async function runRushCommand(executableName, args) { try { - const env = prepareRushEnvironmentVariables( - mergeSafeChainProxyEnvironmentVariables(process.env), - ); - const result = await safeSpawn(executableName, args, { stdio: "inherit", - env, + env: mergeSafeChainProxyEnvironmentVariables(process.env), }); return { status: result.status }; @@ -23,19 +19,3 @@ export async function runRushCommand(executableName, args) { return reportCommandExecutionFailure(error, executableName); } } - -/** - * @param {Record} env - * @returns {Record} - */ -function prepareRushEnvironmentVariables(env) { - const prepared = { - ...env, - }; - - if (prepared.HTTPS_PROXY && !prepared.HTTP_PROXY) { - prepared.HTTP_PROXY = prepared.HTTPS_PROXY; - } - - return prepared; -} diff --git a/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js b/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js index 343fb1e8..fa2c35a4 100644 --- a/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js +++ b/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js @@ -74,7 +74,6 @@ describe("runRushCommand", () => { 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.ok(mergeCalls.length >= 1, "proxy env merge should be called"); }); From 26f1dfb81aca770df73070a3a63771b9cbece60c Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Fri, 8 May 2026 13:12:57 +0100 Subject: [PATCH 08/13] Use the standard install command for rush --- test/e2e/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/Dockerfile b/test/e2e/Dockerfile index c448b094..0e381103 100644 --- a/test/e2e/Dockerfile +++ b/test/e2e/Dockerfile @@ -47,7 +47,7 @@ RUN volta install node@${NODE_VERSION} RUN volta install npm@${NPM_VERSION} RUN volta install yarn@${YARN_VERSION} RUN volta install pnpm@${PNPM_VERSION} -RUN npm install -g @microsoft/rush@${RUSH_VERSION} +RUN volta install @microsoft/rush@${RUSH_VERSION} # Install Bun RUN curl -fsSL https://bun.sh/install | bash From e891d1a992517f000a386dc9507dcd9cc96db6ad Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Fri, 8 May 2026 13:13:37 +0100 Subject: [PATCH 09/13] Update e2e suite to cover supported package managers --- test/e2e/rush.e2e.spec.js | 109 +++++++++++++++++++++++++++----------- 1 file changed, 77 insertions(+), 32 deletions(-) diff --git a/test/e2e/rush.e2e.spec.js b/test/e2e/rush.e2e.spec.js index efe7eadc..fb3cbdde 100644 --- a/test/e2e/rush.e2e.spec.js +++ b/test/e2e/rush.e2e.spec.js @@ -4,6 +4,11 @@ import assert from "node:assert"; describe("E2E: rush coverage", () => { let container; + const packageManagerConfigs = [ + { name: "pnpm", versionField: "pnpmVersion", version: "latest" }, + { name: "yarn", versionField: "yarnVersion", version: "latest" }, + { name: "npm", versionField: "npmVersion", version: "latest" }, + ]; before(async () => { DockerTestContainer.buildImage(); @@ -65,41 +70,81 @@ describe("E2E: rush coverage", () => { `Malicious package was added despite safe-chain protection. Output was:\n${packageJson.output}` ); }); + + for (const packageManagerConfig of packageManagerConfigs) { + it(`safe-chain proxy blocks malicious package downloads during rush update with ${packageManagerConfig.name}`, async () => { + const shell = await container.openShell("zsh"); + await setupRushWorkspace(shell, { + packageManagerConfig, + packageJson: `{ + "name": "test-app", + "version": "1.0.0", + "dependencies": { + "safe-chain-test": "0.0.1-security" + } +}`, + }); + + const result = await shell.runCommand("cd /testapp/apps/test-app && rush update"); + + assert.ok( + result.output.includes("blocked 1 malicious package downloads"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("- safe-chain-test"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + } }); -async function setupRushWorkspace(shell) { - await shell.runCommand("mkdir -p /testapp/common/config/rush /testapp/apps/test-app"); - await shell.runCommand(`cat > /testapp/common/config/rush/rush.json <<'EOF' -{ - "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush.schema.json", - "rushVersion": "5.175.1", - "pnpmVersion": "11.0.6", - "nodeSupportedVersionRange": ">=18.0.0", - "projectFolderMinDepth": 1, - "projectFolderMaxDepth": 2, - "gitPolicy": {}, - "repository": { - "url": "https://example.com/testapp.git", - "defaultBranch": "main" - }, - "eventHooks": { - "preRushInstall": [], - "postRushInstall": [], - "preRushBuild": [], - "postRushBuild": [] - }, - "projects": [ - { - "packageName": "test-app", - "projectFolder": "apps/test-app" - } - ] -} -EOF`); - await shell.runCommand(`cat > /testapp/apps/test-app/package.json <<'EOF' -{ +async function setupRushWorkspace(shell, options = {}) { + const packageManagerConfig = options.packageManagerConfig ?? { + versionField: "pnpmVersion", + version: "11.0.6", + }; + const packageJson = options.packageJson ?? `{ "name": "test-app", "version": "1.0.0" +}`; + const rushConfig = { + $schema: "https://developer.microsoft.com/json-schemas/rush/v5/rush.schema.json", + rushVersion: "5.175.1", + [packageManagerConfig.versionField]: packageManagerConfig.version, + nodeSupportedVersionRange: ">=18.0.0", + projectFolderMinDepth: 1, + projectFolderMaxDepth: 2, + gitPolicy: {}, + repository: { + url: "https://example.com/testapp.git", + defaultBranch: "main", + }, + eventHooks: { + preRushInstall: [], + postRushInstall: [], + preRushBuild: [], + postRushBuild: [], + }, + projects: [ + { + packageName: "test-app", + projectFolder: "apps/test-app", + }, + ], + }; + + await shell.runCommand("rm -rf /testapp/common /testapp/apps/test-app"); + await shell.runCommand("mkdir -p /testapp/apps/test-app"); + await writeTextFile(shell, "/testapp/rush.json", JSON.stringify(rushConfig, null, 2)); + await writeTextFile(shell, "/testapp/apps/test-app/package.json", packageJson); } -EOF`); + +async function writeTextFile(shell, filePath, content) { + const encoded = Buffer.from(content).toString("base64"); + await shell.runCommand(`printf '%s' '${encoded}' | base64 -d > ${filePath}`); } From 5f0ad7ecfdde2152aad12f826ccb20f92e94b46c Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Tue, 12 May 2026 10:33:26 +0100 Subject: [PATCH 10/13] Address e2e suite failures --- npm-shrinkwrap.json | 2 +- test/e2e/rush.e2e.spec.js | 131 ++++++++++++++----------------- test/e2e/rushx.e2e.spec.js | 67 ++++++++-------- test/e2e/utils/rushtestutils.mjs | 70 +++++++++++++++++ 4 files changed, 165 insertions(+), 105 deletions(-) create mode 100644 test/e2e/utils/rushtestutils.mjs diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 68aecf73..81483446 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -2417,7 +2417,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3139,6 +3138,7 @@ "aikido-python": "bin/aikido-python.js", "aikido-python3": "bin/aikido-python3.js", "aikido-rush": "bin/aikido-rush.js", + "aikido-rushx": "bin/aikido-rushx.js", "aikido-uv": "bin/aikido-uv.js", "aikido-uvx": "bin/aikido-uvx.js", "aikido-yarn": "bin/aikido-yarn.js", diff --git a/test/e2e/rush.e2e.spec.js b/test/e2e/rush.e2e.spec.js index fb3cbdde..f2ccc14e 100644 --- a/test/e2e/rush.e2e.spec.js +++ b/test/e2e/rush.e2e.spec.js @@ -1,14 +1,22 @@ import { describe, it, before, beforeEach, afterEach } from "node:test"; import { DockerTestContainer } from "./DockerTestContainer.js"; +import { + buildRushConfig, + resolveRushVersions, + writeTextFile, +} from "./utils/rushtestutils.mjs"; import assert from "node:assert"; +// These tests cover safe-chain's Rush wrapper: pre-scanning `rush add` and +// blocking malicious packages downloaded during `rush update` via the MITM +// proxy. They use a single Rush-internal package manager (pnpm) β€” see +// `utils/rushtestutils.mjs` for why this suite isn't parameterised over the +// CI matrix's NPM_VERSION/PNPM_VERSION/YARN_VERSION values. + describe("E2E: rush coverage", () => { let container; - const packageManagerConfigs = [ - { name: "pnpm", versionField: "pnpmVersion", version: "latest" }, - { name: "yarn", versionField: "yarnVersion", version: "latest" }, - { name: "npm", versionField: "npmVersion", version: "latest" }, - ]; + /** @type {{ rushVersion: string, pnpmVersion: string } | undefined} */ + let resolvedVersions; before(async () => { DockerTestContainer.buildImage(); @@ -20,7 +28,12 @@ describe("E2E: rush coverage", () => { const installationShell = await container.openShell("zsh"); await installationShell.runCommand("safe-chain setup"); - await setupRushWorkspace(installationShell); + + if (!resolvedVersions) { + resolvedVersions = await resolveRushVersions(installationShell); + } + + await setupRushWorkspace(installationShell, { resolvedVersions }); }); afterEach(async () => { @@ -71,80 +84,58 @@ describe("E2E: rush coverage", () => { ); }); - for (const packageManagerConfig of packageManagerConfigs) { - it(`safe-chain proxy blocks malicious package downloads during rush update with ${packageManagerConfig.name}`, async () => { - const shell = await container.openShell("zsh"); - await setupRushWorkspace(shell, { - packageManagerConfig, - packageJson: `{ + it("safe-chain proxy blocks malicious package downloads during rush update", async () => { + const shell = await container.openShell("zsh"); + await setupRushWorkspace(shell, { + resolvedVersions, + packageJson: `{ "name": "test-app", "version": "1.0.0", "dependencies": { "safe-chain-test": "0.0.1-security" } }`, - }); - - const result = await shell.runCommand("cd /testapp/apps/test-app && rush update"); - - assert.ok( - result.output.includes("blocked 1 malicious package downloads"), - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("- safe-chain-test"), - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Output did not include expected text. Output was:\n${result.output}` - ); }); - } + + const result = await shell.runCommand( + "cd /testapp/apps/test-app && rush update" + ); + + assert.ok( + result.output.includes("blocked 1 malicious package downloads"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("- safe-chain-test"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); }); -async function setupRushWorkspace(shell, options = {}) { - const packageManagerConfig = options.packageManagerConfig ?? { - versionField: "pnpmVersion", - version: "11.0.6", - }; - const packageJson = options.packageJson ?? `{ - "name": "test-app", - "version": "1.0.0" -}`; - const rushConfig = { - $schema: "https://developer.microsoft.com/json-schemas/rush/v5/rush.schema.json", - rushVersion: "5.175.1", - [packageManagerConfig.versionField]: packageManagerConfig.version, - nodeSupportedVersionRange: ">=18.0.0", - projectFolderMinDepth: 1, - projectFolderMaxDepth: 2, - gitPolicy: {}, - repository: { - url: "https://example.com/testapp.git", - defaultBranch: "main", - }, - eventHooks: { - preRushInstall: [], - postRushInstall: [], - preRushBuild: [], - postRushBuild: [], - }, - projects: [ - { - packageName: "test-app", - projectFolder: "apps/test-app", - }, - ], - }; +async function setupRushWorkspace(shell, { resolvedVersions, packageJson }) { + const rushConfig = buildRushConfig({ + rushVersion: resolvedVersions.rushVersion, + pnpmVersion: resolvedVersions.pnpmVersion, + }); await shell.runCommand("rm -rf /testapp/common /testapp/apps/test-app"); await shell.runCommand("mkdir -p /testapp/apps/test-app"); - await writeTextFile(shell, "/testapp/rush.json", JSON.stringify(rushConfig, null, 2)); - await writeTextFile(shell, "/testapp/apps/test-app/package.json", packageJson); -} - -async function writeTextFile(shell, filePath, content) { - const encoded = Buffer.from(content).toString("base64"); - await shell.runCommand(`printf '%s' '${encoded}' | base64 -d > ${filePath}`); + await writeTextFile( + shell, + "/testapp/rush.json", + JSON.stringify(rushConfig, null, 2) + ); + await writeTextFile( + shell, + "/testapp/apps/test-app/package.json", + packageJson ?? + `{ + "name": "test-app", + "version": "1.0.0" +}` + ); } diff --git a/test/e2e/rushx.e2e.spec.js b/test/e2e/rushx.e2e.spec.js index aaadf4e6..ab2c803e 100644 --- a/test/e2e/rushx.e2e.spec.js +++ b/test/e2e/rushx.e2e.spec.js @@ -1,9 +1,16 @@ import { describe, it, before, beforeEach, afterEach } from "node:test"; import { DockerTestContainer } from "./DockerTestContainer.js"; +import { + buildRushConfig, + resolveRushVersions, + writeTextFile, +} from "./utils/rushtestutils.mjs"; import assert from "node:assert"; describe("E2E: rushx coverage", () => { let container; + /** @type {{ rushVersion: string, pnpmVersion: string } | undefined} */ + let resolvedVersions; before(async () => { DockerTestContainer.buildImage(); @@ -15,7 +22,12 @@ describe("E2E: rushx coverage", () => { const installationShell = await container.openShell("zsh"); await installationShell.runCommand("safe-chain setup"); - await setupRushWorkspace(installationShell); + + if (!resolvedVersions) { + resolvedVersions = await resolveRushVersions(installationShell); + } + + await setupRushWorkspace(installationShell, { resolvedVersions }); }); afterEach(async () => { @@ -58,43 +70,30 @@ describe("E2E: rushx coverage", () => { }); }); -async function setupRushWorkspace(shell) { - await shell.runCommand("mkdir -p /testapp/common/config/rush /testapp/apps/test-app"); - await shell.runCommand(`cat > /testapp/common/config/rush/rush.json <<'EOF' -{ - "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush.schema.json", - "rushVersion": "5.175.1", - "pnpmVersion": "11.0.6", - "nodeSupportedVersionRange": ">=18.0.0", - "projectFolderMinDepth": 1, - "projectFolderMaxDepth": 2, - "gitPolicy": {}, - "repository": { - "url": "https://example.com/testapp.git", - "defaultBranch": "main" - }, - "eventHooks": { - "preRushInstall": [], - "postRushInstall": [], - "preRushBuild": [], - "postRushBuild": [] - }, - "projects": [ - { - "packageName": "test-app", - "projectFolder": "apps/test-app" - } - ] -} -EOF`); - await shell.runCommand(`cat > /testapp/apps/test-app/package.json <<'EOF' -{ +async function setupRushWorkspace(shell, { resolvedVersions }) { + const rushConfig = buildRushConfig({ + rushVersion: resolvedVersions.rushVersion, + pnpmVersion: resolvedVersions.pnpmVersion, + }); + + await shell.runCommand( + "mkdir -p /testapp/common/config/rush /testapp/apps/test-app" + ); + await writeTextFile( + shell, + "/testapp/rush.json", + JSON.stringify(rushConfig, null, 2) + ); + await writeTextFile( + shell, + "/testapp/apps/test-app/package.json", + `{ "name": "test-app", "version": "1.0.0", "scripts": { "install-safe": "npm install axios@1.13.0", "install-malicious": "npm install safe-chain-test@0.0.1-security" } -} -EOF`); +}` + ); } diff --git a/test/e2e/utils/rushtestutils.mjs b/test/e2e/utils/rushtestutils.mjs new file mode 100644 index 00000000..624cc61b --- /dev/null +++ b/test/e2e/utils/rushtestutils.mjs @@ -0,0 +1,70 @@ +// Helpers for the Rush E2E suites. +// +// What these suites actually test: that safe-chain's shim intercepts `rush` +// and `rushx` invocations correctly. The contents of `rush.json` are just +// fixture noise needed to make Rush run at all β€” Rush's schema requires +// exact semver for `rushVersion`/`pnpmVersion` and refuses dist-tags like +// "latest", so we resolve those once per suite. +// +// * `rushVersion` is read from the `rush` binary baked into the image +// (Dockerfile installs `@microsoft/rush@${RUSH_VERSION:-latest}`). +// * `pnpmVersion` is pinned to a known-good pnpm 9 release. Rush downloads +// this internally into `~/.rush/...`; it's unrelated to the system +// pnpm exercised by the pnpm e2e suite. + +const PINNED_PNPM_VERSION = "9.15.9"; + +/** Resolves the versions to put into `rush.json`. */ +export async function resolveRushVersions(shell) { + return { + rushVersion: await getInstalledRushVersion(shell), + pnpmVersion: PINNED_PNPM_VERSION, + }; +} + +/** Builds the standard `rush.json` body for the e2e fixtures. */ +export function buildRushConfig({ rushVersion, pnpmVersion, projects }) { + return { + $schema: + "https://developer.microsoft.com/json-schemas/rush/v5/rush.schema.json", + rushVersion, + pnpmVersion, + nodeSupportedVersionRange: ">=18.0.0", + projectFolderMinDepth: 1, + projectFolderMaxDepth: 2, + gitPolicy: {}, + repository: { + url: "https://example.com/testapp.git", + defaultBranch: "main", + }, + eventHooks: { + preRushInstall: [], + postRushInstall: [], + preRushBuild: [], + postRushBuild: [], + }, + projects: projects ?? [ + { packageName: "test-app", projectFolder: "apps/test-app" }, + ], + }; +} + +/** + * Writes a UTF-8 text file inside the container, base64-encoding the payload + * to avoid shell escaping issues for arbitrary content. + */ +export async function writeTextFile(shell, filePath, content) { + const encoded = Buffer.from(content).toString("base64"); + await shell.runCommand(`printf '%s' '${encoded}' | base64 -d > ${filePath}`); +} + +async function getInstalledRushVersion(shell) { + const { output } = await shell.runCommand("rush --version"); + const match = output.match(/\b(\d+\.\d+\.\d+)\b/); + if (!match) { + throw new Error( + `Could not determine installed Rush version. Output was:\n${output}` + ); + } + return match[1]; +} From 25d966bfa939887702c4071c8d2add3fe3d2e6d3 Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Tue, 12 May 2026 10:51:55 +0100 Subject: [PATCH 11/13] Switch to using the versions from the CI matrix Incorporates the actual Rush and PNPM versions instead of pinning an old known-good version of PNPM --- test/e2e/utils/rushtestutils.mjs | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/test/e2e/utils/rushtestutils.mjs b/test/e2e/utils/rushtestutils.mjs index 624cc61b..285c50ed 100644 --- a/test/e2e/utils/rushtestutils.mjs +++ b/test/e2e/utils/rushtestutils.mjs @@ -4,22 +4,21 @@ // and `rushx` invocations correctly. The contents of `rush.json` are just // fixture noise needed to make Rush run at all β€” Rush's schema requires // exact semver for `rushVersion`/`pnpmVersion` and refuses dist-tags like -// "latest", so we resolve those once per suite. +// "latest", so we read both back from the binaries baked into the image. // -// * `rushVersion` is read from the `rush` binary baked into the image -// (Dockerfile installs `@microsoft/rush@${RUSH_VERSION:-latest}`). -// * `pnpmVersion` is pinned to a known-good pnpm 9 release. Rush downloads -// this internally into `~/.rush/...`; it's unrelated to the system -// pnpm exercised by the pnpm e2e suite. - -const PINNED_PNPM_VERSION = "9.15.9"; +// * `rushVersion` ← `rush --version` (image installs +// `@microsoft/rush@${RUSH_VERSION:-latest}`). +// * `pnpmVersion` ← `pnpm --version` (image installs +// `pnpm@${PNPM_VERSION:-latest}`). Rush downloads its own copy of this +// into `~/.rush/...`; using the same exact version as the system pnpm +// just keeps the fixture in lockstep with whatever the CI matrix picks. /** Resolves the versions to put into `rush.json`. */ export async function resolveRushVersions(shell) { - return { - rushVersion: await getInstalledRushVersion(shell), - pnpmVersion: PINNED_PNPM_VERSION, - }; + // Sequential: the helper drives a single PTY shell. + const rushVersion = await getInstalledVersion(shell, "rush"); + const pnpmVersion = await getInstalledVersion(shell, "pnpm"); + return { rushVersion, pnpmVersion }; } /** Builds the standard `rush.json` body for the e2e fixtures. */ @@ -58,12 +57,12 @@ export async function writeTextFile(shell, filePath, content) { await shell.runCommand(`printf '%s' '${encoded}' | base64 -d > ${filePath}`); } -async function getInstalledRushVersion(shell) { - const { output } = await shell.runCommand("rush --version"); +async function getInstalledVersion(shell, command) { + const { output } = await shell.runCommand(`${command} --version`); const match = output.match(/\b(\d+\.\d+\.\d+)\b/); if (!match) { throw new Error( - `Could not determine installed Rush version. Output was:\n${output}` + `Could not determine installed ${command} version. Output was:\n${output}` ); } return match[1]; From c93f1920fb6ab8345e1b4d3bfeaf9254073deb19 Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Tue, 12 May 2026 16:53:51 +0100 Subject: [PATCH 12/13] Skip min safe age to allow brand new PNPM boostrap --- test/e2e/rush.e2e.spec.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/e2e/rush.e2e.spec.js b/test/e2e/rush.e2e.spec.js index f2ccc14e..70de4b89 100644 --- a/test/e2e/rush.e2e.spec.js +++ b/test/e2e/rush.e2e.spec.js @@ -97,8 +97,14 @@ describe("E2E: rush coverage", () => { }`, }); + // `--safe-chain-skip-minimum-package-age` is needed because Rush's + // internal pnpm bootstrap (`npm install pnpm@`) goes + // through the safe-chain proxy. When the CI matrix selects pnpm + // `latest`, the just-released version can be below the minimum age + // threshold and Rush's install would otherwise be blocked before our + // malicious-download assertion is reached. const result = await shell.runCommand( - "cd /testapp/apps/test-app && rush update" + "cd /testapp/apps/test-app && rush update --safe-chain-skip-minimum-package-age" ); assert.ok( From fde0003a0af234085d821853b7ef4416821189ce Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Tue, 12 May 2026 17:33:31 +0100 Subject: [PATCH 13/13] Fix expected format to account for retries Count is apparently not deterministic --- test/e2e/rush.e2e.spec.js | 5 +++-- test/e2e/rushx.e2e.spec.js | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/test/e2e/rush.e2e.spec.js b/test/e2e/rush.e2e.spec.js index 70de4b89..a5471a0a 100644 --- a/test/e2e/rush.e2e.spec.js +++ b/test/e2e/rush.e2e.spec.js @@ -107,8 +107,9 @@ describe("E2E: rush coverage", () => { "cd /testapp/apps/test-app && rush update --safe-chain-skip-minimum-package-age" ); - assert.ok( - result.output.includes("blocked 1 malicious package downloads"), + assert.match( + result.output, + /blocked \d+ malicious package downloads/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( diff --git a/test/e2e/rushx.e2e.spec.js b/test/e2e/rushx.e2e.spec.js index ab2c803e..b7d50782 100644 --- a/test/e2e/rushx.e2e.spec.js +++ b/test/e2e/rushx.e2e.spec.js @@ -55,8 +55,9 @@ describe("E2E: rushx coverage", () => { "cd /testapp/apps/test-app && rushx install-malicious" ); - assert.ok( - result.output.includes("blocked 1 malicious package downloads"), + assert.match( + result.output, + /blocked \d+ malicious package downloads/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok(