Merge remote-tracking branch 'aikido/main' into feat/pdm-support

This commit is contained in:
Chris Ingram 2026-05-14 09:51:31 +01:00
commit 8453012f7b
No known key found for this signature in database
44 changed files with 1311 additions and 202 deletions

View file

@ -58,12 +58,21 @@ export class DockerTestContainer {
`docker run -d --name ${this.containerName} ${imageName} sleep infinity`,
{ stdio: "ignore" }
);
await this.startMalwareMirror();
this.isRunning = true;
} catch (error) {
throw new Error(`Failed to start container: ${error.message}`);
}
}
async startMalwareMirror() {
const shell = await this.openShell("zsh");
await shell.runCommand("node /utils/malwarelistmirror.mjs &");
await shell.runCommand("until curl -sf http://127.0.0.1:5555/ready; do sleep 0.2; done");
}
dockerExec(command, daemon = false) {
if (!this.isRunning) {
throw new Error("Container is not running");
@ -125,7 +134,7 @@ export class DockerTestContainer {
const timeout = setTimeout(() => {
// Fallback in case the command doesn't finish in a reasonable time
// oxlint-disable-next-line no-console - having this log in CI helps diagnose issues
console.log("Command timeout reached");
console.log(`Command timeout reached for "${command}"`);
resolve({ allData, output: parseShellOutput(allData), command });
ptyProcess.removeListener("data", handleInput);
}, 15000);

View file

@ -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 volta install @microsoft/rush@${RUSH_VERSION}
# Install Bun
RUN curl -fsSL https://bun.sh/install | bash
@ -88,3 +90,5 @@ RUN npm install -g /pkgs/*.tgz
WORKDIR /testapp
RUN npm init -y
COPY test/e2e/utils/malwarelistmirror.mjs /utils/malwarelistmirror.mjs
ENV SAFE_CHAIN_MALWARE_LIST_BASE_URL=http://127.0.0.1:5555

View file

@ -128,7 +128,7 @@ describe("E2E: pip coverage", () => {
it(`safe-chain blocks installation of malicious Python packages`, async () => {
const shell = await container.openShell("zsh");
const result = await shell.runCommand(
"pip3 install --break-system-packages safe-chain-pi-test"
"pip3 install --break-system-packages numpy==2.4.4 --safe-chain-logging=verbose"
);
assert.ok(
@ -136,7 +136,7 @@ describe("E2E: pip coverage", () => {
`Output did not include expected text. Output was:\n${result.output}`
);
assert.ok(
result.output.includes("safe_chain_pi_test@0.0.1"),
result.output.includes("numpy@2.4.4"),
`Output did not include expected text. Output was:\n${result.output}`
);
assert.ok(
@ -146,7 +146,7 @@ describe("E2E: pip coverage", () => {
const listResult = await shell.runCommand("pip3 list");
assert.ok(
!listResult.output.includes("safe-chain-pi-test"),
!listResult.output.includes("numpy"),
`Malicious package was installed despite safe-chain protection. Output of 'pip3 list' was:\n${listResult.output}`
);
});

View file

@ -41,7 +41,7 @@ describe("E2E: pipx coverage", () => {
const shell = await container.openShell("zsh");
const result = await shell.runCommand(
"pipx install safe-chain-pi-test"
"pipx install numpy==2.4.4"
);
assert.ok(
@ -86,7 +86,7 @@ describe("E2E: pipx coverage", () => {
const shell = await container.openShell("zsh");
const result = await shell.runCommand(
"pipx run safe-chain-pi-test --version"
"pipx run numpy==2.4.4 --version"
);
assert.ok(
@ -122,7 +122,7 @@ describe("E2E: pipx coverage", () => {
await shell.runCommand("pipx install ruff");
const result = await shell.runCommand(
"pipx runpip ruff install safe-chain-pi-test"
"pipx runpip ruff install numpy==2.4.4"
);
assert.ok(
@ -185,7 +185,7 @@ describe("E2E: pipx coverage", () => {
await shell.runCommand("pipx install ruff --safe-chain-logging=verbose");
const result = await shell.runCommand(
"pipx inject ruff safe-chain-pi-test --safe-chain-logging=verbose"
"pipx inject ruff numpy==2.4.4 --safe-chain-logging=verbose"
);
assert.ok(

View file

@ -70,7 +70,7 @@ describe("E2E: poetry coverage", () => {
await shell.runCommand("cd /tmp/test-poetry-malware && poetry init --no-interaction");
const result = await shell.runCommand(
"cd /tmp/test-poetry-malware && poetry add safe-chain-pi-test"
"cd /tmp/test-poetry-malware && poetry add numpy==2.4.4"
);
assert.ok(
@ -300,7 +300,7 @@ describe("E2E: poetry coverage", () => {
// Add malware package - this will create lock file and attempt download
const result = await shell.runCommand(
"cd /tmp/test-poetry-install-malware && poetry add safe-chain-pi-test 2>&1"
"cd /tmp/test-poetry-install-malware && poetry add numpy==2.4.4 2>&1"
);
assert.ok(
@ -324,7 +324,7 @@ describe("E2E: poetry coverage", () => {
// Now try to add malware via add command
const result = await shell.runCommand(
"cd /tmp/test-poetry-update-add && poetry add safe-chain-pi-test 2>&1"
"cd /tmp/test-poetry-update-add && poetry add numpy==2.4.4 2>&1"
);
assert.ok(
@ -345,7 +345,7 @@ describe("E2E: poetry coverage", () => {
// Try to add malware directly - this is the primary vector
const result = await shell.runCommand(
"cd /tmp/test-poetry-req-malware && poetry add safe-chain-pi-test requests 2>&1"
"cd /tmp/test-poetry-req-malware && poetry add numpy==2.4.4 requests 2>&1"
);
assert.ok(

148
test/e2e/rush.e2e.spec.js Normal file
View file

@ -0,0 +1,148 @@
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;
/** @type {{ rushVersion: string, pnpmVersion: string } | undefined} */
let resolvedVersions;
before(async () => {
DockerTestContainer.buildImage();
});
beforeEach(async () => {
container = new DockerTestContainer();
await container.start();
const installationShell = await container.openShell("zsh");
await installationShell.runCommand("safe-chain setup");
if (!resolvedVersions) {
resolvedVersions = await resolveRushVersions(installationShell);
}
await setupRushWorkspace(installationShell, { resolvedVersions });
});
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}`
);
});
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"
}
}`,
});
// `--safe-chain-skip-minimum-package-age` is needed because Rush's
// internal pnpm bootstrap (`npm install pnpm@<resolvedVersion>`) 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 --safe-chain-skip-minimum-package-age"
);
assert.match(
result.output,
/blocked \d+ 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, { 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 ??
`{
"name": "test-app",
"version": "1.0.0"
}`
);
}

