From 5690e55d99be78d683196c55bb679c74eb2c699c Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Thu, 2 Apr 2026 12:31:02 +0100 Subject: [PATCH 01/47] 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/47] 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 1eb4fe05fdd7162cd0e4cbe58e41f01e2cfab95e Mon Sep 17 00:00:00 2001 From: Chris Ingram Date: Mon, 6 Apr 2026 13:01:42 +0100 Subject: [PATCH 03/47] Add pdm package manager support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PDM is a modern Python package manager using pyproject.toml (PEP 621). Uses the same MITM-only proxy approach as poetry/uv/pipx β€” all malware detection and minimum package age enforcement happens at the proxy layer by intercepting PyPI requests. --- README.md | 5 +- package-lock.json | 2 + packages/safe-chain/bin/aikido-pdm.js | 13 + packages/safe-chain/package.json | 3 +- .../packagemanager/currentPackageManager.js | 3 + .../pdm/createPdmPackageManager.js | 72 ++++ .../pdm/createPdmPackageManager.spec.js | 14 + .../src/shell-integration/helpers.js | 6 + .../startup-scripts/init-fish.fish | 4 + .../startup-scripts/init-posix.sh | 4 + .../startup-scripts/init-pwsh.ps1 | 4 + test/e2e/Dockerfile | 4 + test/e2e/pdm.e2e.spec.js | 317 ++++++++++++++++++ 13 files changed, 448 insertions(+), 3 deletions(-) create mode 100644 packages/safe-chain/bin/aikido-pdm.js create mode 100644 packages/safe-chain/src/packagemanager/pdm/createPdmPackageManager.js create mode 100644 packages/safe-chain/src/packagemanager/pdm/createPdmPackageManager.spec.js create mode 100644 test/e2e/pdm.e2e.spec.js diff --git a/README.md b/README.md index 3e73137..800d30c 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Aikido Safe Chain supports the following package managers: - πŸ“¦ **uv** - πŸ“¦ **poetry** - πŸ“¦ **pipx** +- πŸ“¦ **pdm** # Usage @@ -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, bun, bunx, pip, pip3, poetry, uv, pipx and pdm 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`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry`, `pipx` and `pdm` 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: diff --git a/package-lock.json b/package-lock.json index ea8c410..e6dc7b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3108,6 +3108,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4018,6 +4019,7 @@ "aikido-bunx": "bin/aikido-bunx.js", "aikido-npm": "bin/aikido-npm.js", "aikido-npx": "bin/aikido-npx.js", + "aikido-pdm": "bin/aikido-pdm.js", "aikido-pip": "bin/aikido-pip.js", "aikido-pip3": "bin/aikido-pip3.js", "aikido-pipx": "bin/aikido-pipx.js", diff --git a/packages/safe-chain/bin/aikido-pdm.js b/packages/safe-chain/bin/aikido-pdm.js new file mode 100644 index 0000000..9c6cf94 --- /dev/null +++ b/packages/safe-chain/bin/aikido-pdm.js @@ -0,0 +1,13 @@ +#!/usr/bin/env node + +import { main } from "../src/main.js"; +import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js"; + +setEcoSystem(ECOSYSTEM_PY); +initializePackageManager("pdm"); + +(async () => { + var exitCode = await main(process.argv.slice(2)); + process.exit(exitCode); +})(); diff --git a/packages/safe-chain/package.json b/packages/safe-chain/package.json index d4f3501..1ed2d5b 100644 --- a/packages/safe-chain/package.json +++ b/packages/safe-chain/package.json @@ -22,6 +22,7 @@ "aikido-python3": "bin/aikido-python3.js", "aikido-poetry": "bin/aikido-poetry.js", "aikido-pipx": "bin/aikido-pipx.js", + "aikido-pdm": "bin/aikido-pdm.js", "safe-chain": "bin/safe-chain.js" }, "type": "module", @@ -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), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), [pip](https://pip.pypa.io/), and [pdm](https://pdm-project.org/) 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, pip/pip3, or pdm 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..79e4625 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 { createPdmPackageManager } from "./pdm/createPdmPackageManager.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 === "pdm") { + state.packageManagerName = createPdmPackageManager(); } else { throw new Error("Unsupported package manager: " + packageManagerName); } diff --git a/packages/safe-chain/src/packagemanager/pdm/createPdmPackageManager.js b/packages/safe-chain/src/packagemanager/pdm/createPdmPackageManager.js new file mode 100644 index 0000000..1649a89 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/pdm/createPdmPackageManager.js @@ -0,0 +1,72 @@ +import { ui } from "../../environment/userInteraction.js"; +import { safeSpawn } from "../../utils/safeSpawn.js"; +import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; +import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js"; +import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; + +/** + * @returns {import("../currentPackageManager.js").PackageManager} + */ +export function createPdmPackageManager() { + return { + runCommand: (args) => runPdmCommand(args), + + // MITM only approach for PDM + isSupportedCommand: () => false, + getDependencyUpdatesForCommand: () => [], + }; +} + +/** + * Sets CA bundle environment variables used by PDM and Python libraries. + * PDM uses httpx (via unearth) which respects SSL_CERT_FILE through Python's ssl module. + * + * @param {NodeJS.ProcessEnv} env - Environment object to modify + * @param {string} combinedCaPath - Path to the combined CA bundle + */ +function setPdmCaBundleEnvironmentVariables(env, combinedCaPath) { + // SSL_CERT_FILE: Used by Python SSL libraries and httpx (which PDM uses) + if (env.SSL_CERT_FILE) { + ui.writeWarning("Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten."); + } + env.SSL_CERT_FILE = combinedCaPath; + + // REQUESTS_CA_BUNDLE: Used by the requests library (PDM plugins may use it) + if (env.REQUESTS_CA_BUNDLE) { + ui.writeWarning("Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten."); + } + env.REQUESTS_CA_BUNDLE = combinedCaPath; + + // PIP_CERT: PDM may use pip internally + if (env.PIP_CERT) { + ui.writeWarning("Safe-chain: User defined PIP_CERT found in environment. It will be overwritten."); + } + env.PIP_CERT = combinedCaPath; +} + +/** + * Runs a pdm command with safe-chain's certificate bundle and proxy configuration. + * + * PDM respects standard HTTP_PROXY/HTTPS_PROXY environment variables through + * httpx which it uses for package downloads. + * + * @param {string[]} args - Command line arguments to pass to pdm + * @returns {Promise<{status: number}>} Exit status of the pdm command + */ +async function runPdmCommand(args) { + try { + const env = mergeSafeChainProxyEnvironmentVariables(process.env); + + const combinedCaPath = getCombinedCaBundlePath(); + setPdmCaBundleEnvironmentVariables(env, combinedCaPath); + + const result = await safeSpawn("pdm", args, { + stdio: "inherit", + env, + }); + + return { status: result.status }; + } catch (/** @type any */ error) { + return reportCommandExecutionFailure(error, "pdm"); + } +} diff --git a/packages/safe-chain/src/packagemanager/pdm/createPdmPackageManager.spec.js b/packages/safe-chain/src/packagemanager/pdm/createPdmPackageManager.spec.js new file mode 100644 index 0000000..2b2266b --- /dev/null +++ b/packages/safe-chain/src/packagemanager/pdm/createPdmPackageManager.spec.js @@ -0,0 +1,14 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { createPdmPackageManager } from "./createPdmPackageManager.js"; + +test("createPdmPackageManager", async (t) => { + await t.test("should create package manager with required interface", () => { + const pm = createPdmPackageManager(); + + assert.ok(pm); + assert.strictEqual(typeof pm.runCommand, "function"); + assert.strictEqual(typeof pm.isSupportedCommand, "function"); + assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function"); + }); +}); diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index 18ba52e..6bef263 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -102,6 +102,12 @@ export const knownAikidoTools = [ ecoSystem: ECOSYSTEM_PY, internalPackageManagerName: "pipx", }, + { + tool: "pdm", + aikidoCommand: "aikido-pdm", + ecoSystem: ECOSYSTEM_PY, + internalPackageManagerName: "pdm", + }, // When adding a new tool here, also update the documentation for the new tool in the README.md ]; 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 13463f6..a33c3d5 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 @@ -69,6 +69,10 @@ function pipx wrapSafeChainCommand "pipx" $argv end +function pdm + wrapSafeChainCommand "pdm" $argv +end + function printSafeChainWarning set original_cmd $argv[1] 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 ebaaf3c..51eece2 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 @@ -65,6 +65,10 @@ function pipx() { wrapSafeChainCommand "pipx" "$@" } +function pdm() { + wrapSafeChainCommand "pdm" "$@" +} + function printSafeChainWarning() { # \033[43;30m is used to set the background color to yellow and text color to black # \033[0m is used to reset the text formatting 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 f82d0fc..15ac86c 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 @@ -70,6 +70,10 @@ function pipx { Invoke-WrappedCommand "pipx" $args $MyInvocation.Line $MyInvocation.OffsetInLine } +function pdm { + Invoke-WrappedCommand "pdm" $args $MyInvocation.Line $MyInvocation.OffsetInLine +} + function Write-SafeChainWarning { param([string]$Command) diff --git a/test/e2e/Dockerfile b/test/e2e/Dockerfile index bc7ffc2..ff2a86b 100644 --- a/test/e2e/Dockerfile +++ b/test/e2e/Dockerfile @@ -77,6 +77,10 @@ RUN apt-get update && apt-get install -y pipx && \ pipx install poetry && \ ln -sf /root/.local/bin/poetry /usr/local/bin/poetry +# Install PDM +RUN pipx install pdm && \ + ln -sf /root/.local/bin/pdm /usr/local/bin/pdm + # Copy and install Safe chain COPY --from=builder /app/*.tgz /pkgs/ RUN npm install -g /pkgs/*.tgz diff --git a/test/e2e/pdm.e2e.spec.js b/test/e2e/pdm.e2e.spec.js new file mode 100644 index 0000000..96379fb --- /dev/null +++ b/test/e2e/pdm.e2e.spec.js @@ -0,0 +1,317 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +describe("E2E: pdm coverage", () => { + let container; + + before(async () => { + DockerTestContainer.buildImage(); + }); + + beforeEach(async () => { + // Run a new Docker container for each test + container = new DockerTestContainer(); + await container.start(); + + const installationShell = await container.openShell("zsh"); + await installationShell.runCommand("safe-chain setup"); + + // Clear pdm cache + await installationShell.runCommand("command pdm cache clear"); + }); + + afterEach(async () => { + // Stop and clean up the container after each test + if (container) { + await container.stop(); + container = null; + } + }); + + it(`successfully installs known safe packages with pdm add`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize a new pdm project + await shell.runCommand("mkdir /tmp/test-pdm-project && cd /tmp/test-pdm-project"); + await shell.runCommand("cd /tmp/test-pdm-project && pdm init --non-interactive"); + + // Add a safe package + const result = await shell.runCommand( + "cd /tmp/test-pdm-project && pdm add requests" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Installing"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`pdm add with specific version`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-pdm-version && cd /tmp/test-pdm-version"); + await shell.runCommand("cd /tmp/test-pdm-version && pdm init --non-interactive"); + + const result = await shell.runCommand( + "cd /tmp/test-pdm-version && pdm add requests==2.32.3" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Installing"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`safe-chain blocks installation of malicious Python packages via pdm`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-pdm-malware && cd /tmp/test-pdm-malware"); + await shell.runCommand("cd /tmp/test-pdm-malware && pdm init --non-interactive"); + + const result = await shell.runCommand( + "cd /tmp/test-pdm-malware && pdm add safe-chain-pi-test" + ); + + assert.ok( + result.output.includes("blocked by safe-chain"), + `Expected malware to be blocked. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected exit message. Output was:\n${result.output}` + ); + }); + + it(`pdm install installs dependencies from pyproject.toml`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-pdm-install && cd /tmp/test-pdm-install"); + await shell.runCommand("cd /tmp/test-pdm-install && pdm init --non-interactive"); + await shell.runCommand("cd /tmp/test-pdm-install && pdm add requests"); + + // Now remove the virtualenv and run install + await shell.runCommand("cd /tmp/test-pdm-install && rm -rf .venv"); + + const result = await shell.runCommand( + "cd /tmp/test-pdm-install && pdm install" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Installing"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`pdm update updates dependencies`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-pdm-update && cd /tmp/test-pdm-update"); + await shell.runCommand("cd /tmp/test-pdm-update && pdm init --non-interactive"); + await shell.runCommand("cd /tmp/test-pdm-update && pdm add requests"); + + const result = await shell.runCommand( + "cd /tmp/test-pdm-update && pdm update" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Updating"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`pdm update with specific packages`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-pdm-update-specific && cd /tmp/test-pdm-update-specific"); + await shell.runCommand("cd /tmp/test-pdm-update-specific && pdm init --non-interactive"); + await shell.runCommand("cd /tmp/test-pdm-update-specific && pdm add requests certifi"); + + const result = await shell.runCommand( + "cd /tmp/test-pdm-update-specific && pdm update requests" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Updating"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`pdm add with multiple packages`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-pdm-multi && cd /tmp/test-pdm-multi"); + await shell.runCommand("cd /tmp/test-pdm-multi && pdm init --non-interactive"); + + const result = await shell.runCommand( + "cd /tmp/test-pdm-multi && pdm add requests certifi" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Installing"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`pdm add with extras`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-pdm-extras && cd /tmp/test-pdm-extras"); + await shell.runCommand("cd /tmp/test-pdm-extras && pdm init --non-interactive"); + + // Use quotes to prevent shell expansion of square brackets + const result = await shell.runCommand( + 'cd /tmp/test-pdm-extras && pdm add "requests[security]"' + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Installing"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`pdm add with development group`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-pdm-dev && cd /tmp/test-pdm-dev"); + await shell.runCommand("cd /tmp/test-pdm-dev && pdm init --non-interactive"); + + const result = await shell.runCommand( + "cd /tmp/test-pdm-dev && pdm add -dG dev pytest" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Installing"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`pdm lock creates/updates lock file`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-pdm-lock && cd /tmp/test-pdm-lock"); + await shell.runCommand("cd /tmp/test-pdm-lock && pdm init --non-interactive"); + await shell.runCommand("cd /tmp/test-pdm-lock && pdm add requests"); + await shell.runCommand("cd /tmp/test-pdm-lock && rm pdm.lock"); + + const result = await shell.runCommand( + "cd /tmp/test-pdm-lock && pdm lock" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Resolving") || result.output.includes("lock file"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`pdm remove does not download packages`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-pdm-remove && cd /tmp/test-pdm-remove"); + await shell.runCommand("cd /tmp/test-pdm-remove && pdm init --non-interactive"); + await shell.runCommand("cd /tmp/test-pdm-remove && pdm add requests"); + + const result = await shell.runCommand( + "cd /tmp/test-pdm-remove && pdm remove requests" + ); + + // Remove should succeed - it doesn't download packages, just modifies pyproject.toml + assert.ok( + !result.output.includes("blocked"), + `Remove command should not trigger downloads. Output was:\n${result.output}` + ); + }); + + it(`blocks malware during pdm install`, async () => { + const shell = await container.openShell("zsh"); + + // Create a project with malware in dependencies + await shell.runCommand("mkdir /tmp/test-pdm-install-malware && cd /tmp/test-pdm-install-malware"); + await shell.runCommand("cd /tmp/test-pdm-install-malware && pdm init --non-interactive"); + + // Add malware package - this will create lock file and attempt download + const result = await shell.runCommand( + "cd /tmp/test-pdm-install-malware && pdm add safe-chain-pi-test 2>&1" + ); + + assert.ok( + result.output.includes("blocked by safe-chain"), + `Expected malware to be blocked during add (which triggers install). Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected exit message. Output was:\n${result.output}` + ); + }); + + it(`blocks malware when adding malicious dependency alongside safe one`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-pdm-batch && cd /tmp/test-pdm-batch"); + await shell.runCommand("cd /tmp/test-pdm-batch && pdm init --non-interactive"); + + // Try to add malware alongside safe package + const result = await shell.runCommand( + "cd /tmp/test-pdm-batch && pdm add safe-chain-pi-test requests 2>&1" + ); + + assert.ok( + result.output.includes("blocked by safe-chain"), + `Expected malware to be blocked. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected exit message. Output was:\n${result.output}` + ); + + // Verify safe package was also not installed due to malware in batch + const listResult = await shell.runCommand("cd /tmp/test-pdm-batch && pdm list"); + assert.ok( + !listResult.output.includes("requests"), + `Safe package should not be installed when batch includes malware. Output was:\n${listResult.output}` + ); + }); + + it(`pdm non-network commands work correctly`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-pdm-nonnetwork && cd /tmp/test-pdm-nonnetwork"); + await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm init --non-interactive"); + await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm add requests"); + + // Test pdm --version + const versionResult = await shell.runCommand("pdm --version"); + assert.ok( + versionResult.output.includes("PDM") || versionResult.output.includes("pdm"), + `Expected version output. Output was:\n${versionResult.output}` + ); + + // Test pdm list (list installed packages) + const listResult = await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm list"); + assert.ok( + listResult.output.includes("requests"), + `Expected to see installed package. Output was:\n${listResult.output}` + ); + + // Test pdm info (show project info) + const infoResult = await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm info"); + assert.ok( + infoResult.output.includes("PDM") || infoResult.output.includes("Python") || infoResult.output.includes("Project"), + `Expected project info. Output was:\n${infoResult.output}` + ); + + // Test pdm config (show configuration) + const configResult = await shell.runCommand("pdm config"); + assert.ok( + configResult.output.length > 0, + `Expected configuration output. Output was:\n${configResult.output}` + ); + + // Test pdm run (execute command in virtualenv) - non-network command + const runResult = await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm run python --version"); + assert.ok( + runResult.output.includes("Python"), + `Expected Python version output. Output was:\n${runResult.output}` + ); + }); +}); From ced5e264208cb3959b4d95ef2ed5800fcfb3d5c0 Mon Sep 17 00:00:00 2001 From: Chris Ingram Date: Tue, 7 Apr 2026 11:19:04 +0100 Subject: [PATCH 04/47] File mode on aikido-pdm.js --- packages/safe-chain/bin/aikido-pdm.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 packages/safe-chain/bin/aikido-pdm.js diff --git a/packages/safe-chain/bin/aikido-pdm.js b/packages/safe-chain/bin/aikido-pdm.js old mode 100644 new mode 100755 From d0fc643f23923de97c23c7ff04fecb829d02729c Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 29 Apr 2026 12:50:17 +0200 Subject: [PATCH 05/47] 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 06/47] 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 07/47] 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 08/47] 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 09/47] 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 10/47] 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 11/47] 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 12/47] 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 13/47] 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 14/47] 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 15/47] 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 16/47] 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 17/47] 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 18/47] 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 19/47] 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 20/47] 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 21/47] 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 22/47] 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 23/47] 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 24/47] 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 25/47] 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 26/47] 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( From d9b7aefd343c98e9bbc6b1e89b49596c48e19cd5 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 13 May 2026 14:33:58 -0700 Subject: [PATCH 27/47] unset PKG_EXECPATH before invoking safe-chain binary --- packages/safe-chain/bin/safe-chain.js | 6 ++ .../templates/unix-wrapper.template.sh | 5 +- .../pkg-execpath-cleanup.spec.js | 60 +++++++++++++++++++ .../startup-scripts/init-fish.fish | 6 +- .../startup-scripts/init-posix.sh | 6 +- 5 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 packages/safe-chain/src/shell-integration/pkg-execpath-cleanup.spec.js diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index 900bd83..53b6617 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -1,5 +1,11 @@ #!/usr/bin/env node +// Strip PKG_EXECPATH from the environment so any child process safe-chain +// spawns (npm, uv, pip, …) doesn't inherit it. If it leaks into a subsequent +// safe-chain invocation (e.g. via a shim) the yao-pkg bootstrap would treat +// argv[1] as a script path and fail with MODULE_NOT_FOUND. +delete process.env.PKG_EXECPATH; + import chalk from "chalk"; import { ui } from "../src/environment/userInteraction.js"; import { setup } from "../src/shell-integration/setup.js"; diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh index 5b318ff..30ab833 100644 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh @@ -20,7 +20,10 @@ remove_shim_from_path() { } if command -v safe-chain >/dev/null 2>&1; then - # Remove shim directory from PATH when calling {{AIKIDO_COMMAND}} to prevent infinite loops + # Remove shim directory from PATH when calling {{AIKIDO_COMMAND}} to prevent infinite loops. + # Unset PKG_EXECPATH so the yao-pkg bootstrap inside the safe-chain binary doesn't + # mistake argv[1] for a script path and try to resolve "{{PACKAGE_MANAGER}}" against cwd. + unset PKG_EXECPATH PATH=$(remove_shim_from_path) exec safe-chain {{PACKAGE_MANAGER}} "$@" else # safe-chain is not reachable β€” warn the user so they know protection is inactive diff --git a/packages/safe-chain/src/shell-integration/pkg-execpath-cleanup.spec.js b/packages/safe-chain/src/shell-integration/pkg-execpath-cleanup.spec.js new file mode 100644 index 0000000..4057224 --- /dev/null +++ b/packages/safe-chain/src/shell-integration/pkg-execpath-cleanup.spec.js @@ -0,0 +1,60 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, "..", ".."); + +describe("PKG_EXECPATH cleanup", () => { + it("unix shim template unsets PKG_EXECPATH before invoking safe-chain", () => { + const file = path.join( + repoRoot, + "src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh", + ); + const content = fs.readFileSync(file, "utf-8"); + assert.match( + content, + /unset PKG_EXECPATH[\s\S]*exec safe-chain/, + "unix-wrapper.template.sh must `unset PKG_EXECPATH` before `exec safe-chain`", + ); + }); + + it("posix shell function unsets PKG_EXECPATH before invoking safe-chain", () => { + const file = path.join( + repoRoot, + "src/shell-integration/startup-scripts/init-posix.sh", + ); + const content = fs.readFileSync(file, "utf-8"); + // Scoped subshell so we don't mutate the user's interactive env. + assert.match( + content, + /\(unset PKG_EXECPATH;\s*safe-chain "\$@"\)/, + "init-posix.sh must invoke safe-chain in a subshell that unsets PKG_EXECPATH", + ); + }); + + it("fish shell function unsets PKG_EXECPATH before invoking safe-chain", () => { + const file = path.join( + repoRoot, + "src/shell-integration/startup-scripts/init-fish.fish", + ); + const content = fs.readFileSync(file, "utf-8"); + assert.match( + content, + /env -u PKG_EXECPATH safe-chain/, + "init-fish.fish must invoke safe-chain via `env -u PKG_EXECPATH`", + ); + }); + + it("safe-chain entry point deletes PKG_EXECPATH from process.env", () => { + const file = path.join(repoRoot, "bin/safe-chain.js"); + const content = fs.readFileSync(file, "utf-8"); + assert.match( + content, + /delete process\.env\.PKG_EXECPATH/, + "bin/safe-chain.js must delete process.env.PKG_EXECPATH so spawned children don't inherit it", + ); + }); +}); 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 728aff1..68a3df0 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 @@ -120,8 +120,10 @@ function wrapSafeChainCommand end if type -q safe-chain - # If the safe-chain command is available, just run it with the provided arguments - safe-chain $original_cmd $cmd_args + # If the safe-chain command is available, just run it with the provided arguments. + # Unset PKG_EXECPATH for this invocation so the yao-pkg bootstrap inside the + # safe-chain binary doesn't mistake argv[1] for a script path to resolve against cwd. + env -u PKG_EXECPATH safe-chain $original_cmd $cmd_args else # If the safe-chain command is not available, print a warning and run the original command printSafeChainWarning $original_cmd 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 cde8f48..258c281 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 @@ -109,8 +109,10 @@ function wrapSafeChainCommand() { fi if command -v safe-chain > /dev/null 2>&1; then - # If the aikido command is available, just run it with the provided arguments - safe-chain "$@" + # If the aikido command is available, just run it with the provided arguments. + # Unset PKG_EXECPATH so the yao-pkg bootstrap inside the safe-chain binary doesn't + # mistake argv[1] for a script path and try to resolve it against cwd. + (unset PKG_EXECPATH; safe-chain "$@") else # If the aikido command is not available, print a warning and run the original command printSafeChainWarning "$original_cmd" From 6cdad3df98bae5036c0142b5233a61546d0808d9 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 13 May 2026 20:27:27 -0700 Subject: [PATCH 28/47] Fix tests --- test/e2e/bun.e2e.spec.js | 10 ++++++---- test/e2e/npm.e2e.spec.js | 5 +++-- test/e2e/pip.e2e.spec.js | 5 +++-- test/e2e/pnpm.e2e.spec.js | 5 +++-- test/e2e/safe-chain-cli-python.e2e.spec.js | 5 +++-- test/e2e/uv.e2e.spec.js | 20 ++++++++++++-------- test/e2e/yarn.e2e.spec.js | 5 +++-- 7 files changed, 33 insertions(+), 22 deletions(-) diff --git a/test/e2e/bun.e2e.spec.js b/test/e2e/bun.e2e.spec.js index fb6e99a..c4d2e25 100644 --- a/test/e2e/bun.e2e.spec.js +++ b/test/e2e/bun.e2e.spec.js @@ -46,8 +46,9 @@ describe("E2E: bun coverage", () => { var result = await shell.runCommand("bun install"); - 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( @@ -65,8 +66,9 @@ describe("E2E: bun coverage", () => { const result = await shell.runCommand("bunx safe-chain-test"); - 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/npm.e2e.spec.js b/test/e2e/npm.e2e.spec.js index e8ba7c8..c1a09ab 100644 --- a/test/e2e/npm.e2e.spec.js +++ b/test/e2e/npm.e2e.spec.js @@ -70,8 +70,9 @@ describe("E2E: npm coverage", () => { var result = await shell.runCommand("npm install"); - 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/pip.e2e.spec.js b/test/e2e/pip.e2e.spec.js index af979dc..ecf8ad9 100644 --- a/test/e2e/pip.e2e.spec.js +++ b/test/e2e/pip.e2e.spec.js @@ -131,8 +131,9 @@ describe("E2E: pip coverage", () => { "pip3 install --break-system-packages numpy==2.4.4 --safe-chain-logging=verbose" ); - 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/pnpm.e2e.spec.js b/test/e2e/pnpm.e2e.spec.js index a15250a..4411492 100644 --- a/test/e2e/pnpm.e2e.spec.js +++ b/test/e2e/pnpm.e2e.spec.js @@ -70,8 +70,9 @@ describe("E2E: pnpm coverage", () => { var result = await shell.runCommand("pnpm install"); - 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/safe-chain-cli-python.e2e.spec.js b/test/e2e/safe-chain-cli-python.e2e.spec.js index cf3fda2..9d59fb3 100644 --- a/test/e2e/safe-chain-cli-python.e2e.spec.js +++ b/test/e2e/safe-chain-cli-python.e2e.spec.js @@ -100,8 +100,9 @@ describe("E2E: safe-chain CLI python/pip support", () => { "safe-chain pip3 install --break-system-packages numpy==2.4.4" ); - assert.ok( - result.output.includes("blocked 1 malicious package downloads"), + assert.match( + result.output, + /blocked \d+ malicious package downloads/, `Should have blocked malware. Output was:\n${result.output}` ); }); diff --git a/test/e2e/uv.e2e.spec.js b/test/e2e/uv.e2e.spec.js index 5536e22..8fd633a 100644 --- a/test/e2e/uv.e2e.spec.js +++ b/test/e2e/uv.e2e.spec.js @@ -129,8 +129,9 @@ describe("E2E: uv coverage", () => { "uv pip install --system --break-system-packages numpy==2.4.4" ); - 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( @@ -416,8 +417,9 @@ describe("E2E: uv coverage", () => { "cd test-project-malware && uv add numpy==2.4.4" ); - 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( @@ -447,8 +449,9 @@ describe("E2E: uv coverage", () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand("uv tool install numpy==2.4.4"); - 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( @@ -485,8 +488,9 @@ describe("E2E: uv coverage", () => { "uv run --with numpy==2.4.4 test_script2.py" ); - 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}` ); }); diff --git a/test/e2e/yarn.e2e.spec.js b/test/e2e/yarn.e2e.spec.js index 5e56d12..6f892a0 100644 --- a/test/e2e/yarn.e2e.spec.js +++ b/test/e2e/yarn.e2e.spec.js @@ -70,8 +70,9 @@ describe("E2E: yarn coverage", () => { var result = await shell.runCommand("yarn"); - 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( From e0e06431d166883bba74631fd28cf4b470d68845 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 13 May 2026 20:28:58 -0700 Subject: [PATCH 29/47] Fix tests --- test/e2e/bun.e2e.spec.js | 4 ++-- test/e2e/npm.e2e.spec.js | 2 +- test/e2e/pip.e2e.spec.js | 2 +- test/e2e/pnpm.e2e.spec.js | 2 +- test/e2e/rush.e2e.spec.js | 2 +- test/e2e/rushx.e2e.spec.js | 2 +- test/e2e/safe-chain-cli-python.e2e.spec.js | 2 +- test/e2e/uv.e2e.spec.js | 8 ++++---- test/e2e/yarn.e2e.spec.js | 2 +- 9 files changed, 13 insertions(+), 13 deletions(-) diff --git a/test/e2e/bun.e2e.spec.js b/test/e2e/bun.e2e.spec.js index c4d2e25..589d863 100644 --- a/test/e2e/bun.e2e.spec.js +++ b/test/e2e/bun.e2e.spec.js @@ -48,7 +48,7 @@ describe("E2E: bun coverage", () => { assert.match( result.output, - /blocked \d+ malicious package downloads/, + /blocked [1-9]\d* malicious package downloads/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( @@ -68,7 +68,7 @@ describe("E2E: bun coverage", () => { assert.match( result.output, - /blocked \d+ malicious package downloads/, + /blocked [1-9]\d* malicious package downloads/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( diff --git a/test/e2e/npm.e2e.spec.js b/test/e2e/npm.e2e.spec.js index c1a09ab..810359e 100644 --- a/test/e2e/npm.e2e.spec.js +++ b/test/e2e/npm.e2e.spec.js @@ -72,7 +72,7 @@ describe("E2E: npm coverage", () => { assert.match( result.output, - /blocked \d+ malicious package downloads/, + /blocked [1-9]\d* malicious package downloads/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( diff --git a/test/e2e/pip.e2e.spec.js b/test/e2e/pip.e2e.spec.js index ecf8ad9..8044a0f 100644 --- a/test/e2e/pip.e2e.spec.js +++ b/test/e2e/pip.e2e.spec.js @@ -133,7 +133,7 @@ describe("E2E: pip coverage", () => { assert.match( result.output, - /blocked \d+ malicious package downloads:/, + /blocked [1-9]\d* malicious package downloads:/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( diff --git a/test/e2e/pnpm.e2e.spec.js b/test/e2e/pnpm.e2e.spec.js index 4411492..6f9dacf 100644 --- a/test/e2e/pnpm.e2e.spec.js +++ b/test/e2e/pnpm.e2e.spec.js @@ -72,7 +72,7 @@ describe("E2E: pnpm coverage", () => { assert.match( result.output, - /blocked \d+ malicious package downloads/, + /blocked [1-9]\d* malicious package downloads/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( diff --git a/test/e2e/rush.e2e.spec.js b/test/e2e/rush.e2e.spec.js index a5471a0..fb6895f 100644 --- a/test/e2e/rush.e2e.spec.js +++ b/test/e2e/rush.e2e.spec.js @@ -109,7 +109,7 @@ describe("E2E: rush coverage", () => { assert.match( result.output, - /blocked \d+ malicious package downloads/, + /blocked [1-9]\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 b7d5078..ec5ff75 100644 --- a/test/e2e/rushx.e2e.spec.js +++ b/test/e2e/rushx.e2e.spec.js @@ -57,7 +57,7 @@ describe("E2E: rushx coverage", () => { assert.match( result.output, - /blocked \d+ malicious package downloads/, + /blocked [1-9]\d* malicious package downloads/, `Output did not include expected text. Output was:\n${result.output}` ); 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 9d59fb3..43187d8 100644 --- a/test/e2e/safe-chain-cli-python.e2e.spec.js +++ b/test/e2e/safe-chain-cli-python.e2e.spec.js @@ -102,7 +102,7 @@ describe("E2E: safe-chain CLI python/pip support", () => { assert.match( result.output, - /blocked \d+ malicious package downloads/, + /blocked [1-9]\d* malicious package downloads/, `Should have blocked malware. Output was:\n${result.output}` ); }); diff --git a/test/e2e/uv.e2e.spec.js b/test/e2e/uv.e2e.spec.js index 8fd633a..728d4c5 100644 --- a/test/e2e/uv.e2e.spec.js +++ b/test/e2e/uv.e2e.spec.js @@ -131,7 +131,7 @@ describe("E2E: uv coverage", () => { assert.match( result.output, - /blocked \d+ malicious package downloads:/, + /blocked [1-9]\d* malicious package downloads:/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( @@ -419,7 +419,7 @@ describe("E2E: uv coverage", () => { assert.match( result.output, - /blocked \d+ malicious package downloads:/, + /blocked [1-9]\d* malicious package downloads:/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( @@ -451,7 +451,7 @@ describe("E2E: uv coverage", () => { assert.match( result.output, - /blocked \d+ malicious package downloads:/, + /blocked [1-9]\d* malicious package downloads:/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( @@ -490,7 +490,7 @@ describe("E2E: uv coverage", () => { assert.match( result.output, - /blocked \d+ malicious package downloads:/, + /blocked [1-9]\d* malicious package downloads:/, `Output did not include expected text. Output was:\n${result.output}` ); }); diff --git a/test/e2e/yarn.e2e.spec.js b/test/e2e/yarn.e2e.spec.js index 6f892a0..e70d6fc 100644 --- a/test/e2e/yarn.e2e.spec.js +++ b/test/e2e/yarn.e2e.spec.js @@ -72,7 +72,7 @@ describe("E2E: yarn coverage", () => { assert.match( result.output, - /blocked \d+ malicious package downloads/, + /blocked [1-9]\d* malicious package downloads/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( From 54db058ac70810cbcc57507cc8d2d61c04401352 Mon Sep 17 00:00:00 2001 From: Chris Ingram Date: Thu, 14 May 2026 10:04:18 +0100 Subject: [PATCH 30/47] Use getPackageManagerList in safe-chain setup help text The install message in `safe-chain setup` help was hardcoding a stale list of package managers (missing uv, uvx, poetry, pipx, pdm). Use the existing getPackageManagerList() helper so the list stays in sync with knownAikidoTools. --- packages/safe-chain/bin/safe-chain.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index 900bd83..8853467 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -15,7 +15,7 @@ import { main } from "../src/main.js"; import path from "path"; import { fileURLToPath } from "url"; import fs from "fs"; -import { knownAikidoTools } from "../src/shell-integration/helpers.js"; +import { knownAikidoTools, getPackageManagerList } from "../src/shell-integration/helpers.js"; import { getInstalledSafeChainDir } from "../src/installLocation.js"; /** @type {string} */ @@ -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, rushx, bun, bunx, pip and pip3.`, + )}: This will setup your shell to wrap safe-chain around ${getPackageManagerList()}.`, ); ui.writeInformation( `- ${chalk.cyan( From ffe7f8de1f03c887c8697e86bad31b37d684b1bf Mon Sep 17 00:00:00 2001 From: Chris Ingram Date: Thu, 14 May 2026 16:28:50 +0100 Subject: [PATCH 31/47] Use numpy==2.4.4 as test malware in pdm e2e tests The safe-chain-pi-test package no longer exists on PyPI. Aikido now patches numpy==2.4.4 into the malware list for tests, matching the pattern already used in the poetry e2e suite. --- test/e2e/pdm.e2e.spec.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/e2e/pdm.e2e.spec.js b/test/e2e/pdm.e2e.spec.js index 96379fb..f9d1ee6 100644 --- a/test/e2e/pdm.e2e.spec.js +++ b/test/e2e/pdm.e2e.spec.js @@ -70,7 +70,7 @@ describe("E2E: pdm coverage", () => { await shell.runCommand("cd /tmp/test-pdm-malware && pdm init --non-interactive"); const result = await shell.runCommand( - "cd /tmp/test-pdm-malware && pdm add safe-chain-pi-test" + "cd /tmp/test-pdm-malware && pdm add numpy==2.4.4" ); assert.ok( @@ -231,7 +231,7 @@ describe("E2E: pdm coverage", () => { // Add malware package - this will create lock file and attempt download const result = await shell.runCommand( - "cd /tmp/test-pdm-install-malware && pdm add safe-chain-pi-test 2>&1" + "cd /tmp/test-pdm-install-malware && pdm add numpy==2.4.4 2>&1" ); assert.ok( @@ -252,7 +252,7 @@ describe("E2E: pdm coverage", () => { // Try to add malware alongside safe package const result = await shell.runCommand( - "cd /tmp/test-pdm-batch && pdm add safe-chain-pi-test requests 2>&1" + "cd /tmp/test-pdm-batch && pdm add numpy==2.4.4 requests 2>&1" ); assert.ok( From 8ab5cebd4f5c693999f2b36e1a8bc9463ed6fcd3 Mon Sep 17 00:00:00 2001 From: Chris Ingram Date: Thu, 14 May 2026 16:48:18 +0100 Subject: [PATCH 32/47] Match actual block output in pdm e2e assertions The user-facing message is "Safe-chain: blocked N malicious package downloads", not "blocked by safe-chain" (which only appears in the proxy's HTTP response, not the rendered CLI output). --- test/e2e/pdm.e2e.spec.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/e2e/pdm.e2e.spec.js b/test/e2e/pdm.e2e.spec.js index f9d1ee6..5287ca6 100644 --- a/test/e2e/pdm.e2e.spec.js +++ b/test/e2e/pdm.e2e.spec.js @@ -74,7 +74,7 @@ describe("E2E: pdm coverage", () => { ); assert.ok( - result.output.includes("blocked by safe-chain"), + result.output.includes("blocked") && result.output.includes("malicious package downloads"), `Expected malware to be blocked. Output was:\n${result.output}` ); assert.ok( @@ -235,7 +235,7 @@ describe("E2E: pdm coverage", () => { ); assert.ok( - result.output.includes("blocked by safe-chain"), + result.output.includes("blocked") && result.output.includes("malicious package downloads"), `Expected malware to be blocked during add (which triggers install). Output was:\n${result.output}` ); assert.ok( @@ -256,7 +256,7 @@ describe("E2E: pdm coverage", () => { ); assert.ok( - result.output.includes("blocked by safe-chain"), + result.output.includes("blocked") && result.output.includes("malicious package downloads"), `Expected malware to be blocked. Output was:\n${result.output}` ); assert.ok( From a1b89a55f8d04e0d9b286308c3ff0c9b351b6edf Mon Sep 17 00:00:00 2001 From: Chris Ingram Date: Thu, 14 May 2026 17:16:57 +0100 Subject: [PATCH 33/47] Make block-count assertions count-agnostic in bun e2e Bun retries blocked downloads, so the count in "blocked N malicious package downloads" can be >1. Match on the surrounding text rather than a fixed count to keep the assertion robust. Also drops the brittle "pdm update updates dependencies" case. --- test/e2e/bun.e2e.spec.js | 4 ++-- test/e2e/pdm.e2e.spec.js | 17 ----------------- 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/test/e2e/bun.e2e.spec.js b/test/e2e/bun.e2e.spec.js index fb6e99a..494ded2 100644 --- a/test/e2e/bun.e2e.spec.js +++ b/test/e2e/bun.e2e.spec.js @@ -47,7 +47,7 @@ describe("E2E: bun coverage", () => { var result = await shell.runCommand("bun install"); assert.ok( - result.output.includes("blocked 1 malicious package downloads"), + result.output.includes("blocked") && result.output.includes("malicious package downloads"), `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( @@ -66,7 +66,7 @@ describe("E2E: bun coverage", () => { const result = await shell.runCommand("bunx safe-chain-test"); assert.ok( - result.output.includes("blocked 1 malicious package downloads"), + result.output.includes("blocked") && result.output.includes("malicious package downloads"), `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( diff --git a/test/e2e/pdm.e2e.spec.js b/test/e2e/pdm.e2e.spec.js index 5287ca6..94bb5e0 100644 --- a/test/e2e/pdm.e2e.spec.js +++ b/test/e2e/pdm.e2e.spec.js @@ -103,23 +103,6 @@ describe("E2E: pdm coverage", () => { ); }); - it(`pdm update updates dependencies`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-pdm-update && cd /tmp/test-pdm-update"); - await shell.runCommand("cd /tmp/test-pdm-update && pdm init --non-interactive"); - await shell.runCommand("cd /tmp/test-pdm-update && pdm add requests"); - - const result = await shell.runCommand( - "cd /tmp/test-pdm-update && pdm update" - ); - - assert.ok( - result.output.includes("no malware found.") || result.output.includes("Updating"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - it(`pdm update with specific packages`, async () => { const shell = await container.openShell("zsh"); From 34898980d7aaef03c12e3b2a792b6b2f1fe47830 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 18 May 2026 10:24:37 +0200 Subject: [PATCH 34/47] Remove obsolete npm token from pipeline --- .github/workflows/build-and-release.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 36dad7b..08f714a 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -144,8 +144,6 @@ jobs: with: node-version: "lts/*" registry-url: "https://registry.npmjs.org/" - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} - name: Setup safe-chain run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci From b38aba43ddd179ef9d6c4d7572679d6d325a39c3 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Tue, 19 May 2026 14:37:02 -0700 Subject: [PATCH 35/47] Create a bump-endpoint.yml workflow --- .github/workflows/bump-endpoint.yml | 82 +++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 .github/workflows/bump-endpoint.yml diff --git a/.github/workflows/bump-endpoint.yml b/.github/workflows/bump-endpoint.yml new file mode 100644 index 0000000..595e820 --- /dev/null +++ b/.github/workflows/bump-endpoint.yml @@ -0,0 +1,82 @@ +name: Bump safechain-internals endpoint + +on: + schedule: + - cron: '0 * * * *' # every hour + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + bump-endpoint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Get latest safechain-internals release + id: latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION=$(gh api repos/AikidoSec/safechain-internals/releases/latest --jq '.tag_name') + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Get current version from install script + id: current + run: | + CURRENT=$(grep -oP '(?<=releases/download/)[^/]+(?=/EndpointProtection\.pkg)' install-scripts/install-endpoint-mac.sh) + echo "version=$CURRENT" >> $GITHUB_OUTPUT + + - name: Download assets and compute checksums + if: steps.latest.outputs.version != steps.current.outputs.version + id: checksums + run: | + VERSION="${{ steps.latest.outputs.version }}" + BASE="https://github.com/AikidoSec/safechain-internals/releases/download/${VERSION}" + curl -fsSL "${BASE}/EndpointProtection.pkg" -o /tmp/EndpointProtection.pkg + curl -fsSL "${BASE}/EndpointProtection.msi" -o /tmp/EndpointProtection.msi + echo "mac=$(sha256sum /tmp/EndpointProtection.pkg | cut -d' ' -f1)" >> $GITHUB_OUTPUT + echo "win=$(sha256sum /tmp/EndpointProtection.msi | cut -d' ' -f1)" >> $GITHUB_OUTPUT + + - name: Update install scripts + if: steps.latest.outputs.version != steps.current.outputs.version + run: | + NEW="${{ steps.latest.outputs.version }}" + OLD="${{ steps.current.outputs.version }}" + MAC_SHA="${{ steps.checksums.outputs.mac }}" + WIN_SHA="${{ steps.checksums.outputs.win }}" + + sed -i "s|${OLD}/EndpointProtection.pkg|${NEW}/EndpointProtection.pkg|" install-scripts/install-endpoint-mac.sh + sed -i "s|^DOWNLOAD_SHA256=\"[^\"]*\"|DOWNLOAD_SHA256=\"${MAC_SHA}\"|" install-scripts/install-endpoint-mac.sh + + sed -i "s|${OLD}/EndpointProtection.msi|${NEW}/EndpointProtection.msi|" install-scripts/install-endpoint-windows.ps1 + sed -i 's|^\$DownloadSha256 = "[^"]*"|\$DownloadSha256 = "'"${WIN_SHA}"'"|' install-scripts/install-endpoint-windows.ps1 + + - name: Open PR + if: steps.latest.outputs.version != steps.current.outputs.version + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + NEW="${{ steps.latest.outputs.version }}" + OLD="${{ steps.current.outputs.version }}" + BRANCH="bump/endpoint-${NEW}" + + # Skip if a PR for this version already exists + if gh pr list --head "$BRANCH" --json number --jq '.[0].number' | grep -q '[0-9]'; then + echo "PR for $NEW already open, skipping." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b "$BRANCH" + git add install-scripts/install-endpoint-mac.sh install-scripts/install-endpoint-windows.ps1 + git commit -m "Bump Endpoint to ${NEW}" + git push origin "$BRANCH" + gh pr create \ + --title "Bump Endpoint to ${NEW}" \ + --body "Automated bump of safechain-internals endpoint from \`${OLD}\` to \`${NEW}\`." \ + --head "$BRANCH" \ + --base main From 9d44eca1d169c4c1714c9c39eb48bc20548d9468 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Tue, 19 May 2026 14:39:04 -0700 Subject: [PATCH 36/47] Apply suggestion from @bitterpanda63 --- .github/workflows/bump-endpoint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/bump-endpoint.yml b/.github/workflows/bump-endpoint.yml index 595e820..0968115 100644 --- a/.github/workflows/bump-endpoint.yml +++ b/.github/workflows/bump-endpoint.yml @@ -1,4 +1,4 @@ -name: Bump safechain-internals endpoint +name: Bump Device Protection Automatically on: schedule: From cbbbe703d316912cedcf3ad0127f10956f123f04 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Tue, 19 May 2026 14:45:10 -0700 Subject: [PATCH 37/47] Add a slack webhook curl req for endpoint bumps --- .github/workflows/bump-endpoint.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/bump-endpoint.yml b/.github/workflows/bump-endpoint.yml index 0968115..db7e3b6 100644 --- a/.github/workflows/bump-endpoint.yml +++ b/.github/workflows/bump-endpoint.yml @@ -75,8 +75,12 @@ jobs: git add install-scripts/install-endpoint-mac.sh install-scripts/install-endpoint-windows.ps1 git commit -m "Bump Endpoint to ${NEW}" git push origin "$BRANCH" - gh pr create \ + PR_URL=$(gh pr create \ --title "Bump Endpoint to ${NEW}" \ --body "Automated bump of safechain-internals endpoint from \`${OLD}\` to \`${NEW}\`." \ --head "$BRANCH" \ - --base main + --base main) + + curl -s -X POST "https://hooks.slack.com/triggers/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" \ + -H "Content-Type: application/json" \ + -d "{\"text\": \"update to ${NEW} - ${PR_URL}\"}" From 47e9ed0f6cd94f5b67d0ada88311fc30f367ec34 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Tue, 19 May 2026 14:47:33 -0700 Subject: [PATCH 38/47] temp: trigger bump-endpoint on push to test --- .github/workflows/bump-endpoint.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/bump-endpoint.yml b/.github/workflows/bump-endpoint.yml index db7e3b6..d289893 100644 --- a/.github/workflows/bump-endpoint.yml +++ b/.github/workflows/bump-endpoint.yml @@ -1,6 +1,9 @@ name: Bump Device Protection Automatically on: + push: + branches: + - create-bump-endpoint-workflow schedule: - cron: '0 * * * *' # every hour workflow_dispatch: From 3f0837c65a30aafdb0d81bbdf6bdb65d72ff6bb1 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Tue, 19 May 2026 14:48:23 -0700 Subject: [PATCH 39/47] temp: use open-source-releaser runner --- .github/workflows/bump-endpoint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/bump-endpoint.yml b/.github/workflows/bump-endpoint.yml index d289893..3da395f 100644 --- a/.github/workflows/bump-endpoint.yml +++ b/.github/workflows/bump-endpoint.yml @@ -14,7 +14,7 @@ permissions: jobs: bump-endpoint: - runs-on: ubuntu-latest + runs-on: open-source-releaser steps: - uses: actions/checkout@v4 From 07b8571758638539db7327c19f63061936224e07 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Tue, 19 May 2026 14:52:37 -0700 Subject: [PATCH 40/47] temp: post compare URL to Slack instead of creating PR --- .github/workflows/bump-endpoint.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/bump-endpoint.yml b/.github/workflows/bump-endpoint.yml index 3da395f..b204c5b 100644 --- a/.github/workflows/bump-endpoint.yml +++ b/.github/workflows/bump-endpoint.yml @@ -78,11 +78,7 @@ jobs: git add install-scripts/install-endpoint-mac.sh install-scripts/install-endpoint-windows.ps1 git commit -m "Bump Endpoint to ${NEW}" git push origin "$BRANCH" - PR_URL=$(gh pr create \ - --title "Bump Endpoint to ${NEW}" \ - --body "Automated bump of safechain-internals endpoint from \`${OLD}\` to \`${NEW}\`." \ - --head "$BRANCH" \ - --base main) + PR_URL="https://github.com/${{ github.repository }}/compare/main...${BRANCH}?expand=1" curl -s -X POST "https://hooks.slack.com/triggers/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" \ -H "Content-Type: application/json" \ From 0b46c5408b18ad924b19f8672590ea28ddb1c24a Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Tue, 19 May 2026 14:55:22 -0700 Subject: [PATCH 41/47] Update bump-endpoint.yml --- .github/workflows/bump-endpoint.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/bump-endpoint.yml b/.github/workflows/bump-endpoint.yml index b204c5b..becdb77 100644 --- a/.github/workflows/bump-endpoint.yml +++ b/.github/workflows/bump-endpoint.yml @@ -1,9 +1,6 @@ name: Bump Device Protection Automatically on: - push: - branches: - - create-bump-endpoint-workflow schedule: - cron: '0 * * * *' # every hour workflow_dispatch: From f2cce7b7e90edad50d1ba3b8bf43a59103d9db99 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Tue, 19 May 2026 14:56:04 -0700 Subject: [PATCH 42/47] temp: skip if branch already exists instead of checking for PR --- .github/workflows/bump-endpoint.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/bump-endpoint.yml b/.github/workflows/bump-endpoint.yml index becdb77..9a7df3b 100644 --- a/.github/workflows/bump-endpoint.yml +++ b/.github/workflows/bump-endpoint.yml @@ -63,9 +63,8 @@ jobs: OLD="${{ steps.current.outputs.version }}" BRANCH="bump/endpoint-${NEW}" - # Skip if a PR for this version already exists - if gh pr list --head "$BRANCH" --json number --jq '.[0].number' | grep -q '[0-9]'; then - echo "PR for $NEW already open, skipping." + if git ls-remote --exit-code --heads origin "$BRANCH" &>/dev/null; then + echo "Branch $BRANCH already exists, skipping." exit 0 fi From ab058367f1908260d5c1478c4cad620925a175d5 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Tue, 19 May 2026 14:56:46 -0700 Subject: [PATCH 43/47] temp: re-add push trigger for testing --- .github/workflows/bump-endpoint.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/bump-endpoint.yml b/.github/workflows/bump-endpoint.yml index 9a7df3b..6d4a93e 100644 --- a/.github/workflows/bump-endpoint.yml +++ b/.github/workflows/bump-endpoint.yml @@ -1,6 +1,9 @@ name: Bump Device Protection Automatically on: + push: + branches: + - create-bump-endpoint-workflow schedule: - cron: '0 * * * *' # every hour workflow_dispatch: From f6145d5c20226fcba96c3505290dacac7495e073 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Tue, 19 May 2026 14:58:55 -0700 Subject: [PATCH 44/47] Update bump-endpoint.yml --- .github/workflows/bump-endpoint.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/bump-endpoint.yml b/.github/workflows/bump-endpoint.yml index 6d4a93e..9a7df3b 100644 --- a/.github/workflows/bump-endpoint.yml +++ b/.github/workflows/bump-endpoint.yml @@ -1,9 +1,6 @@ name: Bump Device Protection Automatically on: - push: - branches: - - create-bump-endpoint-workflow schedule: - cron: '0 * * * *' # every hour workflow_dispatch: From aed0aebdae85825be0b56fd0bb7cd3a5bdc2dc41 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 20 May 2026 09:20:03 +0200 Subject: [PATCH 45/47] Store the slack url as a secret --- .github/workflows/bump-endpoint.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/bump-endpoint.yml b/.github/workflows/bump-endpoint.yml index 9a7df3b..8c61826 100644 --- a/.github/workflows/bump-endpoint.yml +++ b/.github/workflows/bump-endpoint.yml @@ -58,6 +58,7 @@ jobs: if: steps.latest.outputs.version != steps.current.outputs.version env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} run: | NEW="${{ steps.latest.outputs.version }}" OLD="${{ steps.current.outputs.version }}" @@ -76,6 +77,6 @@ jobs: git push origin "$BRANCH" PR_URL="https://github.com/${{ github.repository }}/compare/main...${BRANCH}?expand=1" - curl -s -X POST "https://hooks.slack.com/triggers/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" \ + curl -s -X POST "$SLACK_WEBHOOK_URL" \ -H "Content-Type: application/json" \ -d "{\"text\": \"update to ${NEW} - ${PR_URL}\"}" From 70b5e4d0125ade2fa53b3c42f5f76c6586d3918c Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 20 May 2026 08:39:03 -0700 Subject: [PATCH 46/47] 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 feabeb1..429dcc8 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.4/EndpointProtection.pkg" -DOWNLOAD_SHA256="f2ea55588d42e4aa17545ad787f46dd36001009e2ddb9655c497b1a36edf3581" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.5.3/EndpointProtection.pkg" +DOWNLOAD_SHA256="9a05eaf314876f236efd8a597aba6831b8569774d6cb4d0df4af4e74706a31eb" 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 29bc873..c47df95 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.4/EndpointProtection.msi" -$DownloadSha256 = "0699379716a9a8b1531befa538befb237252af9f7fd780b33f4dce73588c6f83" +$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.5.3/EndpointProtection.msi" +$DownloadSha256 = "e6d3d52a9c16b98014adb451dc7e544db15a55db59c83433f8d6f93aadd0c3d5" # Ensure TLS 1.2 is enabled for downloads [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 From 2621f6f974c1d2ec9ac25ba5cdc380a81641d5ef Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 21 May 2026 17:39:03 +0000 Subject: [PATCH 47/47] Bump Endpoint to v1.5.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 429dcc8..4cb9e9a 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.5.3/EndpointProtection.pkg" -DOWNLOAD_SHA256="9a05eaf314876f236efd8a597aba6831b8569774d6cb4d0df4af4e74706a31eb" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.5.4/EndpointProtection.pkg" +DOWNLOAD_SHA256="ad800f9e476b0a75bf32b1c079f060ecb98bc16972a4e8cca29cf165388ea9fe" 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 c47df95..05da8b4 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.5.3/EndpointProtection.msi" -$DownloadSha256 = "e6d3d52a9c16b98014adb451dc7e544db15a55db59c83433f8d6f93aadd0c3d5" +$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.5.4/EndpointProtection.msi" +$DownloadSha256 = "e2750c59124f53456a8f9cdb9e81fd9ce2f2491869f68f01602444ad519be5be" # Ensure TLS 1.2 is enabled for downloads [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12