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]; +}