100
test/e2e/rushx.e2e.spec.js Normal file
View file

@ -0,0 +1,100 @@
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();
});
beforeEach(async () => {
container = new DockerTestContainer();
await container.start();
const installationShell = await container.openShell("zsh");
await installationShell.runCommand("safe-chain setup");
if (!resolvedVersions) {
resolvedVersions = await resolveRushVersions(installationShell);
}
await setupRushWorkspace(installationShell, { resolvedVersions });
});
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.match(
result.output,
/blocked \d+ 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, { 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"
}
}`
);
}

View file

@ -97,7 +97,7 @@ describe("E2E: safe-chain CLI python/pip support", () => {
await shell.runCommand("pip3 cache purge");
const result = await shell.runCommand(
"safe-chain pip3 install --break-system-packages safe-chain-pi-test"
"safe-chain pip3 install --break-system-packages numpy==2.4.4"
);
assert.ok(

View file

@ -0,0 +1,79 @@
// Test-only mirror of the malware list. Injects known-safe packages as malicious
// to simulate blocking behavior in e2e tests without affecting real data.
import * as http from "node:http";
const lists = await downloadLists();
const server = http.createServer(handleRequest);
server.listen(5555, "127.0.0.1");
console.log("listening on http://127.0.0.1:5555");
function handleRequest(req, res) {
if (req.method !== "GET" || !req.url) {
res.writeHead(404);
res.end();
return;
}
if (req.url.startsWith("/ready")) {
res.writeHead(200);
res.end();
return;
}
for (const list of lists) {
if (req.url.startsWith(list.path)) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(list.data));
return;
}
}
res.writeHead(404);
res.end();
}
async function downloadLists() {
const lists = [
{
"path": "/malware_predictions.json",
"patchFunc": (data) => data,
},
{
"path": "/malware_pypi.json",
"patchFunc": patchPypi,
},
{
"path": "/releases/npm.json",
"patchFunc": (data) => data,
},
{
"path": "/releases/pypi.json",
"patchFunc": (data) => data,
},
]
for (const list of lists) {
list.data = list.patchFunc(await downloadList(list.path));
}
return lists;
}
async function downloadList(path) {
const baseUrl = "https://malware-list.aikido.dev";
const url = `${baseUrl}${path}`;
const response = await fetch(url);
return await response.json();
}
function patchPypi(data) {
data.push({
"package_name": "numpy",
"version": "2.4.4",
"reason": "MALWARE"
});
return data;
}

View file

@ -0,0 +1,69 @@
// 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 read both back from the binaries baked into the image.
//
// * `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) {
// 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. */
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 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 ${command} version. Output was:\n${output}`
);
}
return match[1];
}

View file

@ -126,7 +126,7 @@ describe("E2E: uv coverage", () => {
const shell = await container.openShell("zsh");
const result = await shell.runCommand(
"uv pip install --system --break-system-packages safe-chain-pi-test"
"uv pip install --system --break-system-packages numpy==2.4.4"
);
assert.ok(
@ -134,7 +134,7 @@ describe("E2E: uv coverage", () => {
`Output did not include expected text. Output was:\n${result.output}`
);
assert.ok(
result.output.includes("safe_chain_pi_test@0.0.1"),
result.output.includes("numpy@2.4.4"),
`Output did not include expected text. Output was:\n${result.output}`
);
assert.ok(
@ -144,7 +144,7 @@ describe("E2E: uv coverage", () => {
const listResult = await shell.runCommand("uv pip list --system");
assert.ok(
!listResult.output.includes("safe-chain-pi-test"),
!listResult.output.includes("numpy"),
`Malicious package was installed despite safe-chain protection. Output of 'uv pip list' was:\n${listResult.output}`
);
});
@ -413,7 +413,7 @@ describe("E2E: uv coverage", () => {
await shell.runCommand("uv init test-project-malware");
const result = await shell.runCommand(
"cd test-project-malware && uv add safe-chain-pi-test"
"cd test-project-malware && uv add numpy==2.4.4"
);
assert.ok(
@ -421,7 +421,7 @@ describe("E2E: uv coverage", () => {
`Output did not include expected text. Output was:\n${result.output}`
);
assert.ok(
result.output.includes("safe_chain_pi_test@0.0.1"),
result.output.includes("numpy@2.4.4"),
`Output did not include expected text. Output was:\n${result.output}`
);
assert.ok(
@ -445,14 +445,14 @@ describe("E2E: uv coverage", () => {
it(`safe-chain blocks malicious packages via uv tool install`, async () => {
const shell = await container.openShell("zsh");
const result = await shell.runCommand("uv tool install safe-chain-pi-test");
const result = await shell.runCommand("uv tool install numpy==2.4.4");
assert.ok(
result.output.includes("blocked 1 malicious package downloads:"),
`Output did not include expected text. Output was:\n${result.output}`
);
assert.ok(
result.output.includes("safe_chain_pi_test@0.0.1"),
result.output.includes("numpy@2.4.4"),
`Output did not include expected text. Output was:\n${result.output}`
);
});
@ -482,7 +482,7 @@ describe("E2E: uv coverage", () => {
await shell.runCommand("echo 'print(\"test\")' > test_script2.py");
const result = await shell.runCommand(
"uv run --with safe-chain-pi-test test_script2.py"
"uv run --with numpy==2.4.4 test_script2.py"
);
assert.ok(

View file

@ -44,7 +44,7 @@ describe("E2E: uvx coverage", () => {
const shell = await container.openShell("zsh");
const result = await shell.runCommand(
"uvx safe-chain-pi-test"
"uvx numpy==2.4.4"
);
assert.ok(
@ -74,7 +74,7 @@ describe("E2E: uvx coverage", () => {
const shell = await container.openShell("zsh");
const result = await shell.runCommand(
"uvx --from safe-chain-pi-test some-command"
"uvx --from numpy==2.4.4 some-command"
);
assert.ok(
@ -117,7 +117,7 @@ describe("E2E: uvx coverage", () => {
const shell = await container.openShell("zsh");
const result = await shell.runCommand(
"uvx --with safe-chain-pi-test ruff --version"
"uvx --with numpy==2.4.4 ruff --version"
);
assert.ok(