From 293089462430fe807f9cacfd207d4f85a51e1fc2 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 21 Apr 2026 09:26:07 +0200 Subject: [PATCH 01/15] Fix concurrency bug leading to multiple fetches of the malware database --- .../src/scanning/malwareDatabase.js | 72 +++++++++---------- .../src/scanning/newPackagesListCache.js | 34 ++++----- 2 files changed, 51 insertions(+), 55 deletions(-) diff --git a/packages/safe-chain/src/scanning/malwareDatabase.js b/packages/safe-chain/src/scanning/malwareDatabase.js index 4aba43c..afc8b98 100644 --- a/packages/safe-chain/src/scanning/malwareDatabase.js +++ b/packages/safe-chain/src/scanning/malwareDatabase.js @@ -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 | null} */ +let cachedMalwareDatabasePromise = null; /** * Normalize package name for comparison. @@ -34,45 +38,41 @@ function normalizePackageName(name) { return name; } -export async function openMalwareDatabase() { - if (cachedMalwareDatabase) { - return cachedMalwareDatabase; - } +export function openMalwareDatabase() { + if (!cachedMalwareDatabasePromise) { + cachedMalwareDatabasePromise = getMalwareDatabase().then((malwareDatabase) => { + /** + * @param {string} name + * @param {string} version + * @returns {string} + */ + function getPackageStatus(name, version) { + const normalizedName = normalizePackageName(name); + const packageData = malwareDatabase.find( + (pkg) => { + const normalizedPkgName = normalizePackageName(pkg.package_name); + return normalizedPkgName === normalizedName && + (pkg.version === version || pkg.version === "*"); + } + ); - const malwareDatabase = await getMalwareDatabase(); + if (!packageData) { + return MALWARE_STATUS_OK; + } - /** - * @param {string} name - * @param {string} version - * @returns {string} - */ - function getPackageStatus(name, version) { - const normalizedName = normalizePackageName(name); - const packageData = malwareDatabase.find( - (pkg) => { - const normalizedPkgName = normalizePackageName(pkg.package_name); - return normalizedPkgName === normalizedName && - (pkg.version === version || pkg.version === "*"); + return packageData.reason; } - ); - if (!packageData) { - return MALWARE_STATUS_OK; - } - - return packageData.reason; + return { + getPackageStatus, + isMalware: (name, version) => { + const status = getPackageStatus(name, version); + return isMalwareStatus(status); + }, + }; + }); } - - // This implicitly caches the malware database - // that's closed over by the getPackageStatus function - cachedMalwareDatabase = { - getPackageStatus, - isMalware: (name, version) => { - const status = getPackageStatus(name, version); - return isMalwareStatus(status); - }, - }; - return cachedMalwareDatabase; + return cachedMalwareDatabasePromise; } /** diff --git a/packages/safe-chain/src/scanning/newPackagesListCache.js b/packages/safe-chain/src/scanning/newPackagesListCache.js index dfac247..b6c990e 100644 --- a/packages/safe-chain/src/scanning/newPackagesListCache.js +++ b/packages/safe-chain/src/scanning/newPackagesListCache.js @@ -16,30 +16,26 @@ 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 | null} */ +let cachedNewPackagesDatabasePromise = null; /** * @returns {Promise} */ -export async function openNewPackagesDatabase() { - if (cachedNewPackagesDatabase) { - return cachedNewPackagesDatabase; +export function openNewPackagesDatabase() { + if (!cachedNewPackagesDatabasePromise) { + cachedNewPackagesDatabasePromise = getNewPackagesList() + .then((newPackagesList) => buildNewPackagesDatabase(newPackagesList)) + .catch((/** @type {any} */ error) => { + warnOnceAboutUnavailableDatabase(error); + return { isNewlyReleasedPackage: () => false }; + }); } - - /** @type {import("../api/aikido.js").NewPackageEntry[]} */ - let newPackagesList; - - try { - newPackagesList = await getNewPackagesList(); - } catch (/** @type {any} */ error) { - warnOnceAboutUnavailableDatabase(error); - cachedNewPackagesDatabase = { isNewlyReleasedPackage: () => false }; - return cachedNewPackagesDatabase; - } - - cachedNewPackagesDatabase = buildNewPackagesDatabase(newPackagesList); - return cachedNewPackagesDatabase; + return cachedNewPackagesDatabasePromise; } /** From 9fae225277b769824d74125f6973c4b871b894fa Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 21 Apr 2026 09:31:26 +0200 Subject: [PATCH 02/15] Make sure rejected promise is not cached in malware list / new packages cache --- packages/safe-chain/src/scanning/malwareDatabase.js | 5 ++++- packages/safe-chain/src/scanning/newPackagesListCache.js | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/safe-chain/src/scanning/malwareDatabase.js b/packages/safe-chain/src/scanning/malwareDatabase.js index afc8b98..0eccc88 100644 --- a/packages/safe-chain/src/scanning/malwareDatabase.js +++ b/packages/safe-chain/src/scanning/malwareDatabase.js @@ -65,11 +65,14 @@ export function openMalwareDatabase() { return { getPackageStatus, - isMalware: (name, version) => { + isMalware: (/** @type {string} */ name, /** @type {string} */ version) => { const status = getPackageStatus(name, version); return isMalwareStatus(status); }, }; + }).catch((error) => { + cachedMalwareDatabasePromise = null; + throw error; }); } return cachedMalwareDatabasePromise; diff --git a/packages/safe-chain/src/scanning/newPackagesListCache.js b/packages/safe-chain/src/scanning/newPackagesListCache.js index b6c990e..418dbdd 100644 --- a/packages/safe-chain/src/scanning/newPackagesListCache.js +++ b/packages/safe-chain/src/scanning/newPackagesListCache.js @@ -32,6 +32,7 @@ export function openNewPackagesDatabase() { .then((newPackagesList) => buildNewPackagesDatabase(newPackagesList)) .catch((/** @type {any} */ error) => { warnOnceAboutUnavailableDatabase(error); + cachedNewPackagesDatabasePromise = null; return { isNewlyReleasedPackage: () => false }; }); } From d81b0f521497c865503c03dd0fee4c338b797f58 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 23 Apr 2026 10:32:04 -0700 Subject: [PATCH 03/15] 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 51b5cac..2c83a17 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.20/EndpointProtection.pkg" -DOWNLOAD_SHA256="def6c01caac6a4ce93eb68157a5a6b81028c9203fa13a0f5c539cceb92cc7e7b" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.21/EndpointProtection.pkg" +DOWNLOAD_SHA256="2a6abef9a6c16b28f792226c5c4facfaca806920ec6d4d1edf43b40d083b18ad" 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 f85d8ce..bea7722 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.20/EndpointProtection.msi" -$DownloadSha256 = "46fe377a4ce6204e1cc4a031e80f92f85cb8e1ef6b9690b542438c0870937be3" +$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.21/EndpointProtection.msi" +$DownloadSha256 = "a59005b5f9e14694e27fd92396d5e438525b396acdd6e931aeccec44d1e3b44b" # Ensure TLS 1.2 is enabled for downloads [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 From 0a230eb64c033a7a62b7be181476d4c06adbcc34 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 24 Apr 2026 12:04:31 +0200 Subject: [PATCH 04/15] Update endpoint uninstall script location --- install-scripts/uninstall-endpoint-mac.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install-scripts/uninstall-endpoint-mac.sh b/install-scripts/uninstall-endpoint-mac.sh index 6da0f17..bd3b0e7 100755 --- a/install-scripts/uninstall-endpoint-mac.sh +++ b/install-scripts/uninstall-endpoint-mac.sh @@ -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' From e8fb134136bc55d7d7fb3df4ac9414974ac08403 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 24 Apr 2026 17:12:48 +0200 Subject: [PATCH 05/15] Endpoint 1.2.22 --- 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 2c83a17..427b39a 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.21/EndpointProtection.pkg" -DOWNLOAD_SHA256="2a6abef9a6c16b28f792226c5c4facfaca806920ec6d4d1edf43b40d083b18ad" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.22/EndpointProtection.pkg" +DOWNLOAD_SHA256="64dfb91230918bf0714c3e7230422c0460f0e7ec64b6d8d0f616987eb2df5805" 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 bea7722..7f69f39 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.21/EndpointProtection.msi" -$DownloadSha256 = "a59005b5f9e14694e27fd92396d5e438525b396acdd6e931aeccec44d1e3b44b" +$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.22/EndpointProtection.msi" +$DownloadSha256 = "a4d3bf839484b4d6ab87f9d47bfd303d5442aa5e213c9061daf305717a1e8dd1" # Ensure TLS 1.2 is enabled for downloads [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 From d04db58a5ee591ca07e2714971919e432352a184 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Sun, 26 Apr 2026 17:19:34 -0700 Subject: [PATCH 06/15] Bump Endpoint Version to 1.2.23 --- 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 427b39a..02df48b 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.22/EndpointProtection.pkg" -DOWNLOAD_SHA256="64dfb91230918bf0714c3e7230422c0460f0e7ec64b6d8d0f616987eb2df5805" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.23/EndpointProtection.pkg" +DOWNLOAD_SHA256="9af1e0f72e53516c888ade1753ed03f087c1def89244eb0afb60e1f11e8e87e2" 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 7f69f39..437264e 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.22/EndpointProtection.msi" -$DownloadSha256 = "a4d3bf839484b4d6ab87f9d47bfd303d5442aa5e213c9061daf305717a1e8dd1" +$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.23/EndpointProtection.msi" +$DownloadSha256 = "3327d35db6654d12dbd7c5ccec0645edb0277f71dcd993ba9733e266bbd235f8" # Ensure TLS 1.2 is enabled for downloads [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 From ae40140199321567b6ad572a59ba32d2fe8a40c6 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 27 Apr 2026 12:51:31 -0700 Subject: [PATCH 07/15] Fix Bitbucket Pipelines Example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f041983..394b835 100644 --- a/README.md +++ b/README.md @@ -471,7 +471,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 ``` From 6abad2d37f815103793a9503f33f72247c4cc4f1 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Tue, 28 Apr 2026 08:50:54 +0200 Subject: [PATCH 08/15] Enhance Aikido Endpoint link with UTM parameters Updated the Aikido Endpoint link to include UTM parameters for tracking. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f041983..c5e1d5e 100644 --- a/README.md +++ b/README.md @@ -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). From ebebe6d6c1e51f6e4552d7f448655d1568982b98 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 28 Apr 2026 14:47:49 +0200 Subject: [PATCH 09/15] Mirror malware list in e2e tests to mock malware in a harmless way --- test/e2e/DockerTestContainer.js | 11 ++- test/e2e/Dockerfile | 2 + test/e2e/pip.e2e.spec.js | 6 +- test/e2e/pipx.e2e.spec.js | 8 +-- test/e2e/poetry.e2e.spec.js | 8 +-- test/e2e/safe-chain-cli-python.e2e.spec.js | 2 +- test/e2e/utils/malwarelistmirror.mjs | 79 ++++++++++++++++++++++ test/e2e/uv.e2e.spec.js | 16 ++--- test/e2e/uvx.e2e.spec.js | 6 +- 9 files changed, 114 insertions(+), 24 deletions(-) create mode 100644 test/e2e/utils/malwarelistmirror.mjs diff --git a/test/e2e/DockerTestContainer.js b/test/e2e/DockerTestContainer.js index cd48c4e..543b1a3 100644 --- a/test/e2e/DockerTestContainer.js +++ b/test/e2e/DockerTestContainer.js @@ -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); diff --git a/test/e2e/Dockerfile b/test/e2e/Dockerfile index bc7ffc2..3de600c 100644 --- a/test/e2e/Dockerfile +++ b/test/e2e/Dockerfile @@ -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 diff --git a/test/e2e/pip.e2e.spec.js b/test/e2e/pip.e2e.spec.js index b06978f..af979dc 100644 --- a/test/e2e/pip.e2e.spec.js +++ b/test/e2e/pip.e2e.spec.js @@ -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}` ); }); diff --git a/test/e2e/pipx.e2e.spec.js b/test/e2e/pipx.e2e.spec.js index a554aa6..332709d 100644 --- a/test/e2e/pipx.e2e.spec.js +++ b/test/e2e/pipx.e2e.spec.js @@ -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( diff --git a/test/e2e/poetry.e2e.spec.js b/test/e2e/poetry.e2e.spec.js index 58b74fd..7d77d9c 100644 --- a/test/e2e/poetry.e2e.spec.js +++ b/test/e2e/poetry.e2e.spec.js @@ -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( diff --git a/test/e2e/safe-chain-cli-python.e2e.spec.js b/test/e2e/safe-chain-cli-python.e2e.spec.js index 15dbf94..cf3fda2 100644 --- a/test/e2e/safe-chain-cli-python.e2e.spec.js +++ b/test/e2e/safe-chain-cli-python.e2e.spec.js @@ -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( diff --git a/test/e2e/utils/malwarelistmirror.mjs b/test/e2e/utils/malwarelistmirror.mjs new file mode 100644 index 0000000..e8091b0 --- /dev/null +++ b/test/e2e/utils/malwarelistmirror.mjs @@ -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; +} diff --git a/test/e2e/uv.e2e.spec.js b/test/e2e/uv.e2e.spec.js index 9d5f3b9..5536e22 100644 --- a/test/e2e/uv.e2e.spec.js +++ b/test/e2e/uv.e2e.spec.js @@ -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( diff --git a/test/e2e/uvx.e2e.spec.js b/test/e2e/uvx.e2e.spec.js index 12dfc0f..61fb924 100644 --- a/test/e2e/uvx.e2e.spec.js +++ b/test/e2e/uvx.e2e.spec.js @@ -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( From d0fc643f23923de97c23c7ff04fecb829d02729c Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 29 Apr 2026 12:50:17 +0200 Subject: [PATCH 10/15] 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 11/15] 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 12/15] 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 13/15] 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 14/15] 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 15/15] 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