Merge branch 'main' into feature/add-rush-monorepo-support

This commit is contained in:
James McMeeking 2026-05-01 16:49:49 +01:00
commit 5cf2ffe201
No known key found for this signature in database
GPG key ID: C69A11061EE15228
20 changed files with 404 additions and 92 deletions

View file

@ -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

View file

@ -12,7 +12,7 @@
## Need protection beyond npm & PyPI?
[Aikido Endpoint](https://www.aikido.dev/protect/endpoint-protection) builds on Safe Chain, extending package and extension security across more ecosystems: **npm**, **PyPI**, **Maven**, **NuGet**, **VS Code**, **Open VSX** - (Cursor, Windsurf, Kiro, Vs Codium, ...), **Chrome extensions**, **Skills.sh AI skills** and more.
[Aikido Endpoint](https://www.aikido.dev/protect/endpoint-protection?utm_source=github.com&utm_medium=referral&utm_campaign=safechain) builds on Safe Chain, extending package and extension security across more ecosystems: **npm**, **PyPI**, **Maven**, **NuGet**, **VS Code**, **Open VSX** - (Cursor, Windsurf, Kiro, Vs Codium, ...), **Chrome extensions**, **Skills.sh AI skills** and more.
Get centralized policy management, request-and-approval workflows, and visibility across every developer workstation in your org. Powered by the same Aikido Intel feed. Deploy it manually or manage it through your MDM tool (Jamf, Fleet, or Iru).
@ -291,6 +291,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.
@ -472,7 +478,7 @@ steps:
name: Install
script:
- curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
- export PATH=~/.safe-chain/shims:$PATH
- export PATH=~/.safe-chain/shims:~/.safe-chain/bin:$PATH
- npm ci
```

View file

@ -7,8 +7,8 @@
set -e # Exit on error
# Configuration
INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.20/EndpointProtection.pkg"
DOWNLOAD_SHA256="def6c01caac6a4ce93eb68157a5a6b81028c9203fa13a0f5c539cceb92cc7e7b"
INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.3.3/EndpointProtection.pkg"
DOWNLOAD_SHA256="a025d33ca493a3b7b77c9515fe7f0b2c1f2dd18fb3e60e08549499cafee6f250"
TOKEN_FILE="/tmp/aikido_endpoint_token.txt"
# Colors for output

View file

@ -7,8 +7,8 @@ param(
)
# Configuration
$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.20/EndpointProtection.msi"
$DownloadSha256 = "46fe377a4ce6204e1cc4a031e80f92f85cb8e1ef6b9690b542438c0870937be3"
$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.3.1/EndpointProtection.msi"
$DownloadSha256 = "6d72170cfd2090c6af8e111a625fa3961f9dc345495117db4f1d7c518d537076"
# Ensure TLS 1.2 is enabled for downloads
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

View file

@ -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 {

View file

@ -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"

View file

@ -7,7 +7,7 @@
set -e # Exit on error
# Configuration
UNINSTALL_SCRIPT="/Library/Application Support/AikidoSecurity/EndpointProtection/scripts/uninstall"
UNINSTALL_SCRIPT="/Applications/Aikido Endpoint Protection.app/Contents/Resources/scripts/uninstall"
# Colors for output
RED='\033[0;31m'

View file

@ -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;

View file

@ -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();
});
});
}
});
});

View file

@ -15,8 +15,12 @@ import { getEcoSystem, ECOSYSTEM_PY } from "../config/settings.js";
* @property {function(string, string): boolean} isMalware
*/
/** @type {MalwareDatabase | null} */
let cachedMalwareDatabase = null;
// Caching the Promise (rather than the resolved database) prevents duplicate fetches. If we cached the resolved
// value, multiple callers could pass the null-check before the first fetch completes (because each `await` yields
// control back to the event loop, allowing other callers to run). Since the Promise assignment is synchronous, all
// concurrent callers see it immediately and share a single fetch.
/** @type {Promise<MalwareDatabase> | null} */
let cachedMalwareDatabasePromise = null;
/**
* Normalize package name for comparison.
@ -34,13 +38,9 @@ function normalizePackageName(name) {
return name;
}
export async function openMalwareDatabase() {
if (cachedMalwareDatabase) {
return cachedMalwareDatabase;
}
const malwareDatabase = await getMalwareDatabase();
export function openMalwareDatabase() {
if (!cachedMalwareDatabasePromise) {
cachedMalwareDatabasePromise = getMalwareDatabase().then((malwareDatabase) => {
/**
* @param {string} name
* @param {string} version
@ -63,16 +63,19 @@ export async function openMalwareDatabase() {
return packageData.reason;
}
// This implicitly caches the malware database
// that's closed over by the getPackageStatus function
cachedMalwareDatabase = {
return {
getPackageStatus,
isMalware: (name, version) => {
isMalware: (/** @type {string} */ name, /** @type {string} */ version) => {
const status = getPackageStatus(name, version);
return isMalwareStatus(status);
},
};
return cachedMalwareDatabase;
}).catch((error) => {
cachedMalwareDatabasePromise = null;
throw error;
});
}
return cachedMalwareDatabasePromise;
}
/**

View file

@ -16,30 +16,27 @@ import { warnOnceAboutUnavailableDatabase } from "./newPackagesDatabaseWarnings.
*/
// Shared per-process cache to avoid rebuilding the same feed-backed database on each request.
/** @type {NewPackagesDatabase | null} */
let cachedNewPackagesDatabase = null;
// Caching the Promise (rather than the resolved database) prevents duplicate fetches. If we cached the resolved
// value, multiple callers could pass the null-check before the first fetch completes (because each `await` yields
// control back to the event loop, allowing other callers to run). Since the Promise assignment is synchronous, all
// concurrent callers see it immediately and share a single fetch.
/** @type {Promise<NewPackagesDatabase> | null} */
let cachedNewPackagesDatabasePromise = null;
/**
* @returns {Promise<NewPackagesDatabase>}
*/
export async function openNewPackagesDatabase() {
if (cachedNewPackagesDatabase) {
return cachedNewPackagesDatabase;
}
/** @type {import("../api/aikido.js").NewPackageEntry[]} */
let newPackagesList;
try {
newPackagesList = await getNewPackagesList();
} catch (/** @type {any} */ error) {
export function openNewPackagesDatabase() {
if (!cachedNewPackagesDatabasePromise) {
cachedNewPackagesDatabasePromise = getNewPackagesList()
.then((newPackagesList) => buildNewPackagesDatabase(newPackagesList))
.catch((/** @type {any} */ error) => {
warnOnceAboutUnavailableDatabase(error);
cachedNewPackagesDatabase = { isNewlyReleasedPackage: () => false };
return cachedNewPackagesDatabase;
cachedNewPackagesDatabasePromise = null;
return { isNewlyReleasedPackage: () => false };
});
}
cachedNewPackagesDatabase = buildNewPackagesDatabase(newPackagesList);
return cachedNewPackagesDatabase;
return cachedNewPackagesDatabasePromise;
}
/**

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

@ -84,3 +84,5 @@ RUN npm install -g /pkgs/*.tgz
WORKDIR /testapp
RUN npm init -y
COPY test/e2e/utils/malwarelistmirror.mjs /utils/malwarelistmirror.mjs
ENV SAFE_CHAIN_MALWARE_LIST_BASE_URL=http://127.0.0.1:5555

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(

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

@ -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(