From 5690e55d99be78d683196c55bb679c74eb2c699c Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Thu, 2 Apr 2026 12:31:02 +0100 Subject: [PATCH 01/34] 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 e173b66..956526b 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 ea8c410..75d73b8 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 0000000..b5d8094 --- /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 8d942e4..a3f80b1 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 d4f3501..dae27c3 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 af297dc..45d897e 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 0000000..1a4aebb --- /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 0000000..5c02f52 --- /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 0000000..ebc3bf1 --- /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 0000000..97676e4 --- /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 18ba52e..5791aba 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 b437157..bbd05dc 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/34] 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 1a4aebb..16c5815 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 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, + }; + }), + ); + const changes = []; - - for (const spec of packageSpecs) { - const parsed = parsePackageSpec(spec); - if (!parsed) { - continue; - } - - const exactVersion = await resolvePackageVersion(parsed.name, parsed.version); - if (!exactVersion) { + 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 ebc3bf1..f6ba3cc 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 97676e4..b21087e 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 293089462430fe807f9cacfd207d4f85a51e1fc2 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 21 Apr 2026 09:26:07 +0200 Subject: [PATCH 03/34] Fix concurrency bug leading to multiple fetches of the malware database --- .../src/scanning/malwareDatabase.js | 72 +++++++++---------- .../src/scanning/newPackagesListCache.js | 34 ++++----- 2 files changed, 51 insertions(+), 55 deletions(-) diff --git a/packages/safe-chain/src/scanning/malwareDatabase.js b/packages/safe-chain/src/scanning/malwareDatabase.js index 4aba43c..afc8b98 100644 --- a/packages/safe-chain/src/scanning/malwareDatabase.js +++ b/packages/safe-chain/src/scanning/malwareDatabase.js @@ -15,8 +15,12 @@ import { getEcoSystem, ECOSYSTEM_PY } from "../config/settings.js"; * @property {function(string, string): boolean} isMalware */ -/** @type {MalwareDatabase | null} */ -let cachedMalwareDatabase = null; +// Caching the Promise (rather than the resolved database) prevents duplicate fetches. If we cached the resolved +// value, multiple callers could pass the null-check before the first fetch completes (because each `await` yields +// control back to the event loop, allowing other callers to run). Since the Promise assignment is synchronous, all +// concurrent callers see it immediately and share a single fetch. +/** @type {Promise | null} */ +let cachedMalwareDatabasePromise = null; /** * Normalize package name for comparison. @@ -34,45 +38,41 @@ function normalizePackageName(name) { return name; } -export async function openMalwareDatabase() { - if (cachedMalwareDatabase) { - return cachedMalwareDatabase; - } +export function openMalwareDatabase() { + if (!cachedMalwareDatabasePromise) { + cachedMalwareDatabasePromise = getMalwareDatabase().then((malwareDatabase) => { + /** + * @param {string} name + * @param {string} version + * @returns {string} + */ + function getPackageStatus(name, version) { + const normalizedName = normalizePackageName(name); + const packageData = malwareDatabase.find( + (pkg) => { + const normalizedPkgName = normalizePackageName(pkg.package_name); + return normalizedPkgName === normalizedName && + (pkg.version === version || pkg.version === "*"); + } + ); - const malwareDatabase = await getMalwareDatabase(); + if (!packageData) { + return MALWARE_STATUS_OK; + } - /** - * @param {string} name - * @param {string} version - * @returns {string} - */ - function getPackageStatus(name, version) { - const normalizedName = normalizePackageName(name); - const packageData = malwareDatabase.find( - (pkg) => { - const normalizedPkgName = normalizePackageName(pkg.package_name); - return normalizedPkgName === normalizedName && - (pkg.version === version || pkg.version === "*"); + return packageData.reason; } - ); - if (!packageData) { - return MALWARE_STATUS_OK; - } - - return packageData.reason; + return { + getPackageStatus, + isMalware: (name, version) => { + const status = getPackageStatus(name, version); + return isMalwareStatus(status); + }, + }; + }); } - - // This implicitly caches the malware database - // that's closed over by the getPackageStatus function - cachedMalwareDatabase = { - getPackageStatus, - isMalware: (name, version) => { - const status = getPackageStatus(name, version); - return isMalwareStatus(status); - }, - }; - return cachedMalwareDatabase; + return cachedMalwareDatabasePromise; } /** diff --git a/packages/safe-chain/src/scanning/newPackagesListCache.js b/packages/safe-chain/src/scanning/newPackagesListCache.js index dfac247..b6c990e 100644 --- a/packages/safe-chain/src/scanning/newPackagesListCache.js +++ b/packages/safe-chain/src/scanning/newPackagesListCache.js @@ -16,30 +16,26 @@ import { warnOnceAboutUnavailableDatabase } from "./newPackagesDatabaseWarnings. */ // Shared per-process cache to avoid rebuilding the same feed-backed database on each request. -/** @type {NewPackagesDatabase | null} */ -let cachedNewPackagesDatabase = null; +// Caching the Promise (rather than the resolved database) prevents duplicate fetches. If we cached the resolved +// value, multiple callers could pass the null-check before the first fetch completes (because each `await` yields +// control back to the event loop, allowing other callers to run). Since the Promise assignment is synchronous, all +// concurrent callers see it immediately and share a single fetch. +/** @type {Promise | null} */ +let cachedNewPackagesDatabasePromise = null; /** * @returns {Promise} */ -export async function openNewPackagesDatabase() { - if (cachedNewPackagesDatabase) { - return cachedNewPackagesDatabase; +export function openNewPackagesDatabase() { + if (!cachedNewPackagesDatabasePromise) { + cachedNewPackagesDatabasePromise = getNewPackagesList() + .then((newPackagesList) => buildNewPackagesDatabase(newPackagesList)) + .catch((/** @type {any} */ error) => { + warnOnceAboutUnavailableDatabase(error); + return { isNewlyReleasedPackage: () => false }; + }); } - - /** @type {import("../api/aikido.js").NewPackageEntry[]} */ - let newPackagesList; - - try { - newPackagesList = await getNewPackagesList(); - } catch (/** @type {any} */ error) { - warnOnceAboutUnavailableDatabase(error); - cachedNewPackagesDatabase = { isNewlyReleasedPackage: () => false }; - return cachedNewPackagesDatabase; - } - - cachedNewPackagesDatabase = buildNewPackagesDatabase(newPackagesList); - return cachedNewPackagesDatabase; + return cachedNewPackagesDatabasePromise; } /** From 9fae225277b769824d74125f6973c4b871b894fa Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 21 Apr 2026 09:31:26 +0200 Subject: [PATCH 04/34] Make sure rejected promise is not cached in malware list / new packages cache --- packages/safe-chain/src/scanning/malwareDatabase.js | 5 ++++- packages/safe-chain/src/scanning/newPackagesListCache.js | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/safe-chain/src/scanning/malwareDatabase.js b/packages/safe-chain/src/scanning/malwareDatabase.js index afc8b98..0eccc88 100644 --- a/packages/safe-chain/src/scanning/malwareDatabase.js +++ b/packages/safe-chain/src/scanning/malwareDatabase.js @@ -65,11 +65,14 @@ export function openMalwareDatabase() { return { getPackageStatus, - isMalware: (name, version) => { + isMalware: (/** @type {string} */ name, /** @type {string} */ version) => { const status = getPackageStatus(name, version); return isMalwareStatus(status); }, }; + }).catch((error) => { + cachedMalwareDatabasePromise = null; + throw error; }); } return cachedMalwareDatabasePromise; diff --git a/packages/safe-chain/src/scanning/newPackagesListCache.js b/packages/safe-chain/src/scanning/newPackagesListCache.js index b6c990e..418dbdd 100644 --- a/packages/safe-chain/src/scanning/newPackagesListCache.js +++ b/packages/safe-chain/src/scanning/newPackagesListCache.js @@ -32,6 +32,7 @@ export function openNewPackagesDatabase() { .then((newPackagesList) => buildNewPackagesDatabase(newPackagesList)) .catch((/** @type {any} */ error) => { warnOnceAboutUnavailableDatabase(error); + cachedNewPackagesDatabasePromise = null; return { isNewlyReleasedPackage: () => false }; }); } From c22f36113c3daf29cd50aee68039eafe9e412942 Mon Sep 17 00:00:00 2001 From: Samuel Vandamme Date: Wed, 22 Apr 2026 17:42:22 +0200 Subject: [PATCH 05/34] moved endpoint up --- README.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 81dda88..f041983 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,14 @@ - βœ… **Blocks packages newer than 48 hours** without breaking your build - βœ… **Tokenless, free, no build data shared** +## Need protection beyond npm & PyPI? + +[Aikido Endpoint](https://www.aikido.dev/protect/endpoint-protection) builds on Safe Chain, extending package and extension security across more ecosystems: **npm**, **PyPI**, **Maven**, **NuGet**, **VS Code**, **Open VSX** - (Cursor, Windsurf, Kiro, Vs Codium, ...), **Chrome extensions**, **Skills.sh AI skills** and more. + +Get centralized policy management, request-and-approval workflows, and visibility across every developer workstation in your org. Powered by the same Aikido Intel feed. Deploy it manually or manage it through your MDM tool (Jamf, Fleet, or Iru). + +--- + Aikido Safe Chain supports the following package managers: - πŸ“¦ **npm** @@ -30,12 +38,6 @@ Aikido Safe Chain supports the following package managers: ![Aikido Safe Chain demo](https://raw.githubusercontent.com/AikidoSec/safe-chain/main/docs/safe-package-manager-demo.gif) -# Using Safe Chain across a team? - -[Aikido Endpoint](https://www.aikido.dev/protect/endpoint-protection) builds on Safe Chain, extending package and extension security across more ecosystems: **npm**, **PyPI**, **Maven**, **NuGet**, **VS Code**, **Open VSX** - (Cursor, Windsurf, Kiro, Vs Codium, ...), **Chrome extensions**, **Skills.sh AI skills** and more. - -Get centralized policy management, request-and-approval workflows, and visibility across every developer workstation in your org. Powered by the same Aikido Intel feed. Deploy it manually or manage it through your MDM tool (Jamf, Fleet, or Iru). - ## Installation Installing the Aikido Safe Chain is easy with our one-line installer. From d81b0f521497c865503c03dd0fee4c338b797f58 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 23 Apr 2026 10:32:04 -0700 Subject: [PATCH 06/34] Bump endpoint --- install-scripts/install-endpoint-mac.sh | 4 ++-- install-scripts/install-endpoint-windows.ps1 | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/install-scripts/install-endpoint-mac.sh b/install-scripts/install-endpoint-mac.sh index 51b5cac..2c83a17 100755 --- a/install-scripts/install-endpoint-mac.sh +++ b/install-scripts/install-endpoint-mac.sh @@ -7,8 +7,8 @@ set -e # Exit on error # Configuration -INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.20/EndpointProtection.pkg" -DOWNLOAD_SHA256="def6c01caac6a4ce93eb68157a5a6b81028c9203fa13a0f5c539cceb92cc7e7b" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.21/EndpointProtection.pkg" +DOWNLOAD_SHA256="2a6abef9a6c16b28f792226c5c4facfaca806920ec6d4d1edf43b40d083b18ad" TOKEN_FILE="/tmp/aikido_endpoint_token.txt" # Colors for output diff --git a/install-scripts/install-endpoint-windows.ps1 b/install-scripts/install-endpoint-windows.ps1 index f85d8ce..bea7722 100644 --- a/install-scripts/install-endpoint-windows.ps1 +++ b/install-scripts/install-endpoint-windows.ps1 @@ -7,8 +7,8 @@ param( ) # Configuration -$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.20/EndpointProtection.msi" -$DownloadSha256 = "46fe377a4ce6204e1cc4a031e80f92f85cb8e1ef6b9690b542438c0870937be3" +$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.21/EndpointProtection.msi" +$DownloadSha256 = "a59005b5f9e14694e27fd92396d5e438525b396acdd6e931aeccec44d1e3b44b" # Ensure TLS 1.2 is enabled for downloads [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 From 0a230eb64c033a7a62b7be181476d4c06adbcc34 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 24 Apr 2026 12:04:31 +0200 Subject: [PATCH 07/34] Update endpoint uninstall script location --- install-scripts/uninstall-endpoint-mac.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install-scripts/uninstall-endpoint-mac.sh b/install-scripts/uninstall-endpoint-mac.sh index 6da0f17..bd3b0e7 100755 --- a/install-scripts/uninstall-endpoint-mac.sh +++ b/install-scripts/uninstall-endpoint-mac.sh @@ -7,7 +7,7 @@ set -e # Exit on error # Configuration -UNINSTALL_SCRIPT="/Library/Application Support/AikidoSecurity/EndpointProtection/scripts/uninstall" +UNINSTALL_SCRIPT="/Applications/Aikido Endpoint Protection.app/Contents/Resources/scripts/uninstall" # Colors for output RED='\033[0;31m' From e8fb134136bc55d7d7fb3df4ac9414974ac08403 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 24 Apr 2026 17:12:48 +0200 Subject: [PATCH 08/34] Endpoint 1.2.22 --- install-scripts/install-endpoint-mac.sh | 4 ++-- install-scripts/install-endpoint-windows.ps1 | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/install-scripts/install-endpoint-mac.sh b/install-scripts/install-endpoint-mac.sh index 2c83a17..427b39a 100755 --- a/install-scripts/install-endpoint-mac.sh +++ b/install-scripts/install-endpoint-mac.sh @@ -7,8 +7,8 @@ set -e # Exit on error # Configuration -INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.21/EndpointProtection.pkg" -DOWNLOAD_SHA256="2a6abef9a6c16b28f792226c5c4facfaca806920ec6d4d1edf43b40d083b18ad" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.22/EndpointProtection.pkg" +DOWNLOAD_SHA256="64dfb91230918bf0714c3e7230422c0460f0e7ec64b6d8d0f616987eb2df5805" TOKEN_FILE="/tmp/aikido_endpoint_token.txt" # Colors for output diff --git a/install-scripts/install-endpoint-windows.ps1 b/install-scripts/install-endpoint-windows.ps1 index bea7722..7f69f39 100644 --- a/install-scripts/install-endpoint-windows.ps1 +++ b/install-scripts/install-endpoint-windows.ps1 @@ -7,8 +7,8 @@ param( ) # Configuration -$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.21/EndpointProtection.msi" -$DownloadSha256 = "a59005b5f9e14694e27fd92396d5e438525b396acdd6e931aeccec44d1e3b44b" +$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.22/EndpointProtection.msi" +$DownloadSha256 = "a4d3bf839484b4d6ab87f9d47bfd303d5442aa5e213c9061daf305717a1e8dd1" # Ensure TLS 1.2 is enabled for downloads [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 From d04db58a5ee591ca07e2714971919e432352a184 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Sun, 26 Apr 2026 17:19:34 -0700 Subject: [PATCH 09/34] Bump Endpoint Version to 1.2.23 --- install-scripts/install-endpoint-mac.sh | 4 ++-- install-scripts/install-endpoint-windows.ps1 | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/install-scripts/install-endpoint-mac.sh b/install-scripts/install-endpoint-mac.sh index 427b39a..02df48b 100755 --- a/install-scripts/install-endpoint-mac.sh +++ b/install-scripts/install-endpoint-mac.sh @@ -7,8 +7,8 @@ set -e # Exit on error # Configuration -INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.22/EndpointProtection.pkg" -DOWNLOAD_SHA256="64dfb91230918bf0714c3e7230422c0460f0e7ec64b6d8d0f616987eb2df5805" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.23/EndpointProtection.pkg" +DOWNLOAD_SHA256="9af1e0f72e53516c888ade1753ed03f087c1def89244eb0afb60e1f11e8e87e2" TOKEN_FILE="/tmp/aikido_endpoint_token.txt" # Colors for output diff --git a/install-scripts/install-endpoint-windows.ps1 b/install-scripts/install-endpoint-windows.ps1 index 7f69f39..437264e 100644 --- a/install-scripts/install-endpoint-windows.ps1 +++ b/install-scripts/install-endpoint-windows.ps1 @@ -7,8 +7,8 @@ param( ) # Configuration -$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.22/EndpointProtection.msi" -$DownloadSha256 = "a4d3bf839484b4d6ab87f9d47bfd303d5442aa5e213c9061daf305717a1e8dd1" +$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.23/EndpointProtection.msi" +$DownloadSha256 = "3327d35db6654d12dbd7c5ccec0645edb0277f71dcd993ba9733e266bbd235f8" # Ensure TLS 1.2 is enabled for downloads [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 From ae40140199321567b6ad572a59ba32d2fe8a40c6 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 27 Apr 2026 12:51:31 -0700 Subject: [PATCH 10/34] Fix Bitbucket Pipelines Example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f041983..394b835 100644 --- a/README.md +++ b/README.md @@ -471,7 +471,7 @@ steps: name: Install script: - curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci - - export PATH=~/.safe-chain/shims:$PATH + - export PATH=~/.safe-chain/shims:~/.safe-chain/bin:$PATH - npm ci ``` From 6abad2d37f815103793a9503f33f72247c4cc4f1 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Tue, 28 Apr 2026 08:50:54 +0200 Subject: [PATCH 11/34] Enhance Aikido Endpoint link with UTM parameters Updated the Aikido Endpoint link to include UTM parameters for tracking. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f041983..c5e1d5e 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ ## Need protection beyond npm & PyPI? -[Aikido Endpoint](https://www.aikido.dev/protect/endpoint-protection) builds on Safe Chain, extending package and extension security across more ecosystems: **npm**, **PyPI**, **Maven**, **NuGet**, **VS Code**, **Open VSX** - (Cursor, Windsurf, Kiro, Vs Codium, ...), **Chrome extensions**, **Skills.sh AI skills** and more. +[Aikido Endpoint](https://www.aikido.dev/protect/endpoint-protection?utm_source=github.com&utm_medium=referral&utm_campaign=safechain) builds on Safe Chain, extending package and extension security across more ecosystems: **npm**, **PyPI**, **Maven**, **NuGet**, **VS Code**, **Open VSX** - (Cursor, Windsurf, Kiro, Vs Codium, ...), **Chrome extensions**, **Skills.sh AI skills** and more. Get centralized policy management, request-and-approval workflows, and visibility across every developer workstation in your org. Powered by the same Aikido Intel feed. Deploy it manually or manage it through your MDM tool (Jamf, Fleet, or Iru). From ebebe6d6c1e51f6e4552d7f448655d1568982b98 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 28 Apr 2026 14:47:49 +0200 Subject: [PATCH 12/34] Mirror malware list in e2e tests to mock malware in a harmless way --- test/e2e/DockerTestContainer.js | 11 ++- test/e2e/Dockerfile | 2 + test/e2e/pip.e2e.spec.js | 6 +- test/e2e/pipx.e2e.spec.js | 8 +-- test/e2e/poetry.e2e.spec.js | 8 +-- test/e2e/safe-chain-cli-python.e2e.spec.js | 2 +- test/e2e/utils/malwarelistmirror.mjs | 79 ++++++++++++++++++++++ test/e2e/uv.e2e.spec.js | 16 ++--- test/e2e/uvx.e2e.spec.js | 6 +- 9 files changed, 114 insertions(+), 24 deletions(-) create mode 100644 test/e2e/utils/malwarelistmirror.mjs diff --git a/test/e2e/DockerTestContainer.js b/test/e2e/DockerTestContainer.js index cd48c4e..543b1a3 100644 --- a/test/e2e/DockerTestContainer.js +++ b/test/e2e/DockerTestContainer.js @@ -58,12 +58,21 @@ export class DockerTestContainer { `docker run -d --name ${this.containerName} ${imageName} sleep infinity`, { stdio: "ignore" } ); + + await this.startMalwareMirror(); + this.isRunning = true; } catch (error) { throw new Error(`Failed to start container: ${error.message}`); } } + async startMalwareMirror() { + const shell = await this.openShell("zsh"); + await shell.runCommand("node /utils/malwarelistmirror.mjs &"); + await shell.runCommand("until curl -sf http://127.0.0.1:5555/ready; do sleep 0.2; done"); + } + dockerExec(command, daemon = false) { if (!this.isRunning) { throw new Error("Container is not running"); @@ -125,7 +134,7 @@ export class DockerTestContainer { const timeout = setTimeout(() => { // Fallback in case the command doesn't finish in a reasonable time // oxlint-disable-next-line no-console - having this log in CI helps diagnose issues - console.log("Command timeout reached"); + console.log(`Command timeout reached for "${command}"`); resolve({ allData, output: parseShellOutput(allData), command }); ptyProcess.removeListener("data", handleInput); }, 15000); diff --git a/test/e2e/Dockerfile b/test/e2e/Dockerfile index bc7ffc2..3de600c 100644 --- a/test/e2e/Dockerfile +++ b/test/e2e/Dockerfile @@ -84,3 +84,5 @@ RUN npm install -g /pkgs/*.tgz WORKDIR /testapp RUN npm init -y +COPY test/e2e/utils/malwarelistmirror.mjs /utils/malwarelistmirror.mjs +ENV SAFE_CHAIN_MALWARE_LIST_BASE_URL=http://127.0.0.1:5555 diff --git a/test/e2e/pip.e2e.spec.js b/test/e2e/pip.e2e.spec.js index b06978f..af979dc 100644 --- a/test/e2e/pip.e2e.spec.js +++ b/test/e2e/pip.e2e.spec.js @@ -128,7 +128,7 @@ describe("E2E: pip coverage", () => { it(`safe-chain blocks installation of malicious Python packages`, async () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand( - "pip3 install --break-system-packages safe-chain-pi-test" + "pip3 install --break-system-packages numpy==2.4.4 --safe-chain-logging=verbose" ); assert.ok( @@ -136,7 +136,7 @@ describe("E2E: pip coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( - result.output.includes("safe_chain_pi_test@0.0.1"), + result.output.includes("numpy@2.4.4"), `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( @@ -146,7 +146,7 @@ describe("E2E: pip coverage", () => { const listResult = await shell.runCommand("pip3 list"); assert.ok( - !listResult.output.includes("safe-chain-pi-test"), + !listResult.output.includes("numpy"), `Malicious package was installed despite safe-chain protection. Output of 'pip3 list' was:\n${listResult.output}` ); }); diff --git a/test/e2e/pipx.e2e.spec.js b/test/e2e/pipx.e2e.spec.js index a554aa6..332709d 100644 --- a/test/e2e/pipx.e2e.spec.js +++ b/test/e2e/pipx.e2e.spec.js @@ -41,7 +41,7 @@ describe("E2E: pipx coverage", () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand( - "pipx install safe-chain-pi-test" + "pipx install numpy==2.4.4" ); assert.ok( @@ -86,7 +86,7 @@ describe("E2E: pipx coverage", () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand( - "pipx run safe-chain-pi-test --version" + "pipx run numpy==2.4.4 --version" ); assert.ok( @@ -122,7 +122,7 @@ describe("E2E: pipx coverage", () => { await shell.runCommand("pipx install ruff"); const result = await shell.runCommand( - "pipx runpip ruff install safe-chain-pi-test" + "pipx runpip ruff install numpy==2.4.4" ); assert.ok( @@ -185,7 +185,7 @@ describe("E2E: pipx coverage", () => { await shell.runCommand("pipx install ruff --safe-chain-logging=verbose"); const result = await shell.runCommand( - "pipx inject ruff safe-chain-pi-test --safe-chain-logging=verbose" + "pipx inject ruff numpy==2.4.4 --safe-chain-logging=verbose" ); assert.ok( diff --git a/test/e2e/poetry.e2e.spec.js b/test/e2e/poetry.e2e.spec.js index 58b74fd..7d77d9c 100644 --- a/test/e2e/poetry.e2e.spec.js +++ b/test/e2e/poetry.e2e.spec.js @@ -70,7 +70,7 @@ describe("E2E: poetry coverage", () => { await shell.runCommand("cd /tmp/test-poetry-malware && poetry init --no-interaction"); const result = await shell.runCommand( - "cd /tmp/test-poetry-malware && poetry add safe-chain-pi-test" + "cd /tmp/test-poetry-malware && poetry add numpy==2.4.4" ); assert.ok( @@ -300,7 +300,7 @@ describe("E2E: poetry coverage", () => { // Add malware package - this will create lock file and attempt download const result = await shell.runCommand( - "cd /tmp/test-poetry-install-malware && poetry add safe-chain-pi-test 2>&1" + "cd /tmp/test-poetry-install-malware && poetry add numpy==2.4.4 2>&1" ); assert.ok( @@ -324,7 +324,7 @@ describe("E2E: poetry coverage", () => { // Now try to add malware via add command const result = await shell.runCommand( - "cd /tmp/test-poetry-update-add && poetry add safe-chain-pi-test 2>&1" + "cd /tmp/test-poetry-update-add && poetry add numpy==2.4.4 2>&1" ); assert.ok( @@ -345,7 +345,7 @@ describe("E2E: poetry coverage", () => { // Try to add malware directly - this is the primary vector const result = await shell.runCommand( - "cd /tmp/test-poetry-req-malware && poetry add safe-chain-pi-test requests 2>&1" + "cd /tmp/test-poetry-req-malware && poetry add numpy==2.4.4 requests 2>&1" ); assert.ok( diff --git a/test/e2e/safe-chain-cli-python.e2e.spec.js b/test/e2e/safe-chain-cli-python.e2e.spec.js index 15dbf94..cf3fda2 100644 --- a/test/e2e/safe-chain-cli-python.e2e.spec.js +++ b/test/e2e/safe-chain-cli-python.e2e.spec.js @@ -97,7 +97,7 @@ describe("E2E: safe-chain CLI python/pip support", () => { await shell.runCommand("pip3 cache purge"); const result = await shell.runCommand( - "safe-chain pip3 install --break-system-packages safe-chain-pi-test" + "safe-chain pip3 install --break-system-packages numpy==2.4.4" ); assert.ok( diff --git a/test/e2e/utils/malwarelistmirror.mjs b/test/e2e/utils/malwarelistmirror.mjs new file mode 100644 index 0000000..e8091b0 --- /dev/null +++ b/test/e2e/utils/malwarelistmirror.mjs @@ -0,0 +1,79 @@ +// Test-only mirror of the malware list. Injects known-safe packages as malicious +// to simulate blocking behavior in e2e tests without affecting real data. + +import * as http from "node:http"; + +const lists = await downloadLists(); +const server = http.createServer(handleRequest); +server.listen(5555, "127.0.0.1"); +console.log("listening on http://127.0.0.1:5555"); + +function handleRequest(req, res) { + if (req.method !== "GET" || !req.url) { + res.writeHead(404); + res.end(); + return; + } + + if (req.url.startsWith("/ready")) { + res.writeHead(200); + res.end(); + return; + } + + for (const list of lists) { + if (req.url.startsWith(list.path)) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(list.data)); + return; + } + } + + res.writeHead(404); + res.end(); +} + +async function downloadLists() { + const lists = [ + { + "path": "/malware_predictions.json", + "patchFunc": (data) => data, + }, + { + "path": "/malware_pypi.json", + "patchFunc": patchPypi, + }, + { + "path": "/releases/npm.json", + "patchFunc": (data) => data, + }, + { + "path": "/releases/pypi.json", + "patchFunc": (data) => data, + }, + ] + + for (const list of lists) { + list.data = list.patchFunc(await downloadList(list.path)); + } + + return lists; +} + +async function downloadList(path) { + const baseUrl = "https://malware-list.aikido.dev"; + const url = `${baseUrl}${path}`; + const response = await fetch(url); + return await response.json(); +} + +function patchPypi(data) { + + data.push({ + "package_name": "numpy", + "version": "2.4.4", + "reason": "MALWARE" + }); + + return data; +} diff --git a/test/e2e/uv.e2e.spec.js b/test/e2e/uv.e2e.spec.js index 9d5f3b9..5536e22 100644 --- a/test/e2e/uv.e2e.spec.js +++ b/test/e2e/uv.e2e.spec.js @@ -126,7 +126,7 @@ describe("E2E: uv coverage", () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand( - "uv pip install --system --break-system-packages safe-chain-pi-test" + "uv pip install --system --break-system-packages numpy==2.4.4" ); assert.ok( @@ -134,7 +134,7 @@ describe("E2E: uv coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( - result.output.includes("safe_chain_pi_test@0.0.1"), + result.output.includes("numpy@2.4.4"), `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( @@ -144,7 +144,7 @@ describe("E2E: uv coverage", () => { const listResult = await shell.runCommand("uv pip list --system"); assert.ok( - !listResult.output.includes("safe-chain-pi-test"), + !listResult.output.includes("numpy"), `Malicious package was installed despite safe-chain protection. Output of 'uv pip list' was:\n${listResult.output}` ); }); @@ -413,7 +413,7 @@ describe("E2E: uv coverage", () => { await shell.runCommand("uv init test-project-malware"); const result = await shell.runCommand( - "cd test-project-malware && uv add safe-chain-pi-test" + "cd test-project-malware && uv add numpy==2.4.4" ); assert.ok( @@ -421,7 +421,7 @@ describe("E2E: uv coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( - result.output.includes("safe_chain_pi_test@0.0.1"), + result.output.includes("numpy@2.4.4"), `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( @@ -445,14 +445,14 @@ describe("E2E: uv coverage", () => { it(`safe-chain blocks malicious packages via uv tool install`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand("uv tool install safe-chain-pi-test"); + const result = await shell.runCommand("uv tool install numpy==2.4.4"); 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_pi_test@0.0.1"), + result.output.includes("numpy@2.4.4"), `Output did not include expected text. Output was:\n${result.output}` ); }); @@ -482,7 +482,7 @@ describe("E2E: uv coverage", () => { await shell.runCommand("echo 'print(\"test\")' > test_script2.py"); const result = await shell.runCommand( - "uv run --with safe-chain-pi-test test_script2.py" + "uv run --with numpy==2.4.4 test_script2.py" ); assert.ok( diff --git a/test/e2e/uvx.e2e.spec.js b/test/e2e/uvx.e2e.spec.js index 12dfc0f..61fb924 100644 --- a/test/e2e/uvx.e2e.spec.js +++ b/test/e2e/uvx.e2e.spec.js @@ -44,7 +44,7 @@ describe("E2E: uvx coverage", () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand( - "uvx safe-chain-pi-test" + "uvx numpy==2.4.4" ); assert.ok( @@ -74,7 +74,7 @@ describe("E2E: uvx coverage", () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand( - "uvx --from safe-chain-pi-test some-command" + "uvx --from numpy==2.4.4 some-command" ); assert.ok( @@ -117,7 +117,7 @@ describe("E2E: uvx coverage", () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand( - "uvx --with safe-chain-pi-test ruff --version" + "uvx --with numpy==2.4.4 ruff --version" ); assert.ok( From d0fc643f23923de97c23c7ff04fecb829d02729c Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 29 Apr 2026 12:50:17 +0200 Subject: [PATCH 13/34] Verify sha2356 checksum in install scripts --- .github/workflows/build-and-release.yml | 37 ++++++++++++-- install-scripts/install-safe-chain.ps1 | 49 ++++++++++++++++++ install-scripts/install-safe-chain.sh | 66 +++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 7cd2a91..36dad7b 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -60,12 +60,43 @@ jobs: mv binaries/safe-chain-win-x64/safe-chain.exe release-artifacts/safe-chain-win-x64.exe mv binaries/safe-chain-win-arm64/safe-chain.exe release-artifacts/safe-chain-win-arm64.exe - - name: Move install scripts and hard-code version + - name: Move install scripts and hard-code version and checksums env: VERSION: ${{ needs.set-version.outputs.version }} run: | - sed "s/\$(fetch_latest_version)/${VERSION}/" install-scripts/install-safe-chain.sh > release-artifacts/install-safe-chain.sh - sed "s/\$Version = Get-LatestVersion/\$Version = \"${VERSION}\"/" install-scripts/install-safe-chain.ps1 > release-artifacts/install-safe-chain.ps1 + SHA_MACOS_X64=$(sha256sum release-artifacts/safe-chain-macos-x64 | awk '{print $1}') + SHA_MACOS_ARM64=$(sha256sum release-artifacts/safe-chain-macos-arm64 | awk '{print $1}') + SHA_LINUX_X64=$(sha256sum release-artifacts/safe-chain-linux-x64 | awk '{print $1}') + SHA_LINUX_ARM64=$(sha256sum release-artifacts/safe-chain-linux-arm64 | awk '{print $1}') + SHA_LINUXSTATIC_X64=$(sha256sum release-artifacts/safe-chain-linuxstatic-x64 | awk '{print $1}') + SHA_LINUXSTATIC_ARM64=$(sha256sum release-artifacts/safe-chain-linuxstatic-arm64 | awk '{print $1}') + SHA_WIN_X64=$(sha256sum release-artifacts/safe-chain-win-x64.exe | awk '{print $1}') + SHA_WIN_ARM64=$(sha256sum release-artifacts/safe-chain-win-arm64.exe | awk '{print $1}') + + sed \ + -e "s/\$(fetch_latest_version)/${VERSION}/" \ + -e "s|^SHA256_MACOS_X64=\"\"|SHA256_MACOS_X64=\"${SHA_MACOS_X64}\"|" \ + -e "s|^SHA256_MACOS_ARM64=\"\"|SHA256_MACOS_ARM64=\"${SHA_MACOS_ARM64}\"|" \ + -e "s|^SHA256_LINUX_X64=\"\"|SHA256_LINUX_X64=\"${SHA_LINUX_X64}\"|" \ + -e "s|^SHA256_LINUX_ARM64=\"\"|SHA256_LINUX_ARM64=\"${SHA_LINUX_ARM64}\"|" \ + -e "s|^SHA256_LINUXSTATIC_X64=\"\"|SHA256_LINUXSTATIC_X64=\"${SHA_LINUXSTATIC_X64}\"|" \ + -e "s|^SHA256_LINUXSTATIC_ARM64=\"\"|SHA256_LINUXSTATIC_ARM64=\"${SHA_LINUXSTATIC_ARM64}\"|" \ + -e "s|^SHA256_WIN_X64=\"\"|SHA256_WIN_X64=\"${SHA_WIN_X64}\"|" \ + -e "s|^SHA256_WIN_ARM64=\"\"|SHA256_WIN_ARM64=\"${SHA_WIN_ARM64}\"|" \ + install-scripts/install-safe-chain.sh > release-artifacts/install-safe-chain.sh + + sed \ + -e "s/\$Version = Get-LatestVersion/\$Version = \"${VERSION}\"/" \ + -e "s|^\$SHA256_MACOS_X64 = \"\"|\$SHA256_MACOS_X64 = \"${SHA_MACOS_X64}\"|" \ + -e "s|^\$SHA256_MACOS_ARM64 = \"\"|\$SHA256_MACOS_ARM64 = \"${SHA_MACOS_ARM64}\"|" \ + -e "s|^\$SHA256_LINUX_X64 = \"\"|\$SHA256_LINUX_X64 = \"${SHA_LINUX_X64}\"|" \ + -e "s|^\$SHA256_LINUX_ARM64 = \"\"|\$SHA256_LINUX_ARM64 = \"${SHA_LINUX_ARM64}\"|" \ + -e "s|^\$SHA256_LINUXSTATIC_X64 = \"\"|\$SHA256_LINUXSTATIC_X64 = \"${SHA_LINUXSTATIC_X64}\"|" \ + -e "s|^\$SHA256_LINUXSTATIC_ARM64 = \"\"|\$SHA256_LINUXSTATIC_ARM64 = \"${SHA_LINUXSTATIC_ARM64}\"|" \ + -e "s|^\$SHA256_WIN_X64 = \"\"|\$SHA256_WIN_X64 = \"${SHA_WIN_X64}\"|" \ + -e "s|^\$SHA256_WIN_ARM64 = \"\"|\$SHA256_WIN_ARM64 = \"${SHA_WIN_ARM64}\"|" \ + install-scripts/install-safe-chain.ps1 > release-artifacts/install-safe-chain.ps1 + cp install-scripts/uninstall-safe-chain.sh release-artifacts/uninstall-safe-chain.sh cp install-scripts/uninstall-safe-chain.ps1 release-artifacts/uninstall-safe-chain.ps1 cp install-scripts/install-endpoint-mac.sh release-artifacts/install-endpoint-mac.sh diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index a11edf6..53ce15f 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -52,6 +52,20 @@ $SafeChainBase = $installDirValidation.Normalized $InstallDir = Join-Path $SafeChainBase "bin" $RepoUrl = "https://github.com/AikidoSec/safe-chain" +# SHA256 checksums for release binaries. +# Empty in source; populated by the release pipeline. +# When empty (running from main), checksum verification is skipped. +# Non-Windows hashes are unused today (PS script is Windows-only) but baked in +# for future cross-platform support. +$SHA256_MACOS_X64 = "" +$SHA256_MACOS_ARM64 = "" +$SHA256_LINUX_X64 = "" +$SHA256_LINUX_ARM64 = "" +$SHA256_LINUXSTATIC_X64 = "" +$SHA256_LINUXSTATIC_ARM64 = "" +$SHA256_WIN_X64 = "" +$SHA256_WIN_ARM64 = "" + # Ensure TLS 1.2 is enabled for downloads [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 @@ -166,6 +180,38 @@ function Get-BinaryName { return "safe-chain-win-$Architecture.exe" } +# Returns the expected SHA256 for the given OS+arch, or empty if not baked in. +function Get-ExpectedSha256 { + param([string]$Os, [string]$Architecture) + switch ("$Os-$Architecture") { + "macos-x64" { return $SHA256_MACOS_X64 } + "macos-arm64" { return $SHA256_MACOS_ARM64 } + "linux-x64" { return $SHA256_LINUX_X64 } + "linux-arm64" { return $SHA256_LINUX_ARM64 } + "linuxstatic-x64" { return $SHA256_LINUXSTATIC_X64 } + "linuxstatic-arm64" { return $SHA256_LINUXSTATIC_ARM64 } + "win-x64" { return $SHA256_WIN_X64 } + "win-arm64" { return $SHA256_WIN_ARM64 } + default { return "" } + } +} + +function Test-Checksum { + param([string]$File, [string]$Expected) + + if ([string]::IsNullOrWhiteSpace($Expected)) { return } + + $actual = (Get-FileHash -Path $File -Algorithm SHA256).Hash.ToLowerInvariant() + $expectedLower = $Expected.ToLowerInvariant() + + if ($actual -ne $expectedLower) { + Remove-Item -Path $File -Force -ErrorAction SilentlyContinue + Write-Error-Custom "Checksum verification failed. Expected: $expectedLower, Got: $actual" + } + + Write-Info "Checksum verified." +} + # Runs safe-chain setup or setup-ci after the binary is installed. # Temporarily appends the install directory to PATH and downgrades setup failures to warnings. function Invoke-SafeChainSetup { @@ -305,6 +351,9 @@ function Install-SafeChain { Write-Error-Custom "Failed to download from $downloadUrl : $_" } + $expectedSha = Get-ExpectedSha256 -Os "win" -Architecture $arch + Test-Checksum -File $tempFile -Expected $expectedSha + # Rename to final location $finalFile = Join-Path $InstallDir "safe-chain.exe" try { diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index da7d3c0..5f73c53 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -55,6 +55,18 @@ SAFE_CHAIN_BASE="${HOME}/.safe-chain" INSTALL_DIR="${SAFE_CHAIN_BASE}/bin" REPO_URL="https://github.com/AikidoSec/safe-chain" +# SHA256 checksums for release binaries. +# Empty in source; populated by the release pipeline via sed. +# When empty (running from main), checksum verification is skipped. +SHA256_MACOS_X64="" +SHA256_MACOS_ARM64="" +SHA256_LINUX_X64="" +SHA256_LINUX_ARM64="" +SHA256_LINUXSTATIC_X64="" +SHA256_LINUXSTATIC_ARM64="" +SHA256_WIN_X64="" +SHA256_WIN_ARM64="" + # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' @@ -156,6 +168,57 @@ fetch_latest_version() { echo "$latest_version" } +# Returns the expected SHA256 for the detected platform, or empty if the +# release pipeline has not baked one in (i.e. running the source from main). +get_expected_sha256() { + os="$1"; arch="$2" + case "${os}-${arch}" in + macos-x64) echo "$SHA256_MACOS_X64" ;; + macos-arm64) echo "$SHA256_MACOS_ARM64" ;; + linux-x64) echo "$SHA256_LINUX_X64" ;; + linux-arm64) echo "$SHA256_LINUX_ARM64" ;; + linuxstatic-x64) echo "$SHA256_LINUXSTATIC_X64" ;; + linuxstatic-arm64) echo "$SHA256_LINUXSTATIC_ARM64" ;; + win-x64) echo "$SHA256_WIN_X64" ;; + win-arm64) echo "$SHA256_WIN_ARM64" ;; + *) echo "" ;; + esac +} + +compute_sha256() { + file="$1" + if command_exists sha256sum; then + sha256sum "$file" | awk '{print $1}' + elif command_exists shasum; then + shasum -a 256 "$file" | awk '{print $1}' + else + echo "" + fi +} + +# Verifies the downloaded binary against the expected hash baked in by the release pipeline. +# No-op when no expected hash is set (running the script from main). +verify_checksum() { + file="$1"; expected="$2" + + if [ -z "$expected" ]; then + return + fi + + actual=$(compute_sha256 "$file") + if [ -z "$actual" ]; then + rm -f "$file" + error "Cannot verify checksum: neither sha256sum nor shasum is available. Install one and re-run." + fi + + if [ "$actual" != "$expected" ]; then + rm -f "$file" + error "Checksum verification failed. Expected: $expected, Got: $actual" + fi + + info "Checksum verified." +} + # Download file download() { url="$1" @@ -428,6 +491,9 @@ main() { info "Downloading from: $DOWNLOAD_URL" download "$DOWNLOAD_URL" "$TEMP_FILE" + EXPECTED_SHA256=$(get_expected_sha256 "$OS" "$ARCH") + verify_checksum "$TEMP_FILE" "$EXPECTED_SHA256" + # Rename and make executable FINAL_FILE=$(get_final_binary_path "$OS") mv "$TEMP_FILE" "$FINAL_FILE" || error "Failed to move binary to $FINAL_FILE" From f3fd003303397a4f86a669af01198a8477eeb0fb Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 29 Apr 2026 15:23:09 +0200 Subject: [PATCH 14/34] Update Aikido Endpoint version to 1.3.1 --- install-scripts/install-endpoint-mac.sh | 4 ++-- install-scripts/install-endpoint-windows.ps1 | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/install-scripts/install-endpoint-mac.sh b/install-scripts/install-endpoint-mac.sh index 02df48b..3531d2f 100755 --- a/install-scripts/install-endpoint-mac.sh +++ b/install-scripts/install-endpoint-mac.sh @@ -7,8 +7,8 @@ set -e # Exit on error # Configuration -INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.23/EndpointProtection.pkg" -DOWNLOAD_SHA256="9af1e0f72e53516c888ade1753ed03f087c1def89244eb0afb60e1f11e8e87e2" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.3.1/EndpointProtection.pkg" +DOWNLOAD_SHA256="c8c32019aaf3a897e19728f14b783dd803df8b215ca7e1042d07957a13332dc0" TOKEN_FILE="/tmp/aikido_endpoint_token.txt" # Colors for output diff --git a/install-scripts/install-endpoint-windows.ps1 b/install-scripts/install-endpoint-windows.ps1 index 437264e..2797394 100644 --- a/install-scripts/install-endpoint-windows.ps1 +++ b/install-scripts/install-endpoint-windows.ps1 @@ -7,8 +7,8 @@ param( ) # Configuration -$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.23/EndpointProtection.msi" -$DownloadSha256 = "3327d35db6654d12dbd7c5ccec0645edb0277f71dcd993ba9733e266bbd235f8" +$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.3.1/EndpointProtection.msi" +$DownloadSha256 = "6d72170cfd2090c6af8e111a625fa3961f9dc345495117db4f1d7c518d537076" # Ensure TLS 1.2 is enabled for downloads [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 From c8e25f3c21d79933e7ad32c5de26569976e5a70d Mon Sep 17 00:00:00 2001 From: Tudor Timcu Date: Thu, 30 Apr 2026 18:02:18 +0300 Subject: [PATCH 15/34] Bump Endpoint Protection to v1.3.2 --- install-scripts/install-endpoint-mac.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/install-scripts/install-endpoint-mac.sh b/install-scripts/install-endpoint-mac.sh index 3531d2f..5877d7b 100755 --- a/install-scripts/install-endpoint-mac.sh +++ b/install-scripts/install-endpoint-mac.sh @@ -7,8 +7,8 @@ set -e # Exit on error # Configuration -INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.3.1/EndpointProtection.pkg" -DOWNLOAD_SHA256="c8c32019aaf3a897e19728f14b783dd803df8b215ca7e1042d07957a13332dc0" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.3.2/EndpointProtection.pkg" +DOWNLOAD_SHA256="02ba05ad3de289f4507ba0e26dcf4ff5c5eb8fe589e378a86a4177499a9a29a9" TOKEN_FILE="/tmp/aikido_endpoint_token.txt" # Colors for output From 19d2dee5c9726052a496c52986ec1d19bdbdd098 Mon Sep 17 00:00:00 2001 From: Xander Van Raemdonck Date: Wed, 22 Apr 2026 21:11:27 +0200 Subject: [PATCH 16/34] Bind registry proxy to loopback only Without an explicit host, `server.listen(0)` binds to every interface, turning safe-chain's unauthenticated forward proxy into an open relay while `aikido-*` commands are running. Anyone reachable on the network can use it to hit the victim's localhost, intranet, or cloud metadata endpoints. The advertised HTTPS_PROXY URL already used `localhost` (loopback), but the listener itself was wide open. Bind to 127.0.0.1 explicitly and update the advertised URL to match. Add a regression test that verifies the listener refuses connections on non-loopback interfaces. --- .../src/registryProxy/registryProxy.js | 9 ++- .../registryProxy.loopback.spec.js | 67 +++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 packages/safe-chain/src/registryProxy/registryProxy.loopback.spec.js diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index 0b009bb..694c72c 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -42,7 +42,7 @@ function getSafeChainProxyEnvironmentVariables() { return {}; } - const proxyUrl = `http://localhost:${state.port}`; + const proxyUrl = `http://127.0.0.1:${state.port}`; const caCertPath = getCombinedCaBundlePath(); return { @@ -95,8 +95,11 @@ function createProxyServer() { */ function startServer(server) { return new Promise((resolve, reject) => { - // Passing port 0 makes the OS assign an available port - server.listen(0, () => { + // Bind to loopback only. Without an explicit host, Node listens on every + // interface, turning the proxy into an unauthenticated forward proxy that + // anyone reachable on the network can use to hit the victim's localhost, + // intranet, or cloud metadata endpoints. Port 0 lets the OS pick a port. + server.listen(0, "127.0.0.1", () => { const address = server.address(); if (address && typeof address === "object") { state.port = address.port; diff --git a/packages/safe-chain/src/registryProxy/registryProxy.loopback.spec.js b/packages/safe-chain/src/registryProxy/registryProxy.loopback.spec.js new file mode 100644 index 0000000..64bb862 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/registryProxy.loopback.spec.js @@ -0,0 +1,67 @@ +import { before, after, describe, it } from "node:test"; +import assert from "node:assert"; +import net from "node:net"; +import os from "node:os"; +import { + createSafeChainProxy, + mergeSafeChainProxyEnvironmentVariables, +} from "./registryProxy.js"; + +describe("registryProxy loopback binding", () => { + let proxy, proxyPort; + + before(async () => { + proxy = createSafeChainProxy(); + await proxy.startServer(); + const envVars = mergeSafeChainProxyEnvironmentVariables([]); + proxyPort = parseInt(new URL(envVars.HTTPS_PROXY).port, 10); + }); + + after(async () => { + await proxy.stopServer(); + }); + + it("advertises a loopback HTTPS_PROXY URL", () => { + const envVars = mergeSafeChainProxyEnvironmentVariables([]); + const hostname = new URL(envVars.HTTPS_PROXY).hostname; + assert.ok( + hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost", + `expected loopback hostname, got ${hostname}` + ); + }); + + it("refuses connections on non-loopback interfaces", async () => { + const externalAddrs = Object.values(os.networkInterfaces()) + .flat() + .filter((iface) => iface && iface.family === "IPv4" && !iface.internal) + .map((iface) => iface.address); + + if (externalAddrs.length === 0) { + // No non-loopback interface available (e.g. locked-down CI) - skip. + return; + } + + for (const addr of externalAddrs) { + await new Promise((resolve, reject) => { + const sock = net.createConnection({ host: addr, port: proxyPort }); + const timer = setTimeout(() => { + sock.destroy(); + resolve(); // Filtered / dropped is also fine - we just don't want success. + }, 500); + sock.once("connect", () => { + clearTimeout(timer); + sock.destroy(); + reject( + new Error( + `proxy accepted a connection on non-loopback ${addr}:${proxyPort}` + ) + ); + }); + sock.once("error", () => { + clearTimeout(timer); + resolve(); + }); + }); + } + }); +}); From a0f0372e159e163cf8ebbe9f321304e76fae2127 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 30 Apr 2026 15:21:51 -0700 Subject: [PATCH 17/34] Add PIP_CONFIG_FILE section in readme --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index a25d526..6513578 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,12 @@ You can set custom registries through environment variable or config file. Both } ``` +## PYPI Configuration File + +If you rely on a `pip.conf` file for pip configuration you must point pip at it explicitly via the `PIP_CONFIG_FILE` environment variable so Safe Chain can merge it. + +Safe Chain runs pip behind its MITM proxy and writes a temporary pip configuration file to inject its certificate and proxy settings. When `PIP_CONFIG_FILE` is set, Safe Chain merges its settings into a copy of your file (your original file is never modified) so your `index-url`, credentials, and other options are preserved. When `PIP_CONFIG_FILE` is not set, pip's user-level config (e.g. `~/.config/pip/pip.conf`) might be overridden by Safe Chain's temporary file and your settings will not be picked up. + ## Malware List Base URL Configure Safe Chain to fetch malware databases and new packages lists from a custom mirror URL. This allows you to host your own copy of the Aikido malware database. From f4aa444cd8d26d584b383349c8f565bc0a99c340 Mon Sep 17 00:00:00 2001 From: Tudor Timcu Date: Fri, 1 May 2026 14:43:41 +0300 Subject: [PATCH 18/34] Bump Endpoint Protection to latest --- install-scripts/install-endpoint-mac.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/install-scripts/install-endpoint-mac.sh b/install-scripts/install-endpoint-mac.sh index 5877d7b..ead41d5 100755 --- a/install-scripts/install-endpoint-mac.sh +++ b/install-scripts/install-endpoint-mac.sh @@ -7,8 +7,8 @@ set -e # Exit on error # Configuration -INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.3.2/EndpointProtection.pkg" -DOWNLOAD_SHA256="02ba05ad3de289f4507ba0e26dcf4ff5c5eb8fe589e378a86a4177499a9a29a9" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.3.3/EndpointProtection.pkg" +DOWNLOAD_SHA256="a025d33ca493a3b7b77c9515fe7f0b2c1f2dd18fb3e60e08549499cafee6f250" TOKEN_FILE="/tmp/aikido_endpoint_token.txt" # Colors for output From 98a1ba7d103368ab2d4c19facb77f927926afaa1 Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Fri, 1 May 2026 17:04:28 +0100 Subject: [PATCH 19/34] 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 a3f7a87..41785e1 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 2e36d0a..d6cc0e0 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 0000000..dfa168c --- /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 e1f801c..900bd83 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 42766d7..f7ae933 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 ee68ee1..90050d3 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 16c5815..d51a832 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 f6ba3cc..ed43c23 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 b21087e..daabcab 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 0000000..af89d21 --- /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 0000000..20b4a32 --- /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 f61ff98..dd10f3f 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 4438124..7af41d6 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 06960ef..728aff1 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 452e62d..cde8f48 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 f65deb9..7aad2fc 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 369a94948a73c4ab925763e9797372d167dfb8c7 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 1 May 2026 14:34:35 -0700 Subject: [PATCH 20/34] Bump Endpoint to 1.3.4 --- install-scripts/install-endpoint-mac.sh | 4 ++-- install-scripts/install-endpoint-windows.ps1 | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/install-scripts/install-endpoint-mac.sh b/install-scripts/install-endpoint-mac.sh index ead41d5..feabeb1 100755 --- a/install-scripts/install-endpoint-mac.sh +++ b/install-scripts/install-endpoint-mac.sh @@ -7,8 +7,8 @@ set -e # Exit on error # Configuration -INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.3.3/EndpointProtection.pkg" -DOWNLOAD_SHA256="a025d33ca493a3b7b77c9515fe7f0b2c1f2dd18fb3e60e08549499cafee6f250" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.3.4/EndpointProtection.pkg" +DOWNLOAD_SHA256="f2ea55588d42e4aa17545ad787f46dd36001009e2ddb9655c497b1a36edf3581" TOKEN_FILE="/tmp/aikido_endpoint_token.txt" # Colors for output diff --git a/install-scripts/install-endpoint-windows.ps1 b/install-scripts/install-endpoint-windows.ps1 index 2797394..29bc873 100644 --- a/install-scripts/install-endpoint-windows.ps1 +++ b/install-scripts/install-endpoint-windows.ps1 @@ -7,8 +7,8 @@ param( ) # Configuration -$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.3.1/EndpointProtection.msi" -$DownloadSha256 = "6d72170cfd2090c6af8e111a625fa3961f9dc345495117db4f1d7c518d537076" +$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.3.4/EndpointProtection.msi" +$DownloadSha256 = "0699379716a9a8b1531befa538befb237252af9f7fd780b33f4dce73588c6f83" # Ensure TLS 1.2 is enabled for downloads [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 From cd5040c3bea52464f07425965bd0a65b041da851 Mon Sep 17 00:00:00 2001 From: Samuel Vandamme Date: Wed, 6 May 2026 10:47:37 +0200 Subject: [PATCH 21/34] moved troubleshooting from docs to here --- README.md | 307 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 306 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6513578..0cec8d3 100644 --- a/README.md +++ b/README.md @@ -548,4 +548,309 @@ npm-ci: # Troubleshooting -Having issues? See the [Troubleshooting Guide](https://help.aikido.dev/code-scanning/aikido-malware-scanning/safe-chain-troubleshooting) for help with common problems. +## Verification & Diagnostics + +**Check Installation** + +```bash +# Check version +safe-chain --version +``` + +**Verify Shell Integration** + +Run the verification command for your package manager: + +```bash +npm safe-chain-verify +pnpm safe-chain-verify +``` + +``` +Expected output: `OK: Safe-chain works!` +``` + +**Test Malware Blocking** + +Verify that malware detection is working: +``` +npm install safe-chain-test +``` + +These test packages are flagged as malware and should be blocked by Safe Chain. + +**If the test package installs successfully instead of being blocked**, see Malware Not Being Blocked below. + +## Logging Options + +Use logging flags or environment variables to get more information: + +```bash +# Verbose mode - detailed diagnostic output for troubleshooting +npm install express --safe-chain-logging=verbose + +# Or set it globally for all commands in your session +export SAFE_CHAIN_LOGGING=verbose +npm install express + +# Silent mode - suppress all output except malware blocking +npm install express --safe-chain-logging=silent +``` + +## Common Issues + +### Malware Not Being Blocked + +**Symptom:** Test malware packages (like `safe-chain-test`) install successfully when they should be blocked + +**Most Common Cause:** The package is cached in your package manager's local store + +Safe-chain blocks malicious packages by intercepting network requests to package registries using its proxy. + +When a package is already cached locally, the package manager skips downloading it from the registry, which bypasses the proxy. + +**Resolution Steps** + +1) Clear your package manager's cache + +```bash +# For npm +npm cache clean --force + +# For pnpm +pnpm store prune + +# For yarn (classic) +yarn cache clean + +# For yarn (berry/v2+) +yarn cache clean --all + +# For bun +bun pm cache rm +``` + +2) Clean local installation artifacts: + +```bash +# Remove node_modules if you want a completely fresh install +rm -rf node_modules +``` + +3) Re-test malware blocking: + +```bash +npm install safe-chain-test # Should be blocked +``` + +### Shell Aliases Not Working After Installation + +**Symptom:** Running `npm` shows regular npm instead of safe-chain wrapped version + +**First step:** Restart your terminal (most common fix) + +**Verify it's working:** + +```bash +type npm +``` + +Should show: `npm is a function` + +**If still not working:** + +Check that your startup file sources safe-chain scripts from `~/.safe-chain/scripts/`: + +* Bash: `~/.bashrc` +* Zsh: `~/.zshrc` +* Fish: `~/.config/fish/config.fish` +* PowerShell: `$PROFILE` + +### "Command Not Found: safe-chain" + +**Symptom:** Binary not found in PATH + +**First step:** Restart your terminal + +**Check PATH:** + +```bash +echo $PATH +``` + +Should include `~/.safe-chain/bin` + +**If persists:** Re-run the installation script + +### PowerShell Execution Policy Blocks Scripts (Windows) + +**Symptom:** When opening PowerShell, you see an error like: + +``` +. : File C:\Users\\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1 cannot be loaded because +running scripts is disabled on this system. +CategoryInfo : SecurityError: (:) [], PSSecurityException +FullyQualifiedErrorId : UnauthorizedAccess +``` + +**Cause:** Windows PowerShell's default execution policy (`Restricted`) blocks all script execution, including safe-chain's initialization script that's sourced from your PowerShell profile. + +**Resolution** + +1) Set the execution policy to allow local scripts + +Open PowerShell as Administrator and run: + +```powershell +Set-ExecutionPolicy -ExecutionPolicy RemoteSigned +``` + +This allows: + +* Local scripts (like safe-chain's) to run without signing +* Downloaded scripts to run only if signed by a trusted publisher + +2) Restart PowerShell and verify the error is resolved. + +> [!IMPORTANT] +> `RemoteSigned` is Microsoft's recommended execution policy for client computers. It provides a good balance between security and usability. + +### Shell Aliases Persist After Uninstallation + +**Symptom:** safe-chain commands still active after running uninstall script + +**Steps** + +1. Run `safe-chain teardown` (if binary still exists) +2. Restart your terminal +3. If still present, manually edit shell config files: + * Bash: `~/.bashrc` + * Zsh: `~/.zshrc` + * Fish: `~/.config/fish/config.fish` + * PowerShell: `$PROFILE` +4. Remove lines that source scripts from `~/.safe-chain/scripts/` +5. Restart terminal again + +## Manual Verification Steps + +### Check Installation Status + +```bash +# Check installation location (helps identify if installed via npm or as standalone binary) +which safe-chain + +# Verify binary exists +ls ~/.safe-chain/bin/safe-chain + +# Check version +safe-chain --version + +# Test shell integration +type npm +type pip +``` + +**Expected `which` output:** + +* Standalone binary (correct): `~/.safe-chain/bin/safe-chain` or `/Users//.safe-chain/bin/safe-chain` +* npm global (outdated): path containing `node_modules` or nvm version paths + +If `which` shows an npm installation, see Check for Conflicting Installations. + +### Check Shell Integration + +```bash +# Which shell you're using +echo $SHELL + +# Check if startup file sources safe-chain +# For Bash: +grep safe-chain ~/.bashrc + +# For Zsh: +grep safe-chain ~/.zshrc + +# For Fish: +grep safe-chain ~/.config/fish/config.fish + +# Verify scripts exist +ls ~/.safe-chain/scripts/ +``` + +### Check for Conflicting Installations + +> **Note:** The install/uninstall scripts automatically detect and remove conflicting installations, but you can manually check: + +```bash +# Check npm global +npm list -g @aikidosec/safe-chain + +# Check Volta +volta list safe-chain + +# Check nvm (all versions) +for version in $(nvm list | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+'); do + nvm exec "$version" npm list -g @aikidosec/safe-chain 2>/dev/null && echo "Found in $version" +done +``` + +### Manual Cleanup + +> **Note:** The install and uninstall scripts automatically handle these cleanup steps. Use these manual commands only if automatic cleanup fails. + +#### Remove npm Global Installation + +```bash +npm uninstall -g @aikidosec/safe-chain +``` + +#### Remove Volta Installation + +```bash +volta uninstall @aikidosec/safe-chain +``` + +#### Remove nvm Installations (All Versions) + +```bash +# Automated approach +for version in $(nvm list | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+'); do + nvm exec "$version" npm uninstall -g @aikidosec/safe-chain +done + +# Or manual per version +nvm use +npm uninstall -g @aikidosec/safe-chain +``` + +#### Clean Shell Configuration Files + +Manually remove safe-chain entries from: + +* Bash: `~/.bashrc` +* Zsh: `~/.zshrc` +* Fish: `~/.config/fish/config.fish` +* PowerShell: `$PROFILE` + +Look for and remove: + +* Lines sourcing from `~/.safe-chain/scripts/` +* Any safe-chain related function definitions + +#### Remove Installation Directory + +```bash +rm -rf ~/.safe-chain +``` + +# Report Issues + +If you encounter problems: + +1. Visit [GitHub Issues](https://github.com/AikidoSec/safe-chain/issues) +2. Include: + * Operating system and version + * Shell type and version + * `safe-chain --version` output + * Output from verification commands + * Verbose logs of the failing command (add the `--safe-chain-logging=verbose` argument) From bd876275b3830be8cb820fa9b85e999b02356214 Mon Sep 17 00:00:00 2001 From: Samuel Vandamme Date: Wed, 6 May 2026 10:51:13 +0200 Subject: [PATCH 22/34] updated troubleshooting guide and linked from readme --- README.md | 295 +--------------------------------------- docs/troubleshooting.md | 161 +++++++++------------- 2 files changed, 69 insertions(+), 387 deletions(-) diff --git a/README.md b/README.md index 0cec8d3..60631b0 100644 --- a/README.md +++ b/README.md @@ -548,300 +548,7 @@ npm-ci: # Troubleshooting -## Verification & Diagnostics - -**Check Installation** - -```bash -# Check version -safe-chain --version -``` - -**Verify Shell Integration** - -Run the verification command for your package manager: - -```bash -npm safe-chain-verify -pnpm safe-chain-verify -``` - -``` -Expected output: `OK: Safe-chain works!` -``` - -**Test Malware Blocking** - -Verify that malware detection is working: -``` -npm install safe-chain-test -``` - -These test packages are flagged as malware and should be blocked by Safe Chain. - -**If the test package installs successfully instead of being blocked**, see Malware Not Being Blocked below. - -## Logging Options - -Use logging flags or environment variables to get more information: - -```bash -# Verbose mode - detailed diagnostic output for troubleshooting -npm install express --safe-chain-logging=verbose - -# Or set it globally for all commands in your session -export SAFE_CHAIN_LOGGING=verbose -npm install express - -# Silent mode - suppress all output except malware blocking -npm install express --safe-chain-logging=silent -``` - -## Common Issues - -### Malware Not Being Blocked - -**Symptom:** Test malware packages (like `safe-chain-test`) install successfully when they should be blocked - -**Most Common Cause:** The package is cached in your package manager's local store - -Safe-chain blocks malicious packages by intercepting network requests to package registries using its proxy. - -When a package is already cached locally, the package manager skips downloading it from the registry, which bypasses the proxy. - -**Resolution Steps** - -1) Clear your package manager's cache - -```bash -# For npm -npm cache clean --force - -# For pnpm -pnpm store prune - -# For yarn (classic) -yarn cache clean - -# For yarn (berry/v2+) -yarn cache clean --all - -# For bun -bun pm cache rm -``` - -2) Clean local installation artifacts: - -```bash -# Remove node_modules if you want a completely fresh install -rm -rf node_modules -``` - -3) Re-test malware blocking: - -```bash -npm install safe-chain-test # Should be blocked -``` - -### Shell Aliases Not Working After Installation - -**Symptom:** Running `npm` shows regular npm instead of safe-chain wrapped version - -**First step:** Restart your terminal (most common fix) - -**Verify it's working:** - -```bash -type npm -``` - -Should show: `npm is a function` - -**If still not working:** - -Check that your startup file sources safe-chain scripts from `~/.safe-chain/scripts/`: - -* Bash: `~/.bashrc` -* Zsh: `~/.zshrc` -* Fish: `~/.config/fish/config.fish` -* PowerShell: `$PROFILE` - -### "Command Not Found: safe-chain" - -**Symptom:** Binary not found in PATH - -**First step:** Restart your terminal - -**Check PATH:** - -```bash -echo $PATH -``` - -Should include `~/.safe-chain/bin` - -**If persists:** Re-run the installation script - -### PowerShell Execution Policy Blocks Scripts (Windows) - -**Symptom:** When opening PowerShell, you see an error like: - -``` -. : File C:\Users\\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1 cannot be loaded because -running scripts is disabled on this system. -CategoryInfo : SecurityError: (:) [], PSSecurityException -FullyQualifiedErrorId : UnauthorizedAccess -``` - -**Cause:** Windows PowerShell's default execution policy (`Restricted`) blocks all script execution, including safe-chain's initialization script that's sourced from your PowerShell profile. - -**Resolution** - -1) Set the execution policy to allow local scripts - -Open PowerShell as Administrator and run: - -```powershell -Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -``` - -This allows: - -* Local scripts (like safe-chain's) to run without signing -* Downloaded scripts to run only if signed by a trusted publisher - -2) Restart PowerShell and verify the error is resolved. - -> [!IMPORTANT] -> `RemoteSigned` is Microsoft's recommended execution policy for client computers. It provides a good balance between security and usability. - -### Shell Aliases Persist After Uninstallation - -**Symptom:** safe-chain commands still active after running uninstall script - -**Steps** - -1. Run `safe-chain teardown` (if binary still exists) -2. Restart your terminal -3. If still present, manually edit shell config files: - * Bash: `~/.bashrc` - * Zsh: `~/.zshrc` - * Fish: `~/.config/fish/config.fish` - * PowerShell: `$PROFILE` -4. Remove lines that source scripts from `~/.safe-chain/scripts/` -5. Restart terminal again - -## Manual Verification Steps - -### Check Installation Status - -```bash -# Check installation location (helps identify if installed via npm or as standalone binary) -which safe-chain - -# Verify binary exists -ls ~/.safe-chain/bin/safe-chain - -# Check version -safe-chain --version - -# Test shell integration -type npm -type pip -``` - -**Expected `which` output:** - -* Standalone binary (correct): `~/.safe-chain/bin/safe-chain` or `/Users//.safe-chain/bin/safe-chain` -* npm global (outdated): path containing `node_modules` or nvm version paths - -If `which` shows an npm installation, see Check for Conflicting Installations. - -### Check Shell Integration - -```bash -# Which shell you're using -echo $SHELL - -# Check if startup file sources safe-chain -# For Bash: -grep safe-chain ~/.bashrc - -# For Zsh: -grep safe-chain ~/.zshrc - -# For Fish: -grep safe-chain ~/.config/fish/config.fish - -# Verify scripts exist -ls ~/.safe-chain/scripts/ -``` - -### Check for Conflicting Installations - -> **Note:** The install/uninstall scripts automatically detect and remove conflicting installations, but you can manually check: - -```bash -# Check npm global -npm list -g @aikidosec/safe-chain - -# Check Volta -volta list safe-chain - -# Check nvm (all versions) -for version in $(nvm list | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+'); do - nvm exec "$version" npm list -g @aikidosec/safe-chain 2>/dev/null && echo "Found in $version" -done -``` - -### Manual Cleanup - -> **Note:** The install and uninstall scripts automatically handle these cleanup steps. Use these manual commands only if automatic cleanup fails. - -#### Remove npm Global Installation - -```bash -npm uninstall -g @aikidosec/safe-chain -``` - -#### Remove Volta Installation - -```bash -volta uninstall @aikidosec/safe-chain -``` - -#### Remove nvm Installations (All Versions) - -```bash -# Automated approach -for version in $(nvm list | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+'); do - nvm exec "$version" npm uninstall -g @aikidosec/safe-chain -done - -# Or manual per version -nvm use -npm uninstall -g @aikidosec/safe-chain -``` - -#### Clean Shell Configuration Files - -Manually remove safe-chain entries from: - -* Bash: `~/.bashrc` -* Zsh: `~/.zshrc` -* Fish: `~/.config/fish/config.fish` -* PowerShell: `$PROFILE` - -Look for and remove: - -* Lines sourcing from `~/.safe-chain/scripts/` -* Any safe-chain related function definitions - -#### Remove Installation Directory - -```bash -rm -rf ~/.safe-chain -``` +Having issues? See the [Troubleshooting Guide](./docs/troubleshooting) for help with common problems. # Report Issues diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 456fe58..321fb67 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,52 +1,39 @@ # Troubleshooting -This guide helps you diagnose and resolve common issues with Aikido Safe Chain. - ## Verification & Diagnostics -### Check Installation +**Check Installation** ```bash # Check version safe-chain --version ``` -### Verify Shell Integration +**Verify Shell Integration** Run the verification command for your package manager: ```bash npm safe-chain-verify pnpm safe-chain-verify -pip safe-chain-verify -uv safe-chain-verify - -# Any other supported package manager: {packagemanager} safe-chain-verify ``` +``` Expected output: `OK: Safe-chain works!` +``` -### Test Malware Blocking +**Test Malware Blocking** Verify that malware detection is working: - -**For JavaScript/Node.js:** - -```bash -npm install safe-chain-test ``` - -**For Python:** - -```bash -pip3 install safe-chain-pi-test +npm install safe-chain-test ``` These test packages are flagged as malware and should be blocked by Safe Chain. -**If the test package installs successfully instead of being blocked**, see [Malware Not Being Blocked](#malware-not-being-blocked) below. +**If the test package installs successfully instead of being blocked**, see Malware Not Being Blocked below. -### Logging Options +## Logging Options Use logging flags or environment variables to get more information: @@ -74,41 +61,39 @@ Safe-chain blocks malicious packages by intercepting network requests to package When a package is already cached locally, the package manager skips downloading it from the registry, which bypasses the proxy. -**Resolution Steps:** +**Resolution Steps** -1. **Clear your package manager's cache:** +1) Clear your package manager's cache - ```bash - # For npm - npm cache clean --force +```bash +# For npm +npm cache clean --force - # For pnpm - pnpm store prune +# For pnpm +pnpm store prune - # For yarn (classic) - yarn cache clean +# For yarn (classic) +yarn cache clean - # For yarn (berry/v2+) - yarn cache clean --all +# For yarn (berry/v2+) +yarn cache clean --all - # For bun - bun pm cache rm - ``` +# For bun +bun pm cache rm +``` - > **⚠️ Warning:** Cache clearing is safe but will remove all cached packages. Subsequent installations will need to re-download packages. In CI/CD environments or monorepos, this may affect build times. +2) Clean local installation artifacts: -2. **Clean local installation artifacts:** +```bash +# Remove node_modules if you want a completely fresh install +rm -rf node_modules +``` - ```bash - # Remove node_modules if you want a completely fresh install - rm -rf node_modules - ``` +3) Re-test malware blocking: -3. **Re-test malware blocking:** - - ```bash - npm install safe-chain-test # Should be blocked - ``` +```bash +npm install safe-chain-test # Should be blocked +``` ### Shell Aliases Not Working After Installation @@ -128,10 +113,10 @@ Should show: `npm is a function` Check that your startup file sources safe-chain scripts from `~/.safe-chain/scripts/`: -- Bash: `~/.bashrc` -- Zsh: `~/.zshrc` -- Fish: `~/.config/fish/config.fish` -- PowerShell: `$PROFILE` +* Bash: `~/.bashrc` +* Zsh: `~/.zshrc` +* Fish: `~/.config/fish/config.fish` +* PowerShell: `$PROFILE` ### "Command Not Found: safe-chain" @@ -162,37 +147,39 @@ FullyQualifiedErrorId : UnauthorizedAccess **Cause:** Windows PowerShell's default execution policy (`Restricted`) blocks all script execution, including safe-chain's initialization script that's sourced from your PowerShell profile. -**Resolution:** +**Resolution** -1. **Set the execution policy to allow local scripts:** +1) Set the execution policy to allow local scripts - Open PowerShell as Administrator and run: +Open PowerShell as Administrator and run: - ```powershell - Set-ExecutionPolicy -ExecutionPolicy RemoteSigned - ``` +```powershell +Set-ExecutionPolicy -ExecutionPolicy RemoteSigned +``` - This allows: - - Local scripts (like safe-chain's) to run without signing - - Downloaded scripts to run only if signed by a trusted publisher +This allows: -2. **Restart PowerShell** and verify the error is resolved. +* Local scripts (like safe-chain's) to run without signing +* Downloaded scripts to run only if signed by a trusted publisher -> **Note:** `RemoteSigned` is Microsoft's recommended execution policy for client computers. It provides a good balance between security and usability. +2) Restart PowerShell and verify the error is resolved. + +> [!IMPORTANT] +> `RemoteSigned` is Microsoft's recommended execution policy for client computers. It provides a good balance between security and usability. ### Shell Aliases Persist After Uninstallation **Symptom:** safe-chain commands still active after running uninstall script -**Steps:** +**Steps** 1. Run `safe-chain teardown` (if binary still exists) 2. Restart your terminal 3. If still present, manually edit shell config files: - - Bash: `~/.bashrc` - - Zsh: `~/.zshrc` - - Fish: `~/.config/fish/config.fish` - - PowerShell: `$PROFILE` + * Bash: `~/.bashrc` + * Zsh: `~/.zshrc` + * Fish: `~/.config/fish/config.fish` + * PowerShell: `$PROFILE` 4. Remove lines that source scripts from `~/.safe-chain/scripts/` 5. Restart terminal again @@ -217,10 +204,10 @@ type pip **Expected `which` output:** -- Standalone binary (correct): `~/.safe-chain/bin/safe-chain` or `/Users//.safe-chain/bin/safe-chain` -- npm global (outdated): path containing `node_modules` or nvm version paths +* Standalone binary (correct): `~/.safe-chain/bin/safe-chain` or `/Users//.safe-chain/bin/safe-chain` +* npm global (outdated): path containing `node_modules` or nvm version paths -If `which` shows an npm installation, see [Check for Conflicting Installations](#check-for-conflicting-installations). +If `which` shows an npm installation, see Check for Conflicting Installations. ### Check Shell Integration @@ -259,23 +246,23 @@ for version in $(nvm list | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+'); do done ``` -## Manual Cleanup +### Manual Cleanup > **Note:** The install and uninstall scripts automatically handle these cleanup steps. Use these manual commands only if automatic cleanup fails. -### Remove npm Global Installation +#### Remove npm Global Installation ```bash npm uninstall -g @aikidosec/safe-chain ``` -### Remove Volta Installation +#### Remove Volta Installation ```bash volta uninstall @aikidosec/safe-chain ``` -### Remove nvm Installations (All Versions) +#### Remove nvm Installations (All Versions) ```bash # Automated approach @@ -288,34 +275,22 @@ nvm use npm uninstall -g @aikidosec/safe-chain ``` -### Clean Shell Configuration Files +#### Clean Shell Configuration Files Manually remove safe-chain entries from: -- Bash: `~/.bashrc` -- Zsh: `~/.zshrc` -- Fish: `~/.config/fish/config.fish` -- PowerShell: `$PROFILE` +* Bash: `~/.bashrc` +* Zsh: `~/.zshrc` +* Fish: `~/.config/fish/config.fish` +* PowerShell: `$PROFILE` Look for and remove: -- Lines sourcing from `~/.safe-chain/scripts/` -- Any safe-chain related function definitions +* Lines sourcing from `~/.safe-chain/scripts/` +* Any safe-chain related function definitions -### Remove Installation Directory +#### Remove Installation Directory ```bash rm -rf ~/.safe-chain ``` - -### Report Issues - -If you encounter problems: - -1. Visit [GitHub Issues](https://github.com/AikidoSec/safe-chain/issues) -2. Include: - - Operating system and version - - Shell type and version - - `safe-chain --version` output - - Output from verification commands - - Verbose logs of the failing command (add the `--safe-chain-logging=verbose` argument) From fbe094802e05c2d44b1b2f9c68f180ea7415798e Mon Sep 17 00:00:00 2001 From: Samuel Vandamme Date: Wed, 6 May 2026 10:51:35 +0200 Subject: [PATCH 23/34] reverted copy --- docs/troubleshooting.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 321fb67..4672849 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,5 +1,7 @@ # Troubleshooting +This guide helps you diagnose and resolve common issues with Aikido Safe Chain. + ## Verification & Diagnostics **Check Installation** From 08ae1ef732a40340d523a01b184289bd7840d12e Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Fri, 8 May 2026 11:08:58 +0100 Subject: [PATCH 24/34] 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 d51a832..85ec4d5 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 0000000..3e82085 --- /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 0000000..0607c82 --- /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 25/34] 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 3de600c..c448b09 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 0000000..efe7ead --- /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 0000000..aaadf4e --- /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 26/34] 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 ed43c23..f2b249f 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 daabcab..343fb1e 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 27/34] 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 f2b249f..340e3f6 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 343fb1e..fa2c35a 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 28/34] 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 c448b09..0e38110 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 29/34] 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 efe7ead..fb3cbdd 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 6667e5d7b4eb68ee704efa8d931f40975cdcf1b3 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 11 May 2026 16:04:27 +0200 Subject: [PATCH 30/34] E2E: Use pnpm 10 in node versions that don't support pnpm 11 --- .github/workflows/test-on-pr.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-on-pr.yml b/.github/workflows/test-on-pr.yml index d7e9aab..744f52c 100644 --- a/.github/workflows/test-on-pr.yml +++ b/.github/workflows/test-on-pr.yml @@ -77,7 +77,7 @@ jobs: - node_version: "20" npm_version: "9.0.0" yarn_version: "latest" - pnpm_version: "latest" + pnpm_version: "10.0.0" # Version pinning scenario - node_version: "22" npm_version: "10.2.0" @@ -87,7 +87,7 @@ jobs: - node_version: "18" npm_version: "latest" yarn_version: "latest" - pnpm_version: "latest" + pnpm_version: "10.0.0" # Future compatibility (becomes LTS October 2025) - node_version: "24" npm_version: "latest" From 5f0ad7ecfdde2152aad12f826ccb20f92e94b46c Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Tue, 12 May 2026 10:33:26 +0100 Subject: [PATCH 31/34] 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 68aecf7..8148344 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 fb3cbdd..f2ccc14 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 aaadf4e..ab2c803 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 0000000..624cc61 --- /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 32/34] 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 624cc61..285c50e 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 33/34] 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 f2ccc14..70de4b8 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 34/34] 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 70de4b8..a5471a0 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 ab2c803..b7d5078 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(