Add rush command wrapper and tests

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

View file

@ -17,6 +17,7 @@ Aikido Safe Chain supports the following package managers:
- 📦 **yarn** - 📦 **yarn**
- 📦 **pnpm** - 📦 **pnpm**
- 📦 **pnpx** - 📦 **pnpx**
- 📦 **rush**
- 📦 **bun** - 📦 **bun**
- 📦 **bunx** - 📦 **bunx**
- 📦 **pip** - 📦 **pip**
@ -66,7 +67,7 @@ You can find all available versions on the [releases page](https://github.com/Ai
### Verify the installation ### Verify the installation
1. **❗Restart your terminal** to start using the Aikido Safe Chain. 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: 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. - 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: You can check the installed version by running:
@ -109,7 +110,7 @@ safe-chain --version
### Malware Blocking ### 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 ### Minimum package age
@ -127,7 +128,7 @@ By default, the minimum package age is 48 hours. This provides an additional sec
### Shell Integration ### 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** - ✅ **Bash**
- ✅ **Zsh** - ✅ **Zsh**

1
package-lock.json generated
View file

@ -4026,6 +4026,7 @@
"aikido-poetry": "bin/aikido-poetry.js", "aikido-poetry": "bin/aikido-poetry.js",
"aikido-python": "bin/aikido-python.js", "aikido-python": "bin/aikido-python.js",
"aikido-python3": "bin/aikido-python3.js", "aikido-python3": "bin/aikido-python3.js",
"aikido-rush": "bin/aikido-rush.js",
"aikido-uv": "bin/aikido-uv.js", "aikido-uv": "bin/aikido-uv.js",
"aikido-yarn": "bin/aikido-yarn.js", "aikido-yarn": "bin/aikido-yarn.js",
"safe-chain": "bin/safe-chain.js" "safe-chain": "bin/safe-chain.js"

View file

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

View file

@ -96,7 +96,7 @@ function writeHelp() {
ui.writeInformation( ui.writeInformation(
`- ${chalk.cyan( `- ${chalk.cyan(
"safe-chain setup", "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( ui.writeInformation(
`- ${chalk.cyan( `- ${chalk.cyan(

View file

@ -13,6 +13,7 @@
"aikido-yarn": "bin/aikido-yarn.js", "aikido-yarn": "bin/aikido-yarn.js",
"aikido-pnpm": "bin/aikido-pnpm.js", "aikido-pnpm": "bin/aikido-pnpm.js",
"aikido-pnpx": "bin/aikido-pnpx.js", "aikido-pnpx": "bin/aikido-pnpx.js",
"aikido-rush": "bin/aikido-rush.js",
"aikido-bun": "bin/aikido-bun.js", "aikido-bun": "bin/aikido-bun.js",
"aikido-bunx": "bin/aikido-bunx.js", "aikido-bunx": "bin/aikido-bunx.js",
"aikido-uv": "bin/aikido-uv.js", "aikido-uv": "bin/aikido-uv.js",
@ -36,7 +37,7 @@
"keywords": [], "keywords": [],
"author": "Aikido Security", "author": "Aikido Security",
"license": "AGPL-3.0-or-later", "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": { "dependencies": {
"archiver": "^7.0.1", "archiver": "^7.0.1",
"certifi": "14.5.15", "certifi": "14.5.15",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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