From 833fa285aa84c849cbc4b06badbf1996e651ba85 Mon Sep 17 00:00:00 2001 From: galargh Date: Wed, 10 Dec 2025 13:27:18 +0100 Subject: [PATCH 001/360] feat: allow python custom registries configuration --- README.md | 19 ++ .../src/config/environmentVariables.js | 8 + packages/safe-chain/src/config/settings.js | 27 +++ .../interceptors/pipInterceptor.js | 9 +- ...pipInterceptor.pipCustomRegistries.spec.js | 199 ++++++++++++++++++ 5 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.pipCustomRegistries.spec.js diff --git a/README.md b/README.md index def262f..702f8bf 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,25 @@ You can set the minimum package age through multiple sources (in order of priori } ``` +## Custom Registries + +By default, Safe Chain monitors downloads from the official package registries (npm registry, PyPI, etc.). If you use a private or custom package registry, you can configure Safe Chain to also monitor downloads from those registries. + +⚠️ This feature **currently only applies to Python package managers** (pip, pip3, uv, poetry) and does not apply to npm-based package managers. + +### Configuration Options + +You can set custom registries through the following source: + +1. **Environment Variable**: + + ```shell + export SAFE_CHAIN_PIP_CUSTOM_REGISTRIES=my-custom-registry.example.com,private-pypi.internal.com + pip install mypackage + ``` + + Use a comma-separated list of registry hostnames to monitor multiple custom registries. + # Usage in CI/CD You can protect your CI/CD pipelines from malicious packages by integrating Aikido Safe Chain into your build process. This ensures that any packages installed during your automated builds are checked for malware before installation. diff --git a/packages/safe-chain/src/config/environmentVariables.js b/packages/safe-chain/src/config/environmentVariables.js index 5c6056a..fe54732 100644 --- a/packages/safe-chain/src/config/environmentVariables.js +++ b/packages/safe-chain/src/config/environmentVariables.js @@ -5,3 +5,11 @@ export function getMinimumPackageAgeHours() { return process.env.SAFE_CHAIN_MINIMUM_PACKAGE_AGE_HOURS; } + +/** + * Gets the custom pip registries from environment variable + * @returns {string | undefined} + */ +export function getPipCustomRegistries() { + return process.env.SAFE_CHAIN_PIP_CUSTOM_REGISTRIES; +} diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index 7c20358..0480709 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -98,3 +98,30 @@ export function skipMinimumPackageAge() { return defaultSkipMinimumPackageAge; } + +/** @type {string[]} */ +const defaultPipCustomRegistries = []; +/** @returns {string[]} */ +export function getPipCustomRegistries() { + // Priority 1: Environment variable + const envValue = validatePipCustomRegistries( + environmentVariables.getPipCustomRegistries() + ); + if (envValue !== undefined) { + return envValue; + } + + return defaultPipCustomRegistries; +} + +/** + * @param {string | undefined} value + * @returns {string[] | undefined} + */ +function validatePipCustomRegistries(value) { + if (!value) { + return undefined; + } + + return value.split(","); +} diff --git a/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js index 9a122a6..e781e30 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js @@ -1,3 +1,4 @@ +import { getPipCustomRegistries } from "../../config/settings.js"; import { isMalwarePackage } from "../../scanning/audit/index.js"; import { interceptRequests } from "./interceptorBuilder.js"; @@ -13,7 +14,9 @@ const knownPipRegistries = [ * @returns {import("./interceptorBuilder.js").Interceptor | undefined} */ export function pipInterceptorForUrl(url) { - const registry = knownPipRegistries.find((reg) => url.includes(reg)); + const customRegistries = getPipCustomRegistries(); + const registries = [...knownPipRegistries, ...customRegistries]; + const registry = registries.find((reg) => url.includes(reg)); if (registry) { return buildPipInterceptor(registry); @@ -37,8 +40,8 @@ function buildPipInterceptor(registry) { // Per python, packages that differ only by hyphen vs underscore are considered the same. const hyphenName = packageName?.includes("_") ? packageName.replace(/_/g, "-") : packageName; - const isMalicious = - await isMalwarePackage(packageName, version) + const isMalicious = + await isMalwarePackage(packageName, version) || await isMalwarePackage(hyphenName, version); if (isMalicious) { diff --git a/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.pipCustomRegistries.spec.js b/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.pipCustomRegistries.spec.js new file mode 100644 index 0000000..fc9c91e --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.pipCustomRegistries.spec.js @@ -0,0 +1,199 @@ +import { describe, it, mock } from "node:test"; +import assert from "node:assert"; + +describe("pipInterceptor custom registries", async () => { + let lastPackage; + let malwareResponse = false; + let customRegistries = []; + + mock.module("../../config/settings.js", { + namedExports: { + getPipCustomRegistries: () => customRegistries, + }, + }); + + mock.module("../../scanning/audit/index.js", { + namedExports: { + isMalwarePackage: async (packageName, version) => { + lastPackage = { packageName, version }; + return malwareResponse; + }, + }, + }); + + const { pipInterceptorForUrl } = await import("./pipInterceptor.js"); + + it("should create interceptor for custom registry", () => { + customRegistries = ["my-custom-registry.example.com"]; + const url = + "https://my-custom-registry.example.com/packages/xx/yy/foobar-1.2.3.tar.gz"; + + const interceptor = pipInterceptorForUrl(url); + + assert.ok( + interceptor, + "Interceptor should be created for custom registry" + ); + }); + + it("should parse package from custom registry URL", async () => { + customRegistries = ["my-custom-registry.example.com"]; + const url = + "https://my-custom-registry.example.com/packages/xx/yy/foobar-1.2.3.tar.gz"; + + const interceptor = pipInterceptorForUrl(url); + assert.ok(interceptor, "Interceptor should be created"); + + await interceptor.handleRequest(url); + + assert.deepEqual(lastPackage, { + packageName: "foobar", + version: "1.2.3", + }); + }); + + it("should parse wheel package from custom registry URL", async () => { + customRegistries = ["private-pypi.internal.com"]; + const url = + "https://private-pypi.internal.com/packages/foo_bar-2.0.0-py3-none-any.whl"; + + const interceptor = pipInterceptorForUrl(url); + assert.ok(interceptor, "Interceptor should be created"); + + await interceptor.handleRequest(url); + + assert.deepEqual(lastPackage, { + packageName: "foo-bar", + version: "2.0.0", + }); + }); + + it("should handle multiple custom registries", async () => { + customRegistries = [ + "registry-one.example.com", + "registry-two.example.com", + ]; + + const url1 = + "https://registry-one.example.com/packages/package1-1.0.0.tar.gz"; + const url2 = + "https://registry-two.example.com/packages/package2-2.0.0.tar.gz"; + + const interceptor1 = pipInterceptorForUrl(url1); + const interceptor2 = pipInterceptorForUrl(url2); + + assert.ok(interceptor1, "Interceptor should be created for first registry"); + assert.ok( + interceptor2, + "Interceptor should be created for second registry" + ); + }); + + it("should block malicious package from custom registry", async () => { + customRegistries = ["my-custom-registry.example.com"]; + malwareResponse = true; + + const url = + "https://my-custom-registry.example.com/packages/malicious_package-1.0.0.tar.gz"; + + const interceptor = pipInterceptorForUrl(url); + assert.ok(interceptor, "Interceptor should be created"); + + const result = await interceptor.handleRequest(url); + + assert.ok(result.blockResponse, "Should contain a blockResponse"); + assert.equal( + result.blockResponse.statusCode, + 403, + "Block response should have status code 403" + ); + assert.equal( + result.blockResponse.message, + "Forbidden - blocked by safe-chain", + "Block response should have correct status message" + ); + + malwareResponse = false; + }); + + it("should still work with known registries when custom registries are set", async () => { + customRegistries = ["my-custom-registry.example.com"]; + + const url = + "https://files.pythonhosted.org/packages/xx/yy/foobar-1.2.3.tar.gz"; + + const interceptor = pipInterceptorForUrl(url); + + assert.ok( + interceptor, + "Interceptor should be created for known registry even with custom registries set" + ); + + await interceptor.handleRequest(url); + + assert.deepEqual(lastPackage, { + packageName: "foobar", + version: "1.2.3", + }); + }); + + it("should not create interceptor for unknown registry when custom registries are set", () => { + customRegistries = ["my-custom-registry.example.com"]; + const url = "https://unknown-registry.example.com/packages/foobar-1.0.0.tar.gz"; + + const interceptor = pipInterceptorForUrl(url); + + assert.equal( + interceptor, + undefined, + "Interceptor should be undefined for unknown registry" + ); + }); + + it("should handle empty custom registries array", () => { + customRegistries = []; + const url = + "https://my-custom-registry.example.com/packages/foobar-1.0.0.tar.gz"; + + const interceptor = pipInterceptorForUrl(url); + + assert.equal( + interceptor, + undefined, + "Interceptor should be undefined when no custom registries are configured" + ); + }); + + it("should parse .whl.metadata from custom registry", async () => { + customRegistries = ["private-pypi.internal.com"]; + const url = + "https://private-pypi.internal.com/packages/foo_bar-2.0.0-py3-none-any.whl.metadata"; + + const interceptor = pipInterceptorForUrl(url); + assert.ok(interceptor, "Interceptor should be created"); + + await interceptor.handleRequest(url); + + assert.deepEqual(lastPackage, { + packageName: "foo-bar", + version: "2.0.0", + }); + }); + + it("should parse .tar.gz.metadata from custom registry", async () => { + customRegistries = ["private-pypi.internal.com"]; + const url = + "https://private-pypi.internal.com/packages/foo_bar-2.0.0.tar.gz.metadata"; + + const interceptor = pipInterceptorForUrl(url); + assert.ok(interceptor, "Interceptor should be created"); + + await interceptor.handleRequest(url); + + assert.deepEqual(lastPackage, { + packageName: "foo-bar", + version: "2.0.0", + }); + }); +}); + From dc6fcb97619529debfd52f3586b15bda107ceeca Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 15 Dec 2025 14:42:58 +0100 Subject: [PATCH 002/360] Skeleton --- packages/safe-chain/bin/aikido-pipx.js | 16 + .../pipx/createPipXPackageManager.js | 18 + .../pipx/createPipXPackageManager.spec.js | 14 + .../src/packagemanager/pipx/runPipXCommand.js | 71 +++ .../src/shell-integration/helpers.js | 6 + test/e2e/pipx.e2e.spec.js | 572 ++++++++++++++++++ 6 files changed, 697 insertions(+) create mode 100755 packages/safe-chain/bin/aikido-pipx.js create mode 100644 packages/safe-chain/src/packagemanager/pipx/createPipXPackageManager.js create mode 100644 packages/safe-chain/src/packagemanager/pipx/createPipXPackageManager.spec.js create mode 100644 packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js create mode 100644 test/e2e/pipx.e2e.spec.js diff --git a/packages/safe-chain/bin/aikido-pipx.js b/packages/safe-chain/bin/aikido-pipx.js new file mode 100755 index 0000000..13e78f0 --- /dev/null +++ b/packages/safe-chain/bin/aikido-pipx.js @@ -0,0 +1,16 @@ +#!/usr/bin/env node + +import { main } from "../src/main.js"; +import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js"; + +// Set eco system +setEcoSystem(ECOSYSTEM_PY); + +initializePackageManager("pipx"); + +(async () => { + // Pass through only user-supplied pipx args + var exitCode = await main(process.argv.slice(2)); + process.exit(exitCode); +})(); diff --git a/packages/safe-chain/src/packagemanager/pipx/createPipXPackageManager.js b/packages/safe-chain/src/packagemanager/pipx/createPipXPackageManager.js new file mode 100644 index 0000000..7ba5949 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/pipx/createPipXPackageManager.js @@ -0,0 +1,18 @@ +import { runPipX } from "./runPipXCommand.js"; + +/** + * @returns {import("../currentPackageManager.js").PackageManager} + */ +export function createPipXPackageManager() { + return { + /** + * @param {string[]} args + */ + runCommand: (args) => { + return runPipX("pipx", args); + }, + // For uv, rely solely on MITM + isSupportedCommand: () => false, + getDependencyUpdatesForCommand: () => [], + }; +} diff --git a/packages/safe-chain/src/packagemanager/pipx/createPipXPackageManager.spec.js b/packages/safe-chain/src/packagemanager/pipx/createPipXPackageManager.spec.js new file mode 100644 index 0000000..407dd1c --- /dev/null +++ b/packages/safe-chain/src/packagemanager/pipx/createPipXPackageManager.spec.js @@ -0,0 +1,14 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { createPipXPackageManager } from "./createPipXPackageManager.js"; + +test("createPipXPackageManager", async (t) => { + await t.test("should create package manager with required interface", () => { + const pm = createPipXPackageManager(); + + assert.ok(pm); + assert.strictEqual(typeof pm.runCommand, "function"); + assert.strictEqual(typeof pm.isSupportedCommand, "function"); + assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function"); + }); +}); diff --git a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js new file mode 100644 index 0000000..058e4ee --- /dev/null +++ b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js @@ -0,0 +1,71 @@ +import { ui } from "../../environment/userInteraction.js"; +import { safeSpawn } from "../../utils/safeSpawn.js"; +import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; +import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js"; + +/** + * Sets CA bundle environment variables used by Python libraries and pipx. + * + * @param {NodeJS.ProcessEnv} env - Env object + * @param {string} combinedCaPath - Path to the combined CA bundle + */ +function setPipXCaBundleEnvironmentVariables(env, combinedCaPath) { + // SSL_CERT_FILE: Used by Python SSL libraries and underlying HTTP clients + if (env.SSL_CERT_FILE) { + ui.writeWarning("Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten."); + } + env.SSL_CERT_FILE = combinedCaPath; + + // REQUESTS_CA_BUNDLE: Used by the requests library (which uv may use internally) + if (env.REQUESTS_CA_BUNDLE) { + ui.writeWarning("Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten."); + } + env.REQUESTS_CA_BUNDLE = combinedCaPath; + + // PIP_CERT: Some underlying pip operations may respect this + if (env.PIP_CERT) { + ui.writeWarning("Safe-chain: User defined PIP_CERT found in environment. It will be overwritten."); + } + env.PIP_CERT = combinedCaPath; +} + +/** + * Runs a uv command with safe-chain's certificate bundle and proxy configuration. + * + * uv respects standard environment variables for proxy and TLS configuration: + * - HTTP_PROXY / HTTPS_PROXY: Proxy settings + * - SSL_CERT_FILE / REQUESTS_CA_BUNDLE: CA bundle for TLS verification + * + * Unlike pip (which requires a temporary config file for cert configuration), uv directly + * honors environment variables, so no config/ini file is needed. + * + * @param {string} command - The pipx command to execute + * @param {string[]} args - Command line arguments to pass to pipx + * @returns {Promise<{status: number}>} Exit status of the pipx command + */ +export async function runPipX(command, args) { + try { + const env = mergeSafeChainProxyEnvironmentVariables(process.env); + + const combinedCaPath = getCombinedCaBundlePath(); + setPipXCaBundleEnvironmentVariables(env, combinedCaPath); + + // Note: pipx uses HTTPS_PROXY and HTTP_PROXY environment variables for proxy configuration + // These are already set by mergeSafeChainProxyEnvironmentVariables + + const result = await safeSpawn(command, args, { + stdio: "inherit", + env, + }); + + return { status: result.status }; + } catch (/** @type any */ error) { + if (error.status) { + return { status: error.status }; + } else { + ui.writeError(`Error executing command: ${error.message}`); + ui.writeError(`Is '${command}' installed and available on your system?`); + return { status: 1 }; + } + } +} diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index 3b08bf2..953feb7 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -94,6 +94,12 @@ export const knownAikidoTools = [ ecoSystem: ECOSYSTEM_PY, internalPackageManagerName: "pip", }, + { + tool: "pipx", + aikidoCommand: "aikido-pipx", + ecoSystem: ECOSYSTEM_PY, + internalPackageManagerName: "pip", + } // When adding a new tool here, also update the documentation for the new tool in the README.md ]; diff --git a/test/e2e/pipx.e2e.spec.js b/test/e2e/pipx.e2e.spec.js new file mode 100644 index 0000000..09902d3 --- /dev/null +++ b/test/e2e/pipx.e2e.spec.js @@ -0,0 +1,572 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +describe("E2E: pipx coverage", () => { + let container; + + before(async () => { + DockerTestContainer.buildImage(); + }); + + beforeEach(async () => { + // Run a new Docker container for each test + container = new DockerTestContainer(); + await container.start(); + + const installationShell = await container.openShell("zsh"); + await installationShell.runCommand("safe-chain setup --include-python"); + + // Clear uv cache + await installationShell.runCommand("uv cache clean"); + }); + + afterEach(async () => { + // Stop and clean up the container after each test + if (container) { + await container.stop(); + container = null; + } + }); + + it(`successfully installs known safe packages with uv pip install`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv pip install --system --break-system-packages requests --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`pipx install with specific version`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv pip install --system --break-system-packages requests==2.32.3 --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`pipx install with version specifiers (>=)`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + 'uv pip install --system --break-system-packages "Jinja2>=3.1" --safe-chain-logging=verbose' + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip install with extras such as requests[socks]`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + 'uv pip install --system --break-system-packages "requests[socks]==2.32.3" --safe-chain-logging=verbose' + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip install multiple packages`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv pip install --system --break-system-packages requests certifi urllib3 --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip install from requirements file`, async () => { + const shell = await container.openShell("zsh"); + + // Create a requirements.txt file + await shell.runCommand("echo 'requests==2.32.3' > requirements.txt"); + await shell.runCommand("echo 'certifi>=2024.0.0' >> requirements.txt"); + + const result = await shell.runCommand( + "uv pip install --system --break-system-packages -r requirements.txt --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip sync with requirements file`, async () => { + const shell = await container.openShell("zsh"); + + // Create a requirements.txt file + await shell.runCommand("echo 'requests==2.32.3' > requirements-sync.txt"); + + const result = await shell.runCommand( + "uv pip sync --system --break-system-packages requirements-sync.txt --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 installation of malicious Python packages via uv`, async () => { + const shell = await container.openShell("zsh"); + + const result = await shell.runCommand( + "uv pip install --system --break-system-packages safe-chain-pi-test" + ); + + 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"), + `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 listResult = await shell.runCommand("uv pip list --system"); + assert.ok( + !listResult.output.includes("safe-chain-pi-test"), + `Malicious package was installed despite safe-chain protection. Output of 'uv pip list' was:\n${listResult.output}` + ); + }); + + it(`uv pip install from GitHub URL using the CA bundle`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv pip install --system --break-system-packages git+https://github.com/psf/requests.git@v2.32.3 --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + + // Verify installation succeeded (would fail if certificate validation via env CA bundle broke) + assert.ok( + result.output.includes("Installed") || + result.output.includes("installed"), + `Installation from GitHub failed - CA bundle may not be working. Output was:\n${result.output}` + ); + }); + + it(`uv pip successfully validates certificates for HTTPS downloads`, async () => { + const shell = await container.openShell("zsh"); + + const result = await shell.runCommand( + "uv pip install --system --break-system-packages certifi --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + + // Verify successful installation (would fail with SSL/certificate errors if the env CA bundle wasn't working) + assert.ok( + result.output.includes("Installed") || + result.output.includes("installed"), + `Installation should succeed with proper certificate validation. Output was:\n${result.output}` + ); + + // Should NOT contain SSL or certificate errors + assert.ok( + !result.output.match( + /SSL|certificate verify failed|CERTIFICATE_VERIFY_FAILED/i + ), + `Should not have SSL/certificate errors. Output was:\n${result.output}` + ); + }); + + it(`uv pip install from direct HTTPS wheel URL`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv pip install --system --break-system-packages https://files.pythonhosted.org/packages/70/8e/0e2d847013cb52cd35b38c009bb167a1a26b2ce6cd6965bf26b47bc0bf44/requests-2.31.0-py3-none-any.whl --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + + assert.ok( + result.output.includes("Installed") || + result.output.includes("installed"), + `Installation from direct HTTPS URL failed. Output was:\n${result.output}` + ); + }); + + it(`uv pip install with --upgrade flag`, async () => { + const shell = await container.openShell("zsh"); + + // First install a package + await shell.runCommand( + "uv pip install --system --break-system-packages requests==2.31.0" + ); + + // Then upgrade it + const result = await shell.runCommand( + "uv pip install --system --break-system-packages --upgrade requests --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip install with --no-deps flag`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv pip install --system --break-system-packages --no-deps requests --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip install with --editable flag from local directory`, async () => { + const shell = await container.openShell("zsh"); + + // Create a simple package structure + await shell.runCommand("mkdir -p /tmp/test-pkg"); + await shell.runCommand( + "echo 'from setuptools import setup' > /tmp/test-pkg/setup.py" + ); + await shell.runCommand( + "echo \"setup(name='test-pkg', version='0.1.0')\" >> /tmp/test-pkg/setup.py" + ); + + const result = await shell.runCommand( + "uv pip install --system --break-system-packages -e /tmp/test-pkg --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip compile creates locked requirements`, async () => { + const shell = await container.openShell("zsh"); + + // Create an input requirements file + await shell.runCommand("echo 'requests' > requirements.in"); + + const result = await shell.runCommand("uv pip compile requirements.in"); + + // uv pip compile doesn't install packages, just resolves dependencies + // It should complete successfully and output resolved requirements + assert.ok( + result.output.includes("requests==") || result.output.includes("# via"), + `Output did not include compiled requirements. Output was:\n${result.output}` + ); + }); + + it(`uv pip install with --index-url for alternate registry`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv pip install --system --break-system-packages --index-url https://test.pypi.org/simple certifi --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + + // Should succeed if CA bundle properly handles tunneled hosts + assert.ok( + result.output.includes("Installed") || + result.output.includes("installed"), + `Installation from Test PyPI failed. This may indicate the CA bundle lacks public roots. Output was:\n${result.output}` + ); + }); + + it(`uv pip install with --safe-chain-logging=verbose`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv pip install --system --break-system-packages requests --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip install with version range constraint`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + 'uv pip install --system --break-system-packages "requests>=2.31.0,<2.33.0" --safe-chain-logging=verbose' + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv pip list shows installed packages`, async () => { + const shell = await container.openShell("zsh"); + + // Install a package first + await shell.runCommand( + "uv pip install --system --break-system-packages requests" + ); + + // Then list packages - this shouldn't trigger safe-chain scanning + const result = await shell.runCommand("uv pip list --system"); + + // List command should work without malware scanning + assert.ok( + result.output.includes("requests") || result.output.length > 0, + `Output did not show package list. Output was:\n${result.output}` + ); + }); + + it(`uv add installs package and updates project`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize a new uv project and add package in same command + const result = await shell.runCommand( + "uv init test-project && cd test-project && uv add requests --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv add with specific version`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize a new uv project + await shell.runCommand("uv init test-project-version"); + + const result = await shell.runCommand( + "cd test-project-version && uv add requests==2.32.3 --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv add --dev for development dependencies`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize a new uv project + await shell.runCommand("uv init test-project-dev"); + + const result = await shell.runCommand( + "cd test-project-dev && uv add --dev pytest --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv add multiple packages at once`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize a new uv project + await shell.runCommand("uv init test-project-multi"); + + const result = await shell.runCommand( + "cd test-project-multi && uv add requests certifi urllib3 --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 packages via uv add`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize a new uv project + await shell.runCommand("uv init test-project-malware"); + + const result = await shell.runCommand( + "cd test-project-malware && uv add safe-chain-pi-test" + ); + + 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"), + `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}` + ); + }); + + it(`uv tool install installs a global tool`, async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "uv tool install ruff --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found.") || + result.output.includes("Installed"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + 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"); + + 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"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv run --with installs ephemeral dependency`, async () => { + const shell = await container.openShell("zsh"); + + // Create a simple Python script + await shell.runCommand( + "echo 'import requests; print(requests.__version__)' > test_script.py" + ); + + const result = await shell.runCommand( + "uv run --with requests test_script.py --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 packages via uv run --with`, async () => { + const shell = await container.openShell("zsh"); + + // Create a simple Python script + await shell.runCommand("echo 'print(\"test\")' > test_script2.py"); + + const result = await shell.runCommand( + "uv run --with safe-chain-pi-test test_script2.py" + ); + + assert.ok( + result.output.includes("blocked 1 malicious package downloads:"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv sync syncs project dependencies`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize a new uv project, add a dependency, remove venv, and sync in one command chain + const result = await shell.runCommand( + "uv init test-sync-project && cd test-sync-project && uv add requests --safe-chain-logging=verbose && rm -rf .venv && uv sync --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv add from git URL`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize a new uv project + await shell.runCommand("uv init test-git-add"); + + const result = await shell.runCommand( + "cd test-git-add && uv add git+https://github.com/psf/requests.git@v2.32.3 --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv add with --optional group`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize a new uv project + await shell.runCommand("uv init test-optional"); + + const result = await shell.runCommand( + "cd test-optional && uv add --optional dev pytest --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv run --with-requirements installs from requirements file`, async () => { + const shell = await container.openShell("zsh"); + + // Create requirements file and script + await shell.runCommand("echo 'requests' > run_requirements.txt"); + await shell.runCommand( + "echo 'import requests; print(requests.__version__)' > run_script.py" + ); + + const result = await shell.runCommand( + "uv run --with-requirements run_requirements.txt run_script.py --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`uv sync --all-extras syncs all optional dependencies`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize project with optional dependency and sync in one command chain + const result = await shell.runCommand( + "uv init test-extras && cd test-extras && uv add --optional dev pytest --safe-chain-logging=verbose && uv sync --all-extras" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); +}); From eb59e9878546e28e9648f8e5fa0a115a47ae307f Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 15 Dec 2025 17:50:38 +0100 Subject: [PATCH 003/360] Fix path separator on Windows Powershell --- .../startup-scripts/include-python/init-pwsh.ps1 | 4 +++- .../src/shell-integration/startup-scripts/init-pwsh.ps1 | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-pwsh.ps1 b/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-pwsh.ps1 index 168556a..c3d21c4 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-pwsh.ps1 +++ b/packages/safe-chain/src/shell-integration/startup-scripts/include-python/init-pwsh.ps1 @@ -1,5 +1,7 @@ # Use cross-platform path separator (: on Unix, ; on Windows) -$pathSeparator = if ($IsWindows) { ';' } else { ':' } +# $IsWindows is only available in PowerShell Core 6.0+. If it doesn't exist, assume Windows PowerShell +$isWindowsPlatform = if (Test-Path variable:IsWindows) { $IsWindows } else { $true } +$pathSeparator = if ($isWindowsPlatform) { ';' } else { ':' } $safeChainBin = Join-Path (Join-Path $HOME '.safe-chain') 'bin' $env:PATH = "$env:PATH$pathSeparator$safeChainBin" diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 index 78228a0..0fc3385 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 @@ -1,5 +1,7 @@ # Use cross-platform path separator (: on Unix, ; on Windows) -$pathSeparator = if ($IsWindows) { ';' } else { ':' } +# $IsWindows is only available in PowerShell Core 6.0+. If it doesn't exist, assume Windows PowerShell +$isWindowsPlatform = if (Test-Path variable:IsWindows) { $IsWindows } else { $true } +$pathSeparator = if ($isWindowsPlatform) { ';' } else { ':' } $safeChainBin = Join-Path (Join-Path $HOME '.safe-chain') 'bin' $env:PATH = "$env:PATH$pathSeparator$safeChainBin" From 4be1f7900dca84ba159d6a63b45b63eb8b74351c Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 16 Dec 2025 12:56:03 +0100 Subject: [PATCH 004/360] Use the standalone binary in our own pipelines --- .github/workflows/build-and-release.yml | 4 +--- .github/workflows/create-artifact.yml | 4 +--- .github/workflows/test-on-pr.yml | 4 +--- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index f9ca4da..a35144f 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -44,9 +44,7 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} - name: Setup safe-chain - run: | - npm i -g @aikidosec/safe-chain - safe-chain setup-ci + run: curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci - name: Set the version in safe-chain package run: npm --no-git-tag-version version ${{ needs.set-version.outputs.version }} --workspace=packages/safe-chain diff --git a/.github/workflows/create-artifact.yml b/.github/workflows/create-artifact.yml index 5aa6422..2465aee 100644 --- a/.github/workflows/create-artifact.yml +++ b/.github/workflows/create-artifact.yml @@ -60,9 +60,7 @@ jobs: node-version: "20.x" - name: Setup safe-chain - run: | - npm i -g @aikidosec/safe-chain - safe-chain setup-ci + run: curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci - name: Install dependencies run: npm ci --ignore-scripts diff --git a/.github/workflows/test-on-pr.yml b/.github/workflows/test-on-pr.yml index f754931..8811944 100644 --- a/.github/workflows/test-on-pr.yml +++ b/.github/workflows/test-on-pr.yml @@ -110,9 +110,7 @@ jobs: node-version: "lts/*" - name: Setup safe-chain - run: | - npm i -g @aikidosec/safe-chain@1.0.24 - safe-chain setup-ci + run: curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci - name: Install dependencies (root) run: npm ci From 5e28190d871f7b4839b1308d27064636040224cd Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 16 Dec 2025 13:01:04 +0100 Subject: [PATCH 005/360] Split up setup step for Windows runner --- .github/workflows/create-artifact.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/create-artifact.yml b/.github/workflows/create-artifact.yml index 2465aee..d57bce9 100644 --- a/.github/workflows/create-artifact.yml +++ b/.github/workflows/create-artifact.yml @@ -59,9 +59,15 @@ jobs: with: node-version: "20.x" - - name: Setup safe-chain + - name: Setup safe-chain (Mac/Linux) + if: runner.os != 'Windows' run: curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci + - name: Setup safe-chain (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.ps1' -UseBasicParsing) } -ci" + - name: Install dependencies run: npm ci --ignore-scripts From 7b8a94587520f6c98c56248082bb3e6ddbf9418f Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 16 Dec 2025 13:34:14 +0100 Subject: [PATCH 006/360] Add safe-chain-test for verification --- package-lock.json | 6 ++++++ packages/safe-chain/package.json | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 30f47e4..aef9fd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2639,6 +2639,11 @@ "dev": true, "license": "MIT" }, + "node_modules/safe-chain-test": { + "version": "0.0.1-security", + "resolved": "https://registry.npmjs.org/safe-chain-test/-/safe-chain-test-0.0.1-security.tgz", + "integrity": "sha512-nJoRuRb52IWYNLNX/Bpwot6w+1U1cykpp08eTUdqZOoJ3AcJkiOi4hrHJx4OtT/c4wbK7MoDlKi763DP8BgD2Q==" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -3122,6 +3127,7 @@ "make-fetch-happen": "15.0.3", "node-forge": "1.3.2", "npm-registry-fetch": "19.1.1", + "safe-chain-test": "0.0.1-security", "semver": "7.7.2" }, "bin": { diff --git a/packages/safe-chain/package.json b/packages/safe-chain/package.json index d0e0e91..dc1c553 100644 --- a/packages/safe-chain/package.json +++ b/packages/safe-chain/package.json @@ -44,7 +44,8 @@ "make-fetch-happen": "15.0.3", "node-forge": "1.3.2", "npm-registry-fetch": "19.1.1", - "semver": "7.7.2" + "semver": "7.7.2", + "safe-chain-test": "0.0.1-security" }, "devDependencies": { "@types/ini": "^4.1.1", From b060cec580e7679711ebc4367d68102c75a98165 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 16 Dec 2025 13:35:41 +0100 Subject: [PATCH 007/360] Revert "Add safe-chain-test for verification" This reverts commit 7b8a94587520f6c98c56248082bb3e6ddbf9418f. --- package-lock.json | 6 ------ packages/safe-chain/package.json | 3 +-- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index aef9fd8..30f47e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2639,11 +2639,6 @@ "dev": true, "license": "MIT" }, - "node_modules/safe-chain-test": { - "version": "0.0.1-security", - "resolved": "https://registry.npmjs.org/safe-chain-test/-/safe-chain-test-0.0.1-security.tgz", - "integrity": "sha512-nJoRuRb52IWYNLNX/Bpwot6w+1U1cykpp08eTUdqZOoJ3AcJkiOi4hrHJx4OtT/c4wbK7MoDlKi763DP8BgD2Q==" - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -3127,7 +3122,6 @@ "make-fetch-happen": "15.0.3", "node-forge": "1.3.2", "npm-registry-fetch": "19.1.1", - "safe-chain-test": "0.0.1-security", "semver": "7.7.2" }, "bin": { diff --git a/packages/safe-chain/package.json b/packages/safe-chain/package.json index dc1c553..d0e0e91 100644 --- a/packages/safe-chain/package.json +++ b/packages/safe-chain/package.json @@ -44,8 +44,7 @@ "make-fetch-happen": "15.0.3", "node-forge": "1.3.2", "npm-registry-fetch": "19.1.1", - "semver": "7.7.2", - "safe-chain-test": "0.0.1-security" + "semver": "7.7.2" }, "devDependencies": { "@types/ini": "^4.1.1", From 037a83e1ff937d7b3392708ca96ab52168918c40 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Tue, 16 Dec 2025 14:47:53 +0100 Subject: [PATCH 008/360] Print warning if deprecated --include-python flag is given --- install-scripts/install-safe-chain.ps1 | 7 ++- install-scripts/install-safe-chain.sh | 3 ++ .../safe-chain/src/config/cliArguments.js | 19 +++++++- .../src/config/cliArguments.spec.js | 37 +++++++++++++++ .../include-python-deprecation.e2e.spec.js | 45 +++++++++++++++++++ 5 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 test/e2e/include-python-deprecation.e2e.spec.js diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index 9c0dcf7..af0e43d 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -3,7 +3,9 @@ # Usage with "iex (iwr {url} -UseBasicParsing)" --> See README.md param( - [switch]$ci + [switch]$ci, + # Backwards compatibility: deprecated; warn and ignore if supplied + [switch]$includepython ) $Version = $env:SAFE_CHAIN_VERSION # Will be fetched from latest release if not set @@ -119,6 +121,9 @@ function Install-SafeChain { if ($ci) { $installMsg += " in ci" } + if ($includepython) { + Write-Warn "-includepython is deprecated and ignored. Python ecosystem is now included by default." + } Write-Info $installMsg diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 37d1710..8e19da7 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -134,6 +134,9 @@ parse_arguments() { --ci) USE_CI_SETUP=true ;; + --include-python) + warn "--include-python is deprecated and ignored. Python ecosystem is now included by default." + ;; *) error "Unknown argument: $arg" ;; diff --git a/packages/safe-chain/src/config/cliArguments.js b/packages/safe-chain/src/config/cliArguments.js index 4dd9336..71ab390 100644 --- a/packages/safe-chain/src/config/cliArguments.js +++ b/packages/safe-chain/src/config/cliArguments.js @@ -1,3 +1,5 @@ +import { ui } from "../environment/userInteraction.js"; + /** * @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, minimumPackageAgeHours: string | undefined}} */ @@ -33,7 +35,7 @@ export function initializeCliArguments(args) { setLoggingLevel(safeChainArgs); setSkipMinimumPackageAge(safeChainArgs); setMinimumPackageAgeHours(safeChainArgs); - + checkDeprecatedPythonFlag(args); return remainingArgs; } @@ -120,3 +122,18 @@ function hasFlagArg(args, flagName) { } return false; } + +/** + * Emits a deprecation warning for legacy --include-python flag + * + * @param {string[]} args + * @returns {void} + */ +export function checkDeprecatedPythonFlag(args) { + if (!Array.isArray(args)) return; + if (args.includes("--include-python")) { + ui.writeWarning( + "--include-python is deprecated and ignored. Python tooling is included by default." + ); + } +} diff --git a/packages/safe-chain/src/config/cliArguments.spec.js b/packages/safe-chain/src/config/cliArguments.spec.js index bbd5121..2b4f2f5 100644 --- a/packages/safe-chain/src/config/cliArguments.spec.js +++ b/packages/safe-chain/src/config/cliArguments.spec.js @@ -6,6 +6,7 @@ import { getSkipMinimumPackageAge, getMinimumPackageAgeHours, } from "./cliArguments.js"; +import { ui } from "../environment/userInteraction.js"; describe("initializeCliArguments", () => { it("should return all args when no safe-chain args are present", () => { @@ -271,4 +272,40 @@ describe("initializeCliArguments", () => { assert.strictEqual(getMinimumPackageAgeHours(), "-24"); }); + + it("should warn on deprecated --include-python for setup", () => { + const warnings = []; + const originalWriteWarning = ui.writeWarning; + ui.writeWarning = (msg, ...rest) => { + warnings.push(String(msg)); + }; + try { + const argv = ["node", "safe-chain", "setup", "--include-python"]; + initializeCliArguments(argv); + assert.ok( + warnings.some((m) => m.includes("--include-python is deprecated")), + "Expected a deprecation warning for --include-python in setup" + ); + } finally { + ui.writeWarning = originalWriteWarning; + } + }); + + it("should warn on deprecated --include-python for setup-ci", () => { + const warnings = []; + const originalWriteWarning = ui.writeWarning; + ui.writeWarning = (msg, ...rest) => { + warnings.push(String(msg)); + }; + try { + const argv = ["node", "safe-chain", "setup-ci", "--include-python"]; + initializeCliArguments(argv); + assert.ok( + warnings.some((m) => m.includes("--include-python is deprecated")), + "Expected a deprecation warning for --include-python in setup-ci" + ); + } finally { + ui.writeWarning = originalWriteWarning; + } + }); }); diff --git a/test/e2e/include-python-deprecation.e2e.spec.js b/test/e2e/include-python-deprecation.e2e.spec.js new file mode 100644 index 0000000..a7019b7 --- /dev/null +++ b/test/e2e/include-python-deprecation.e2e.spec.js @@ -0,0 +1,45 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +describe("E2E: deprecated --include-python handling", () => { + let container; + + before(async () => { + DockerTestContainer.buildImage(); + }); + + beforeEach(async () => { + container = new DockerTestContainer(); + await container.start(); + }); + + afterEach(async () => { + if (container) { + await container.stop(); + container = null; + } + }); + + for (let shell of ["bash", "zsh"]) { + it(`safe-chain setup warns and continues for ${shell}`, async () => { + const sh = await container.openShell(shell); + const result = await sh.runCommand("safe-chain setup --include-python"); + + assert.ok( + result.output.toLowerCase().includes("deprecated and ignored"), + `Expected warning about deprecated --include-python. Output was:\n${result.output}` + ); + }); + + it(`safe-chain setup-ci warns and continues for ${shell}`, async () => { + const sh = await container.openShell(shell); + const result = await sh.runCommand("safe-chain setup-ci --include-python"); + + assert.ok( + result.output.toLowerCase().includes("deprecated and ignored"), + `Expected warning about deprecated --include-python. Output was:\n${result.output}` + ); + }); + } +}); From a47ea153daa61b3ff81fbf169d2101de0a4b2901 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Tue, 16 Dec 2025 14:53:30 +0100 Subject: [PATCH 009/360] Simplify --- install-scripts/install-safe-chain.ps1 | 1 - packages/safe-chain/src/config/cliArguments.js | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index af0e43d..23caa5c 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -4,7 +4,6 @@ param( [switch]$ci, - # Backwards compatibility: deprecated; warn and ignore if supplied [switch]$includepython ) diff --git a/packages/safe-chain/src/config/cliArguments.js b/packages/safe-chain/src/config/cliArguments.js index 71ab390..25013fb 100644 --- a/packages/safe-chain/src/config/cliArguments.js +++ b/packages/safe-chain/src/config/cliArguments.js @@ -130,8 +130,7 @@ function hasFlagArg(args, flagName) { * @returns {void} */ export function checkDeprecatedPythonFlag(args) { - if (!Array.isArray(args)) return; - if (args.includes("--include-python")) { + if (hasFlagArg(args, "--include-python")) { ui.writeWarning( "--include-python is deprecated and ignored. Python tooling is included by default." ); From 379cd20154485558570a5979be168c20cf5e5ea4 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Tue, 16 Dec 2025 15:05:03 +0100 Subject: [PATCH 010/360] Fix linter issue --- packages/safe-chain/src/config/cliArguments.spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/safe-chain/src/config/cliArguments.spec.js b/packages/safe-chain/src/config/cliArguments.spec.js index 2b4f2f5..8b505be 100644 --- a/packages/safe-chain/src/config/cliArguments.spec.js +++ b/packages/safe-chain/src/config/cliArguments.spec.js @@ -276,7 +276,7 @@ describe("initializeCliArguments", () => { it("should warn on deprecated --include-python for setup", () => { const warnings = []; const originalWriteWarning = ui.writeWarning; - ui.writeWarning = (msg, ...rest) => { + ui.writeWarning = (msg, ..._rest) => { warnings.push(String(msg)); }; try { @@ -294,7 +294,7 @@ describe("initializeCliArguments", () => { it("should warn on deprecated --include-python for setup-ci", () => { const warnings = []; const originalWriteWarning = ui.writeWarning; - ui.writeWarning = (msg, ...rest) => { + ui.writeWarning = (msg, ..._rest) => { warnings.push(String(msg)); }; try { From aaa5a41af6c18397ca845210c69993d8a22050ee Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 16 Dec 2025 15:19:50 +0100 Subject: [PATCH 011/360] Replace version correctly --- .github/workflows/build-and-release.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 3792ade..ffe3a7c 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -86,9 +86,11 @@ jobs: mv binaries/safe-chain-win-arm64/safe-chain.exe release-artifacts/safe-chain-win-arm64.exe - name: Move install scripts and hard-code version + 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/Get-LatestVersion/\"${VERSION}\"/" install-scripts/install-safe-chain.ps1 > release-artifacts/install-safe-chain.ps1 + 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 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 From e6cfa65ee249f9e867b0e895dee80ed9907d4725 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 16 Dec 2025 16:09:57 +0100 Subject: [PATCH 012/360] Document release scripts --- .github/workflows/build-and-release.yml | 8 +++---- README.md | 32 ++++++++++++++++++------- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index ffe3a7c..425dc6f 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -63,10 +63,10 @@ jobs: cp LICENSE packages/safe-chain/ cp -r docs packages/safe-chain/ - # - name: Publish to npm - # run: | - # echo "Publishing version ${{ needs.set-version.outputs.version }} to NPM" - # npm publish --workspace=packages/safe-chain --access public --provenance + - name: Publish to npm + run: | + echo "Publishing version ${{ needs.set-version.outputs.version }} to NPM" + npm publish --workspace=packages/safe-chain --access public --provenance - name: Download all binary artifacts uses: actions/download-artifact@v4 diff --git a/README.md b/README.md index 9047def..6b424f1 100644 --- a/README.md +++ b/README.md @@ -35,15 +35,31 @@ Installing the Aikido Safe Chain is easy with our one-line installer. ### Unix/Linux/macOS ```shell -curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh +curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh ``` ### Windows (PowerShell) ```powershell -iex (iwr "https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.ps1" -UseBasicParsing) +iex (iwr "https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1" -UseBasicParsing) ``` +### Pinning to a specific version + +To install a specific version instead of the latest, replace `latest` with the version number in the URL (available from version 1.3.2 onwards): + +**Unix/Linux/macOS:** +```shell +curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/x.x.x/install-safe-chain.sh | sh +``` + +**Windows (PowerShell):** +```powershell +iex (iwr "https://github.com/AikidoSec/safe-chain/releases/download/x.x.x/install-safe-chain.ps1" -UseBasicParsing) +``` + +You can find all available versions on the [releases page](https://github.com/AikidoSec/safe-chain/releases). + ### Verify the installation 1. **❗Restart your terminal** to start using the Aikido Safe Chain. @@ -105,13 +121,13 @@ To uninstall the Aikido Safe Chain, use our one-line uninstaller: ### Unix/Linux/macOS ```shell -curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/uninstall-safe-chain.sh | sh +curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/uninstall-safe-chain.sh | sh ``` ### Windows (PowerShell) ```powershell -iex (iwr "https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/uninstall-safe-chain.ps1" -UseBasicParsing) +iex (iwr "https://github.com/AikidoSec/safe-chain/releases/latest/download/uninstall-safe-chain.ps1" -UseBasicParsing) ``` **❗Restart your terminal** after uninstalling to ensure all aliases are removed. @@ -178,13 +194,13 @@ Use the `--ci` flag to automatically configure Aikido Safe Chain for CI/CD envir ### Unix/Linux/macOS (GitHub Actions, Azure Pipelines, etc.) ```shell -curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci +curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci ``` ### Windows (Azure Pipelines, etc.) ```powershell -iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.ps1' -UseBasicParsing) } -ci" +iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -ci" ``` ## Supported Platforms @@ -202,7 +218,7 @@ iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/inst cache: "npm" - name: Install safe-chain - run: curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci + run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci - name: Install dependencies run: npm ci @@ -216,7 +232,7 @@ iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/inst versionSpec: "22.x" displayName: "Install Node.js" -- script: curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci +- script: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci displayName: "Install safe-chain" - script: npm ci From 2374c7619263a4c50b42d9f721667c3a0a12682d Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 17 Dec 2025 09:35:10 +0100 Subject: [PATCH 013/360] Check current safe-chain version in installation script --- install-scripts/install-safe-chain.ps1 | 46 ++++++++++++++++++++++++++ install-scripts/install-safe-chain.sh | 38 +++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index 9c0dcf7..16d2fc0 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -30,6 +30,46 @@ function Write-Error-Custom { exit 1 } +# Get currently installed version of safe-chain +function Get-InstalledVersion { + # Check if safe-chain command exists + if (-not (Get-Command safe-chain -ErrorAction SilentlyContinue)) { + return $null + } + + try { + # Execute safe-chain -v and capture output + $output = & safe-chain -v 2>&1 + + # Extract version from "Current safe-chain version: X.Y.Z" output + if ($output -match "Current safe-chain version:\s*(.+)") { + return $matches[1].Trim() + } + + return $null + } + catch { + return $null + } +} + +# Check if the requested version is already installed +function Test-VersionInstalled { + param([string]$RequestedVersion) + + $installedVersion = Get-InstalledVersion + + if ([string]::IsNullOrWhiteSpace($installedVersion)) { + return $false + } + + # Strip leading 'v' from versions if present for comparison + $requestedClean = $RequestedVersion -replace '^v', '' + $installedClean = $installedVersion -replace '^v', '' + + return $requestedClean -eq $installedClean +} + # Fetch latest release version tag from GitHub function Get-LatestVersion { try { @@ -114,6 +154,12 @@ function Install-SafeChain { $Version = Get-LatestVersion } + # Check if the requested version is already installed + if (Test-VersionInstalled -RequestedVersion $Version) { + Write-Info "safe-chain $Version is already installed" + exit 0 + } + # Build installation message $installMsg = "Installing safe-chain $Version" if ($ci) { diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 37d1710..54051c9 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -54,6 +54,38 @@ command_exists() { command -v "$1" >/dev/null 2>&1 } +# Get currently installed version of safe-chain +get_installed_version() { + if ! command_exists safe-chain; then + echo "" + return + fi + + # Extract version from "Current safe-chain version: X.Y.Z" output + installed_version=$(safe-chain -v 2>/dev/null | grep "Current safe-chain version:" | sed -E 's/.*: (.*)/\1/') + echo "$installed_version" +} + +# Check if the requested version is already installed +is_version_installed() { + requested_version="$1" + installed_version=$(get_installed_version) + + if [ -z "$installed_version" ]; then + return 1 # Not installed + fi + + # Strip leading 'v' from versions if present for comparison + requested_clean=$(echo "$requested_version" | sed 's/^v//') + installed_clean=$(echo "$installed_version" | sed 's/^v//') + + if [ "$requested_clean" = "$installed_clean" ]; then + return 0 # Same version installed + else + return 1 # Different version installed + fi +} + # Fetch latest release version tag from GitHub fetch_latest_version() { # Try using GitHub API to get the latest release tag @@ -155,6 +187,12 @@ main() { VERSION=$(fetch_latest_version) fi + # Check if the requested version is already installed + if is_version_installed "$VERSION"; then + info "safe-chain ${VERSION} is already installed" + exit 0 + fi + # Build installation message INSTALL_MSG="Installing safe-chain ${VERSION}" if [ "$USE_CI_SETUP" = "true" ]; then From 0b38fcd74e2c64e58d17fd6f6f49f98b10320d15 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 17 Dec 2025 10:20:31 +0100 Subject: [PATCH 014/360] Use return instead of exit --- install-scripts/install-safe-chain.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index 16d2fc0..b7f17b1 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -157,7 +157,7 @@ function Install-SafeChain { # Check if the requested version is already installed if (Test-VersionInstalled -RequestedVersion $Version) { Write-Info "safe-chain $Version is already installed" - exit 0 + return } # Build installation message From 3c18ad76f7446e64d95ed2dbf56a1307ef593ff2 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 17 Dec 2025 11:37:51 +0100 Subject: [PATCH 015/360] Skeleton --- README.md | 23 +++++++++++++++++++ .../src/shell-integration/setup-ci.js | 8 +++++++ 2 files changed, 31 insertions(+) diff --git a/README.md b/README.md index 9047def..d56775c 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,7 @@ iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/inst - ✅ **GitHub Actions** - ✅ **Azure Pipelines** +- ✅ **CircleCI** ## GitHub Actions Example @@ -224,3 +225,25 @@ iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/inst ``` After setup, all subsequent package manager commands in your CI pipeline will automatically be protected by Aikido Safe Chain's malware detection. + +## CircleCI Example + +```yaml +version: 2.1 +jobs: + build: + docker: + - image: cimg/node:lts + steps: + - checkout + - run: | + curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci + - run: npm ci + - run: npm test +workflows: + build_and_test: + jobs: + - build +``` + +Note: `setup-ci` writes the Safe Chain shims to `~/.safe-chain/shims` and persists PATH via CircleCI's `BASH_ENV`, so subsequent steps automatically use the wrapped package managers. diff --git a/packages/safe-chain/src/shell-integration/setup-ci.js b/packages/safe-chain/src/shell-integration/setup-ci.js index 14510f9..54b8505 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.js @@ -157,6 +157,14 @@ function modifyPathForCi(shimsDir, binDir) { ui.writeInformation("##vso[task.prependpath]" + shimsDir); ui.writeInformation("##vso[task.prependpath]" + binDir); } + + if (process.env.BASH_ENV) { + // In CircleCI, persisting PATH across steps is done by appending shell exports + // to the file referenced by BASH_ENV. CircleCI sources this file for each step. + const exportLine = `export PATH=\"${shimsDir}:${binDir}:$PATH\"` + os.EOL; + fs.appendFileSync(process.env.BASH_ENV, exportLine, "utf-8"); + ui.writeInformation(`Added shims directory to BASH_ENV for CircleCI.`); + } } function getToolsToSetup() { From 5de43c1bf231220dfb8f41e3d86083e213407d02 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 17 Dec 2025 13:26:14 +0100 Subject: [PATCH 016/360] Some modifications --- packages/safe-chain/src/shell-integration/setup-ci.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/setup-ci.js b/packages/safe-chain/src/shell-integration/setup-ci.js index 54b8505..762bd9b 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.js @@ -160,8 +160,8 @@ function modifyPathForCi(shimsDir, binDir) { if (process.env.BASH_ENV) { // In CircleCI, persisting PATH across steps is done by appending shell exports - // to the file referenced by BASH_ENV. CircleCI sources this file for each step. - const exportLine = `export PATH=\"${shimsDir}:${binDir}:$PATH\"` + os.EOL; + // to the file referenced by BASH_ENV. CircleCI sources this file for 'run' each step. + const exportLine = `export PATH="${shimsDir}:${binDir}:$PATH"` + os.EOL; fs.appendFileSync(process.env.BASH_ENV, exportLine, "utf-8"); ui.writeInformation(`Added shims directory to BASH_ENV for CircleCI.`); } From 8c929f65e23beccc01af8713ba03bf259904604a Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 17 Dec 2025 13:51:56 +0100 Subject: [PATCH 017/360] Update README --- README.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/README.md b/README.md index d56775c..1d6db62 100644 --- a/README.md +++ b/README.md @@ -224,8 +224,6 @@ iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/inst displayName: "Install dependencies" ``` -After setup, all subsequent package manager commands in your CI pipeline will automatically be protected by Aikido Safe Chain's malware detection. - ## CircleCI Example ```yaml @@ -239,11 +237,10 @@ jobs: - run: | curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci - run: npm ci - - run: npm test workflows: build_and_test: jobs: - build ``` -Note: `setup-ci` writes the Safe Chain shims to `~/.safe-chain/shims` and persists PATH via CircleCI's `BASH_ENV`, so subsequent steps automatically use the wrapped package managers. +After setup, all subsequent package manager commands in your CI pipeline will automatically be protected by Aikido Safe Chain's malware detection. From 148eb214303d5db8ee118afaac5b412d2ba10f0c Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 17 Dec 2025 14:07:58 +0100 Subject: [PATCH 018/360] Use new release script in GH workflows --- .github/workflows/build-and-release.yml | 2 +- .github/workflows/create-artifact.yml | 4 ++-- .github/workflows/test-on-pr.yml | 6 ++---- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 7423778..83c11d9 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -44,7 +44,7 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} - name: Setup safe-chain - run: curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci + run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci - name: Set the version in safe-chain package run: npm --no-git-tag-version version ${{ needs.set-version.outputs.version }} --workspace=packages/safe-chain diff --git a/.github/workflows/create-artifact.yml b/.github/workflows/create-artifact.yml index d57bce9..d7729fd 100644 --- a/.github/workflows/create-artifact.yml +++ b/.github/workflows/create-artifact.yml @@ -61,12 +61,12 @@ jobs: - name: Setup safe-chain (Mac/Linux) if: runner.os != 'Windows' - run: curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci + run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci - name: Setup safe-chain (Windows) if: runner.os == 'Windows' shell: pwsh - run: iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.ps1' -UseBasicParsing) } -ci" + run: iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -ci" - name: Install dependencies run: npm ci --ignore-scripts diff --git a/.github/workflows/test-on-pr.yml b/.github/workflows/test-on-pr.yml index 8811944..f7ee116 100644 --- a/.github/workflows/test-on-pr.yml +++ b/.github/workflows/test-on-pr.yml @@ -23,9 +23,7 @@ jobs: node-version: "lts/*" - name: Setup safe-chain - run: | - npm i -g @aikidosec/safe-chain - safe-chain setup-ci + run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci - name: Install dependencies run: npm ci --ignore-scripts @@ -110,7 +108,7 @@ jobs: node-version: "lts/*" - name: Setup safe-chain - run: curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci + run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci - name: Install dependencies (root) run: npm ci From a1ec035d9cbd38a429946f56b90b3c98169d31be Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 17 Dec 2025 14:09:45 +0100 Subject: [PATCH 019/360] Use Windows installation script --- .github/workflows/build-and-release.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 83c11d9..8d8f841 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -43,9 +43,15 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} - - name: Setup safe-chain + - name: Setup safe-chain (Mac/Linux) + if: runner.os != 'Windows' run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci + - name: Setup safe-chain (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -ci" + - name: Set the version in safe-chain package run: npm --no-git-tag-version version ${{ needs.set-version.outputs.version }} --workspace=packages/safe-chain From 2cb891b9358ff169b5ad60978dd9c5e602ab95de Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 17 Dec 2025 14:12:39 +0100 Subject: [PATCH 020/360] Use correct Windows install script --- .github/workflows/build-and-release.yml | 8 +------- .github/workflows/test-on-pr.yml | 8 +++++++- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 8d8f841..83c11d9 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -43,15 +43,9 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} - - name: Setup safe-chain (Mac/Linux) - if: runner.os != 'Windows' + - name: Setup safe-chain run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci - - name: Setup safe-chain (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -ci" - - name: Set the version in safe-chain package run: npm --no-git-tag-version version ${{ needs.set-version.outputs.version }} --workspace=packages/safe-chain diff --git a/.github/workflows/test-on-pr.yml b/.github/workflows/test-on-pr.yml index f7ee116..9e4a5ec 100644 --- a/.github/workflows/test-on-pr.yml +++ b/.github/workflows/test-on-pr.yml @@ -22,9 +22,15 @@ jobs: with: node-version: "lts/*" - - name: Setup safe-chain + - name: Setup safe-chain (Mac/Linux) + if: runner.os != 'Windows' run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci + - name: Setup safe-chain (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -ci" + - name: Install dependencies run: npm ci --ignore-scripts From d2fc531c81aba8d246da9b47aeddbc7071e02809 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 18 Dec 2025 10:33:31 +0100 Subject: [PATCH 021/360] Fix tests and add command support --- README.md | 9 +- docs/shell-integration.md | 8 +- package-lock.json | 1 + packages/safe-chain/package.json | 1 + .../packagemanager/currentPackageManager.js | 3 + .../pipx/createPipXPackageManager.js | 2 +- .../pipx/createPipXPackageManager.spec.js | 2 +- .../src/packagemanager/pipx/runPipXCommand.js | 17 +- .../pipx/runPipXCommand.spec.js | 100 ++++ .../src/shell-integration/helpers.js | 2 +- .../startup-scripts/init-fish.fish | 5 +- .../startup-scripts/init-posix.sh | 5 +- .../startup-scripts/init-pwsh.ps1 | 3 + test/e2e/pipx.e2e.spec.js | 502 +++--------------- 14 files changed, 198 insertions(+), 462 deletions(-) create mode 100644 packages/safe-chain/src/packagemanager/pipx/runPipXCommand.spec.js diff --git a/README.md b/README.md index 6b424f1..b0c80cc 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Aikido Safe Chain supports the following package managers: - 📦 **pip3** - 📦 **uv** - 📦 **poetry** +- 📦 **pipx** # Usage @@ -64,7 +65,7 @@ You can find all available versions on the [releases page](https://github.com/Ai 1. **❗Restart your terminal** to start using the Aikido Safe Chain. - - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, and pip/pip3 are loaded correctly. If you do not restart your terminal, the aliases will not be available. + - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, poetry, uv and pipx are loaded correctly. If you do not restart your terminal, the aliases will not be available. 2. **Verify the installation** by running one of the following commands: @@ -82,7 +83,7 @@ You can find all available versions on the [releases page](https://github.com/Ai - The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware. -When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `uv`, `pip`, `pip3` or `poetry` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command. +When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `uv`, `pip`, `pip3`, `poetry` and `pipx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command. You can check the installed version by running: @@ -100,11 +101,11 @@ The Aikido Safe Chain works by running a lightweight proxy server that intercept For npm packages, Safe Chain temporarily suppresses packages published within the last 24 hours (by default) until they have been validated against malware. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can configure this threshold or bypass this protection entirely - see the [Minimum Package Age Configuration](#minimum-package-age) section below. -⚠️ This feature **only applies to npm-based package managers** (npm, npx, yarn, pnpm, pnpx, bun, bunx) and does not apply to Python package managers (uv, pip, pip3, poetry). +⚠️ This feature **only applies to npm-based package managers** (npm, npx, yarn, pnpm, pnpx, bun, bunx) and does not apply to Python package managers (uv, pip, pip3, poetry, pipx). ### Shell Integration -The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and Python package managers (uv, pip). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support: +The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and Python package managers (uv, pip, poetry, pipx). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support: - ✅ **Bash** - ✅ **Zsh** diff --git a/docs/shell-integration.md b/docs/shell-integration.md index e7afbe5..6b08fac 100644 --- a/docs/shell-integration.md +++ b/docs/shell-integration.md @@ -2,7 +2,7 @@ ## Overview -The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`) with Aikido's security scanning functionality. It also intercepts Python module invocations for pip when available: `python -m pip`, `python -m pip3`, `python3 -m pip`, `python3 -m pip3`. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents. +The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry`, `pipx`) with Aikido's security scanning functionality. It also intercepts Python module invocations for pip when available: `python -m pip`, `python -m pip3`, `python3 -m pip`, `python3 -m pip3`. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents. ## Supported Shells @@ -28,7 +28,7 @@ This command: - Copies necessary startup scripts to Safe Chain's installation directory (`~/.safe-chain/scripts`) - Detects all supported shells on your system -- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, and `pip3` +- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry` and `pipx` - Adds lightweight interceptors so `python -m pip[...]` and `python3 -m pip[...]` route through Safe Chain when invoked by name ❗ After running this command, **you must restart your terminal** for the changes to take effect. This ensures that the startup scripts are sourced correctly. @@ -78,7 +78,7 @@ The system modifies the following files to source Safe Chain startup scripts: This means the shell functions are working but the Aikido commands aren't installed or available in your PATH: - Make sure Aikido Safe Chain is properly installed on your system -- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-bun`, `aikido-bunx`, `aikido-pip`, and `aikido-pip3` commands exist +- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-bun`, `aikido-bunx`, `aikido-pip`, `aikido-pip3`, `aikido-uv`, `aikido-poetry` and `aikido-pipx` commands exist - Check that these commands are in your system's PATH ### Manual Verification @@ -121,7 +121,7 @@ npm() { } ``` -Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, and `pip3` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes. +Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry` and `pipx` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes. To intercept Python module invocations for pip without altering Python itself, you can add small forwarding functions: diff --git a/package-lock.json b/package-lock.json index 30f47e4..c852d4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3131,6 +3131,7 @@ "aikido-npx": "bin/aikido-npx.js", "aikido-pip": "bin/aikido-pip.js", "aikido-pip3": "bin/aikido-pip3.js", + "aikido-pipx": "bin/aikido-pipx.js", "aikido-pnpm": "bin/aikido-pnpm.js", "aikido-pnpx": "bin/aikido-pnpx.js", "aikido-poetry": "bin/aikido-poetry.js", diff --git a/packages/safe-chain/package.json b/packages/safe-chain/package.json index d0e0e91..3d527cb 100644 --- a/packages/safe-chain/package.json +++ b/packages/safe-chain/package.json @@ -21,6 +21,7 @@ "aikido-python": "bin/aikido-python.js", "aikido-python3": "bin/aikido-python3.js", "aikido-poetry": "bin/aikido-poetry.js", + "aikido-pipx": "bin/aikido-pipx.js", "safe-chain": "bin/safe-chain.js" }, "type": "module", diff --git a/packages/safe-chain/src/packagemanager/currentPackageManager.js b/packages/safe-chain/src/packagemanager/currentPackageManager.js index d8afa80..46bb3c1 100644 --- a/packages/safe-chain/src/packagemanager/currentPackageManager.js +++ b/packages/safe-chain/src/packagemanager/currentPackageManager.js @@ -10,6 +10,7 @@ import { } from "./pnpm/createPackageManager.js"; import { createYarnPackageManager } from "./yarn/createPackageManager.js"; import { createPipPackageManager } from "./pip/createPackageManager.js"; +import { createPipXPackageManager } from "./pipx/createPipXPackageManager.js"; import { createUvPackageManager } from "./uv/createUvPackageManager.js"; import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js"; @@ -61,6 +62,8 @@ export function initializePackageManager(packageManagerName, context) { state.packageManagerName = createUvPackageManager(); } else if (packageManagerName === "poetry") { state.packageManagerName = createPoetryPackageManager(); + } else if (packageManagerName === "pipx") { + state.packageManagerName = createPipXPackageManager(); } else { throw new Error("Unsupported package manager: " + packageManagerName); } diff --git a/packages/safe-chain/src/packagemanager/pipx/createPipXPackageManager.js b/packages/safe-chain/src/packagemanager/pipx/createPipXPackageManager.js index 7ba5949..cc536f8 100644 --- a/packages/safe-chain/src/packagemanager/pipx/createPipXPackageManager.js +++ b/packages/safe-chain/src/packagemanager/pipx/createPipXPackageManager.js @@ -11,7 +11,7 @@ export function createPipXPackageManager() { runCommand: (args) => { return runPipX("pipx", args); }, - // For uv, rely solely on MITM + // MITM only isSupportedCommand: () => false, getDependencyUpdatesForCommand: () => [], }; diff --git a/packages/safe-chain/src/packagemanager/pipx/createPipXPackageManager.spec.js b/packages/safe-chain/src/packagemanager/pipx/createPipXPackageManager.spec.js index 407dd1c..1932384 100644 --- a/packages/safe-chain/src/packagemanager/pipx/createPipXPackageManager.spec.js +++ b/packages/safe-chain/src/packagemanager/pipx/createPipXPackageManager.spec.js @@ -5,7 +5,7 @@ import { createPipXPackageManager } from "./createPipXPackageManager.js"; test("createPipXPackageManager", async (t) => { await t.test("should create package manager with required interface", () => { const pm = createPipXPackageManager(); - + assert.ok(pm); assert.strictEqual(typeof pm.runCommand, "function"); assert.strictEqual(typeof pm.isSupportedCommand, "function"); diff --git a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js index 058e4ee..31d701c 100644 --- a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js +++ b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js @@ -16,7 +16,7 @@ function setPipXCaBundleEnvironmentVariables(env, combinedCaPath) { } env.SSL_CERT_FILE = combinedCaPath; - // REQUESTS_CA_BUNDLE: Used by the requests library (which uv may use internally) + // REQUESTS_CA_BUNDLE: Used by the requests library (may be used by tooling under pipx) if (env.REQUESTS_CA_BUNDLE) { ui.writeWarning("Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten."); } @@ -30,18 +30,11 @@ function setPipXCaBundleEnvironmentVariables(env, combinedCaPath) { } /** - * Runs a uv command with safe-chain's certificate bundle and proxy configuration. + * Runs a pipx command with safe-chain's certificate bundle and proxy configuration. * - * uv respects standard environment variables for proxy and TLS configuration: - * - HTTP_PROXY / HTTPS_PROXY: Proxy settings - * - SSL_CERT_FILE / REQUESTS_CA_BUNDLE: CA bundle for TLS verification - * - * Unlike pip (which requires a temporary config file for cert configuration), uv directly - * honors environment variables, so no config/ini file is needed. - * - * @param {string} command - The pipx command to execute - * @param {string[]} args - Command line arguments to pass to pipx - * @returns {Promise<{status: number}>} Exit status of the pipx command + * @param {string} command - The command to execute + * @param {string[]} args - Command line arguments + * @returns {Promise<{status: number}>} Exit status of the command */ export async function runPipX(command, args) { try { diff --git a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.spec.js b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.spec.js new file mode 100644 index 0000000..7f2130d --- /dev/null +++ b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.spec.js @@ -0,0 +1,100 @@ +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; + +describe("runPipXCommand", () => { + let runPipX; + let safeSpawnMock; + let warnMock; + let errorMock; + let mergeCalls; + let mergedEnvReturn; + + beforeEach(async () => { + mergeCalls = []; + mergedEnvReturn = { + HTTPS_PROXY: "http://localhost:8080", + HTTP_PROXY: "", + }; + + safeSpawnMock = mock.fn(async () => ({ status: 0 })); + warnMock = mock.fn(); + errorMock = mock.fn(); + + mock.module("../../environment/userInteraction.js", { + namedExports: { + ui: { + writeWarning: warnMock, + writeError: errorMock, + writeInfo: () => {}, + writeVerbose: () => {}, + writeSuccess: () => {}, + }, + }, + }); + + mock.module("../../registryProxy/registryProxy.js", { + namedExports: { + mergeSafeChainProxyEnvironmentVariables: (env) => { + mergeCalls.push(env); + return { ...env, ...mergedEnvReturn }; + }, + }, + }); + + mock.module("../../registryProxy/certBundle.js", { + namedExports: { + getCombinedCaBundlePath: () => "/tmp/test-combined-ca.pem", + }, + }); + + mock.module("../../utils/safeSpawn.js", { + namedExports: { + safeSpawn: safeSpawnMock, + }, + }); + + const mod = await import("./runPipXCommand.js"); + runPipX = mod.runPipX; + }); + + afterEach(() => { + mock.reset(); + }); + + it("sets CA env vars and proxies before spawning", async () => { + const res = await runPipX("pipx", ["install", "ruff"]); + + assert.strictEqual(res.status, 0); + assert.strictEqual(safeSpawnMock.mock.calls.length, 1, "safeSpawn should be called once"); + + const [, , options] = safeSpawnMock.mock.calls[0].arguments; + const env = options.env; + + assert.strictEqual(env.SSL_CERT_FILE, "/tmp/test-combined-ca.pem"); + assert.strictEqual(env.REQUESTS_CA_BUNDLE, "/tmp/test-combined-ca.pem"); + assert.strictEqual(env.PIP_CERT, "/tmp/test-combined-ca.pem"); + assert.strictEqual(env.HTTPS_PROXY, "http://localhost:8080"); + assert.strictEqual(env.HTTP_PROXY, ""); + assert.ok(mergeCalls.length >= 1, "proxy merge should be invoked"); + }); + + it("overwrites user CA env vars and warns", async () => { + mergedEnvReturn = { + HTTPS_PROXY: "http://localhost:8080", + HTTP_PROXY: "", + SSL_CERT_FILE: "user-ssl", + REQUESTS_CA_BUNDLE: "user-requests", + PIP_CERT: "user-pip", + }; + + await runPipX("pipx", ["install", "ruff"]); + + const [, , options] = safeSpawnMock.mock.calls[0].arguments; + const env = options.env; + + assert.strictEqual(env.SSL_CERT_FILE, "/tmp/test-combined-ca.pem", "SSL cert should be overwritten"); + assert.strictEqual(env.REQUESTS_CA_BUNDLE, "/tmp/test-combined-ca.pem", "requests bundle should be overwritten"); + assert.strictEqual(env.PIP_CERT, "/tmp/test-combined-ca.pem", "pip cert should be overwritten"); + assert.strictEqual(warnMock.mock.calls.length, 3, "should warn for each overwritten var"); + }); +}); diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index 953feb7..064aca1 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -98,7 +98,7 @@ export const knownAikidoTools = [ tool: "pipx", aikidoCommand: "aikido-pipx", ecoSystem: ECOSYSTEM_PY, - internalPackageManagerName: "pip", + internalPackageManagerName: "pipx", } // When adding a new tool here, also update the documentation for the new tool in the README.md ]; diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish index 386144c..ec58c8b 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish @@ -39,7 +39,6 @@ function npm wrapSafeChainCommand "npm" $argv end - function pip wrapSafeChainCommand "pip" $argv end @@ -66,6 +65,10 @@ function python3 wrapSafeChainCommand "python3" $argv end +function pipx + wrapSafeChainCommand "pipx" $argv +end + function printSafeChainWarning set original_cmd $argv[1] diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh index c71c741..f22f79b 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh @@ -35,7 +35,6 @@ function npm() { wrapSafeChainCommand "npm" "$@" } - function pip() { wrapSafeChainCommand "pip" "$@" } @@ -62,6 +61,10 @@ function python3() { wrapSafeChainCommand "python3" "$@" } +function pipx() { + wrapSafeChainCommand "pipx" "$@" +} + function printSafeChainWarning() { # \033[43;30m is used to set the background color to yellow and text color to black # \033[0m is used to reset the text formatting diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 index c3d21c4..7fabcad 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 @@ -66,6 +66,9 @@ function python3 { Invoke-WrappedCommand 'python3' $args } +function pipx { + Invoke-WrappedCommand "pipx" $args +} function Write-SafeChainWarning { param([string]$Command) diff --git a/test/e2e/pipx.e2e.spec.js b/test/e2e/pipx.e2e.spec.js index 09902d3..a554aa6 100644 --- a/test/e2e/pipx.e2e.spec.js +++ b/test/e2e/pipx.e2e.spec.js @@ -10,563 +10,191 @@ describe("E2E: pipx coverage", () => { }); beforeEach(async () => { - // Run a new Docker container for each test container = new DockerTestContainer(); await container.start(); const installationShell = await container.openShell("zsh"); - await installationShell.runCommand("safe-chain setup --include-python"); - - // Clear uv cache - await installationShell.runCommand("uv cache clean"); + await installationShell.runCommand("safe-chain setup"); }); afterEach(async () => { - // Stop and clean up the container after each test if (container) { await container.stop(); container = null; } }); - it(`successfully installs known safe packages with uv pip install`, async () => { + it(`successfully installs known safe packages with pipx install`, async () => { const shell = await container.openShell("zsh"); + const result = await shell.runCommand( - "uv pip install --system --break-system-packages requests --safe-chain-logging=verbose" + "pipx install ruff --safe-chain-logging=verbose" ); assert.ok( - result.output.includes("no malware found."), + result.output.includes("no malware found.") || result.output.includes("installed successfully"), `Output did not include expected text. Output was:\n${result.output}` ); }); - it(`pipx install with specific version`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "uv pip install --system --break-system-packages requests==2.32.3 --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`pipx install with version specifiers (>=)`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - 'uv pip install --system --break-system-packages "Jinja2>=3.1" --safe-chain-logging=verbose' - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv pip install with extras such as requests[socks]`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - 'uv pip install --system --break-system-packages "requests[socks]==2.32.3" --safe-chain-logging=verbose' - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv pip install multiple packages`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "uv pip install --system --break-system-packages requests certifi urllib3 --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv pip install from requirements file`, async () => { - const shell = await container.openShell("zsh"); - - // Create a requirements.txt file - await shell.runCommand("echo 'requests==2.32.3' > requirements.txt"); - await shell.runCommand("echo 'certifi>=2024.0.0' >> requirements.txt"); - - const result = await shell.runCommand( - "uv pip install --system --break-system-packages -r requirements.txt --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv pip sync with requirements file`, async () => { - const shell = await container.openShell("zsh"); - - // Create a requirements.txt file - await shell.runCommand("echo 'requests==2.32.3' > requirements-sync.txt"); - - const result = await shell.runCommand( - "uv pip sync --system --break-system-packages requirements-sync.txt --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 installation of malicious Python packages via uv`, async () => { + it(`safe-chain blocks installation of malicious Python packages via pipx`, async () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand( - "uv pip install --system --break-system-packages safe-chain-pi-test" + "pipx install safe-chain-pi-test" ); 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"), - `Output did not include expected text. Output was:\n${result.output}` + result.output.includes("blocked by safe-chain"), + `Expected malware to be blocked. 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 listResult = await shell.runCommand("uv pip list --system"); - assert.ok( - !listResult.output.includes("safe-chain-pi-test"), - `Malicious package was installed despite safe-chain protection. Output of 'uv pip list' was:\n${listResult.output}` + `Expected exit message. Output was:\n${result.output}` ); }); - it(`uv pip install from GitHub URL using the CA bundle`, async () => { + it(`pipx upgrade upgrades installed packages`, async () => { const shell = await container.openShell("zsh"); + + await shell.runCommand("pipx install ruff==0.1.0"); + const result = await shell.runCommand( - "uv pip install --system --break-system-packages git+https://github.com/psf/requests.git@v2.32.3 --safe-chain-logging=verbose" + "pipx upgrade ruff" ); assert.ok( - result.output.includes("no malware found."), + result.output.includes("no malware found.") || result.output.includes("Upgraded") || result.output.includes("upgraded"), `Output did not include expected text. Output was:\n${result.output}` ); - - // Verify installation succeeded (would fail if certificate validation via env CA bundle broke) - assert.ok( - result.output.includes("Installed") || - result.output.includes("installed"), - `Installation from GitHub failed - CA bundle may not be working. Output was:\n${result.output}` - ); }); - it(`uv pip successfully validates certificates for HTTPS downloads`, async () => { + it(`pipx run downloads and executes a safe tool`, async () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand( - "uv pip install --system --break-system-packages certifi --safe-chain-logging=verbose" + "pipx run ruff --version" ); assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - - // Verify successful installation (would fail with SSL/certificate errors if the env CA bundle wasn't working) - assert.ok( - result.output.includes("Installed") || - result.output.includes("installed"), - `Installation should succeed with proper certificate validation. Output was:\n${result.output}` - ); - - // Should NOT contain SSL or certificate errors - assert.ok( - !result.output.match( - /SSL|certificate verify failed|CERTIFICATE_VERIFY_FAILED/i - ), - `Should not have SSL/certificate errors. Output was:\n${result.output}` + result.output.includes("no malware found.") || /ruff/i.test(result.output), + `Expected safe run to succeed. Output was:\n${result.output}` ); }); - it(`uv pip install from direct HTTPS wheel URL`, async () => { + it(`pipx run blocks malicious tool download`, async () => { const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "uv pip install --system --break-system-packages https://files.pythonhosted.org/packages/70/8e/0e2d847013cb52cd35b38c009bb167a1a26b2ce6cd6965bf26b47bc0bf44/requests-2.31.0-py3-none-any.whl --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - - assert.ok( - result.output.includes("Installed") || - result.output.includes("installed"), - `Installation from direct HTTPS URL failed. Output was:\n${result.output}` - ); - }); - - it(`uv pip install with --upgrade flag`, async () => { - const shell = await container.openShell("zsh"); - - // First install a package - await shell.runCommand( - "uv pip install --system --break-system-packages requests==2.31.0" - ); - - // Then upgrade it - const result = await shell.runCommand( - "uv pip install --system --break-system-packages --upgrade requests --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv pip install with --no-deps flag`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "uv pip install --system --break-system-packages --no-deps requests --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv pip install with --editable flag from local directory`, async () => { - const shell = await container.openShell("zsh"); - - // Create a simple package structure - await shell.runCommand("mkdir -p /tmp/test-pkg"); - await shell.runCommand( - "echo 'from setuptools import setup' > /tmp/test-pkg/setup.py" - ); - await shell.runCommand( - "echo \"setup(name='test-pkg', version='0.1.0')\" >> /tmp/test-pkg/setup.py" - ); const result = await shell.runCommand( - "uv pip install --system --break-system-packages -e /tmp/test-pkg --safe-chain-logging=verbose" + "pipx run safe-chain-pi-test --version" ); assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv pip compile creates locked requirements`, async () => { - const shell = await container.openShell("zsh"); - - // Create an input requirements file - await shell.runCommand("echo 'requests' > requirements.in"); - - const result = await shell.runCommand("uv pip compile requirements.in"); - - // uv pip compile doesn't install packages, just resolves dependencies - // It should complete successfully and output resolved requirements - assert.ok( - result.output.includes("requests==") || result.output.includes("# via"), - `Output did not include compiled requirements. Output was:\n${result.output}` - ); - }); - - it(`uv pip install with --index-url for alternate registry`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "uv pip install --system --break-system-packages --index-url https://test.pypi.org/simple certifi --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - - // Should succeed if CA bundle properly handles tunneled hosts - assert.ok( - result.output.includes("Installed") || - result.output.includes("installed"), - `Installation from Test PyPI failed. This may indicate the CA bundle lacks public roots. Output was:\n${result.output}` - ); - }); - - it(`uv pip install with --safe-chain-logging=verbose`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "uv pip install --system --break-system-packages requests --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv pip install with version range constraint`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - 'uv pip install --system --break-system-packages "requests>=2.31.0,<2.33.0" --safe-chain-logging=verbose' - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv pip list shows installed packages`, async () => { - const shell = await container.openShell("zsh"); - - // Install a package first - await shell.runCommand( - "uv pip install --system --break-system-packages requests" - ); - - // Then list packages - this shouldn't trigger safe-chain scanning - const result = await shell.runCommand("uv pip list --system"); - - // List command should work without malware scanning - assert.ok( - result.output.includes("requests") || result.output.length > 0, - `Output did not show package list. Output was:\n${result.output}` - ); - }); - - it(`uv add installs package and updates project`, async () => { - const shell = await container.openShell("zsh"); - - // Initialize a new uv project and add package in same command - const result = await shell.runCommand( - "uv init test-project && cd test-project && uv add requests --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv add with specific version`, async () => { - const shell = await container.openShell("zsh"); - - // Initialize a new uv project - await shell.runCommand("uv init test-project-version"); - - const result = await shell.runCommand( - "cd test-project-version && uv add requests==2.32.3 --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv add --dev for development dependencies`, async () => { - const shell = await container.openShell("zsh"); - - // Initialize a new uv project - await shell.runCommand("uv init test-project-dev"); - - const result = await shell.runCommand( - "cd test-project-dev && uv add --dev pytest --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv add multiple packages at once`, async () => { - const shell = await container.openShell("zsh"); - - // Initialize a new uv project - await shell.runCommand("uv init test-project-multi"); - - const result = await shell.runCommand( - "cd test-project-multi && uv add requests certifi urllib3 --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 packages via uv add`, async () => { - const shell = await container.openShell("zsh"); - - // Initialize a new uv project - await shell.runCommand("uv init test-project-malware"); - - const result = await shell.runCommand( - "cd test-project-malware && uv add safe-chain-pi-test" - ); - - 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"), - `Output did not include expected text. Output was:\n${result.output}` + result.output.includes("blocked by safe-chain"), + `Expected malicious run to be blocked. 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}` + `Expected exit message. Output was:\n${result.output}` ); }); - it(`uv tool install installs a global tool`, async () => { - const shell = await container.openShell("zsh"); - const result = await shell.runCommand( - "uv tool install ruff --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found.") || - result.output.includes("Installed"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - 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"); - - 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"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it(`uv run --with installs ephemeral dependency`, async () => { + it(`pipx runpip installs safe dependency inside an app venv`, async () => { const shell = await container.openShell("zsh"); - // Create a simple Python script - await shell.runCommand( - "echo 'import requests; print(requests.__version__)' > test_script.py" - ); + // Prepare an app environment + await shell.runCommand("pipx install ruff"); const result = await shell.runCommand( - "uv run --with requests test_script.py --safe-chain-logging=verbose" + "pipx runpip ruff install requests==2.32.3" ); assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` + result.output.includes("no malware found.") || /Successfully installed/i.test(result.output) || /requests/i.test(result.output), + `Expected safe dependency install inside app venv. Output was:\n${result.output}` ); }); - it(`safe-chain blocks malicious packages via uv run --with`, async () => { + it(`pipx runpip blocks malicious dependency install`, async () => { const shell = await container.openShell("zsh"); - // Create a simple Python script - await shell.runCommand("echo 'print(\"test\")' > test_script2.py"); + // Prepare an app environment + await shell.runCommand("pipx install ruff"); const result = await shell.runCommand( - "uv run --with safe-chain-pi-test test_script2.py" + "pipx runpip ruff install safe-chain-pi-test" ); assert.ok( - result.output.includes("blocked 1 malicious package downloads:"), - `Output did not include expected text. Output was:\n${result.output}` + result.output.includes("blocked by safe-chain"), + `Expected malicious dependency to be blocked. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected exit message. Output was:\n${result.output}` ); }); - it(`uv sync syncs project dependencies`, async () => { + it(`pipx list shows installed packages`, async () => { const shell = await container.openShell("zsh"); - // Initialize a new uv project, add a dependency, remove venv, and sync in one command chain + await shell.runCommand("pipx install ruff"); + const result = await shell.runCommand( - "uv init test-sync-project && cd test-sync-project && uv add requests --safe-chain-logging=verbose && rm -rf .venv && uv sync --safe-chain-logging=verbose" + "pipx list" ); assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` + result.output.includes("ruff"), + `Expected ruff in list output. Output was:\n${result.output}` ); }); - it(`uv add from git URL`, async () => { + it(`pipx uninstall removes packages`, async () => { const shell = await container.openShell("zsh"); - // Initialize a new uv project - await shell.runCommand("uv init test-git-add"); + await shell.runCommand("pipx install ruff --safe-chain-logging=verbose"); + await shell.runCommand("pipx uninstall ruff --safe-chain-logging=verbose"); const result = await shell.runCommand( - "cd test-git-add && uv add git+https://github.com/psf/requests.git@v2.32.3 --safe-chain-logging=verbose" + "pipx list" ); assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` + !result.output.includes("ruff"), + `Expected ruff to be removed from list. Output was:\n${result.output}` ); }); - it(`uv add with --optional group`, async () => { + it('pipx inject installs safe packages into existing venvs', async () => { const shell = await container.openShell("zsh"); - // Initialize a new uv project - await shell.runCommand("uv init test-optional"); - + await shell.runCommand("pipx install ruff --safe-chain-logging=verbose"); const result = await shell.runCommand( - "cd test-optional && uv add --optional dev pytest --safe-chain-logging=verbose" + "pipx inject ruff requests==2.32.3 --safe-chain-logging=verbose" ); assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` + result.output.includes("no malware found.") || /Successfully installed/i.test(result.output) || /requests/i.test(result.output), + `Expected safe package to be injected. Output was:\n${result.output}` ); }); - it(`uv run --with-requirements installs from requirements file`, async () => { + it('pipx inject blocks malicious packages from being installed into existing venvs', async () => { const shell = await container.openShell("zsh"); - // Create requirements file and script - await shell.runCommand("echo 'requests' > run_requirements.txt"); - await shell.runCommand( - "echo 'import requests; print(requests.__version__)' > run_script.py" - ); - + await shell.runCommand("pipx install ruff --safe-chain-logging=verbose"); const result = await shell.runCommand( - "uv run --with-requirements run_requirements.txt run_script.py --safe-chain-logging=verbose" + "pipx inject ruff safe-chain-pi-test --safe-chain-logging=verbose" ); assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` + result.output.includes("blocked by safe-chain"), + `Expected malicious package to be blocked. Output was:\n${result.output}` ); - }); - - it(`uv sync --all-extras syncs all optional dependencies`, async () => { - const shell = await container.openShell("zsh"); - - // Initialize project with optional dependency and sync in one command chain - const result = await shell.runCommand( - "uv init test-extras && cd test-extras && uv add --optional dev pytest --safe-chain-logging=verbose && uv sync --all-extras" - ); - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` + result.output.includes("Exiting without installing malicious packages."), + `Expected exit message. Output was:\n${result.output}` ); }); }); From dbc7272fb49c65366e4491f88a37f8f377fe120d Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 18 Dec 2025 10:43:27 +0100 Subject: [PATCH 022/360] Some cleanup --- README.md | 6 +++--- .../safe-chain/src/packagemanager/currentPackageManager.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b0c80cc..b200465 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ You can find all available versions on the [releases page](https://github.com/Ai - The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware. -When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `uv`, `pip`, `pip3`, `poetry` and `pipx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command. +When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry` and `pipx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command. You can check the installed version by running: @@ -95,7 +95,7 @@ safe-chain --version ### Malware Blocking -The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, uv, pip, pip3 or poetry commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. +The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, uv, poetry or pipx commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. ### Minimum package age (npm only) @@ -105,7 +105,7 @@ For npm packages, Safe Chain temporarily suppresses packages published within th ### Shell Integration -The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and Python package managers (uv, pip, poetry, pipx). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support: +The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and Python package managers (pip, uv, poetry, pipx). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support: - ✅ **Bash** - ✅ **Zsh** diff --git a/packages/safe-chain/src/packagemanager/currentPackageManager.js b/packages/safe-chain/src/packagemanager/currentPackageManager.js index 46bb3c1..af297dc 100644 --- a/packages/safe-chain/src/packagemanager/currentPackageManager.js +++ b/packages/safe-chain/src/packagemanager/currentPackageManager.js @@ -10,9 +10,9 @@ import { } from "./pnpm/createPackageManager.js"; import { createYarnPackageManager } from "./yarn/createPackageManager.js"; import { createPipPackageManager } from "./pip/createPackageManager.js"; -import { createPipXPackageManager } from "./pipx/createPipXPackageManager.js"; import { createUvPackageManager } from "./uv/createUvPackageManager.js"; import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js"; +import { createPipXPackageManager } from "./pipx/createPipXPackageManager.js"; /** * @type {{packageManagerName: PackageManager | null}} From a1d348b7680ba4425695d3ae29f8ea96a9888e3a Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 18 Dec 2025 11:45:43 +0100 Subject: [PATCH 023/360] Fix test --- .../pipx/runPipXCommand.spec.js | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.spec.js b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.spec.js index 7f2130d..dd04dc2 100644 --- a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.spec.js +++ b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.spec.js @@ -77,24 +77,4 @@ describe("runPipXCommand", () => { assert.strictEqual(env.HTTP_PROXY, ""); assert.ok(mergeCalls.length >= 1, "proxy merge should be invoked"); }); - - it("overwrites user CA env vars and warns", async () => { - mergedEnvReturn = { - HTTPS_PROXY: "http://localhost:8080", - HTTP_PROXY: "", - SSL_CERT_FILE: "user-ssl", - REQUESTS_CA_BUNDLE: "user-requests", - PIP_CERT: "user-pip", - }; - - await runPipX("pipx", ["install", "ruff"]); - - const [, , options] = safeSpawnMock.mock.calls[0].arguments; - const env = options.env; - - assert.strictEqual(env.SSL_CERT_FILE, "/tmp/test-combined-ca.pem", "SSL cert should be overwritten"); - assert.strictEqual(env.REQUESTS_CA_BUNDLE, "/tmp/test-combined-ca.pem", "requests bundle should be overwritten"); - assert.strictEqual(env.PIP_CERT, "/tmp/test-combined-ca.pem", "pip cert should be overwritten"); - assert.strictEqual(warnMock.mock.calls.length, 3, "should warn for each overwritten var"); - }); }); From 28f34a8380e45ee0df749cfab2e7d01aa4a2a1e6 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 18 Dec 2025 12:09:28 +0100 Subject: [PATCH 024/360] Fix env func --- .../src/packagemanager/pipx/runPipXCommand.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js index 31d701c..235528a 100644 --- a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js +++ b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js @@ -8,25 +8,29 @@ import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js"; * * @param {NodeJS.ProcessEnv} env - Env object * @param {string} combinedCaPath - Path to the combined CA bundle + * @return {NodeJS.ProcessEnv} Modified environment object */ -function setPipXCaBundleEnvironmentVariables(env, combinedCaPath) { +function getPipXCaBundleEnvironmentVariables(env, combinedCaPath) { + let retVal = env; + // SSL_CERT_FILE: Used by Python SSL libraries and underlying HTTP clients if (env.SSL_CERT_FILE) { ui.writeWarning("Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten."); } - env.SSL_CERT_FILE = combinedCaPath; + retVal.SSL_CERT_FILE = combinedCaPath; // REQUESTS_CA_BUNDLE: Used by the requests library (may be used by tooling under pipx) if (env.REQUESTS_CA_BUNDLE) { ui.writeWarning("Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten."); } - env.REQUESTS_CA_BUNDLE = combinedCaPath; + retVal.REQUESTS_CA_BUNDLE = combinedCaPath; // PIP_CERT: Some underlying pip operations may respect this if (env.PIP_CERT) { ui.writeWarning("Safe-chain: User defined PIP_CERT found in environment. It will be overwritten."); } - env.PIP_CERT = combinedCaPath; + retVal.PIP_CERT = combinedCaPath; + return retVal; } /** @@ -41,14 +45,14 @@ export async function runPipX(command, args) { const env = mergeSafeChainProxyEnvironmentVariables(process.env); const combinedCaPath = getCombinedCaBundlePath(); - setPipXCaBundleEnvironmentVariables(env, combinedCaPath); + const modifiedEnv = getPipXCaBundleEnvironmentVariables(env, combinedCaPath); // Note: pipx uses HTTPS_PROXY and HTTP_PROXY environment variables for proxy configuration // These are already set by mergeSafeChainProxyEnvironmentVariables const result = await safeSpawn(command, args, { stdio: "inherit", - env, + env: modifiedEnv, }); return { status: result.status }; From 878e5492114d7a3e332c5fdbf5e5c211233fbdfd Mon Sep 17 00:00:00 2001 From: Thomas Becker Date: Thu, 18 Dec 2025 12:41:40 +0100 Subject: [PATCH 025/360] fix: use true connection timeout instead of idle timeout socket.setTimeout() is an idle timeout in Node.js (node docs)[https://nodejs.org/api/net.html#socketsettimeouttimeout-callback] - it fires after N ms of inactivity, not N ms after the connection attempt. This caused false timeout errors after successful data transfers when connections went idle for longer than the timeout period. Replace with JS setTimeout() that: - Fires N ms after connection attempt starts - Gets cleared on successful connect - Return 504 Gateway Timeout (more accurate than 502) Also adds proper close event handlers for socket cleanup. Fixes #228 --- .../registryProxy.connect-tunnel.spec.js | 14 ++-- .../src/registryProxy/tunnelRequestHandler.js | 66 +++++++++++-------- 2 files changed, 47 insertions(+), 33 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/registryProxy.connect-tunnel.spec.js b/packages/safe-chain/src/registryProxy/registryProxy.connect-tunnel.spec.js index b6b0ed0..ace84ee 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.connect-tunnel.spec.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.connect-tunnel.spec.js @@ -182,13 +182,13 @@ describe("registryProxy.connectTunnel", () => { const duration = Date.now() - startTime; - // Should return 502 Bad Gateway + // Should return 504 Gateway Timeout (not 502 - 504 is for actual timeouts) assert.ok( - responseData.includes("HTTP/1.1 502 Bad Gateway"), - "Should return 502 for timeout" + responseData.includes("HTTP/1.1 504 Gateway Timeout"), + "Should return 504 for timeout" ); - // Should timeout around 3 seconds for IMDS endpoints (allow some margin) + // Should timeout around 100ms for IMDS endpoints (allow some margin) assert.ok( duration >= 80 && duration < 200, `IMDS timeout should be ~80-200ms, got ${duration}ms` @@ -280,10 +280,10 @@ describe("registryProxy.connectTunnel", () => { const duration = Date.now() - startTime; - // Should return 502 Bad Gateway (timeout) + // Should return 504 Gateway Timeout (not 502 - 504 is for actual timeouts) assert.ok( - responseData.includes("HTTP/1.1 502 Bad Gateway"), - "Should return 502 for timeout" + responseData.includes("HTTP/1.1 504 Gateway Timeout"), + "Should return 504 for timeout" ); // Should NOT be instant - it should retry the connection (taking ~500ms due to mock timeout) diff --git a/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js b/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js index bde9c17..5eac381 100644 --- a/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/tunnelRequestHandler.js @@ -43,6 +43,7 @@ export function tunnelRequest(req, clientSocket, head) { function tunnelRequestToDestination(req, clientSocket, head) { const { port, hostname } = new URL(`http://${req.url}`); const isImds = isImdsEndpoint(hostname); + const targetPort = Number.parseInt(port) || 443; if (timedoutImdsEndpoints.includes(hostname)) { clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); @@ -58,64 +59,77 @@ function tunnelRequestToDestination(req, clientSocket, head) { return; } - const serverSocket = net.connect( - Number.parseInt(port) || 443, - hostname, - () => { - clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); - serverSocket.write(head); - serverSocket.pipe(clientSocket); - clientSocket.pipe(serverSocket); - } - ); - - // Set explicit connection timeout to avoid waiting for OS default (~2 minutes). - // IMDS endpoints get shorter timeout (3s) since they're commonly unreachable outside cloud environments. const connectTimeout = getConnectTimeout(hostname); - serverSocket.setTimeout(connectTimeout); - serverSocket.on("timeout", () => { - // Suppress error logging for IMDS endpoints - timeouts are expected when not in cloud + // Use JS setTimeout for true connection timeout (not idle timeout). + // socket.setTimeout() measures inactivity, not time since connection attempt. + const connectTimer = setTimeout(() => { if (isImds) { timedoutImdsEndpoints.push(hostname); ui.writeVerbose( - `Safe-chain: connect to ${hostname}:${ - port || 443 - } timed out after ${connectTimeout}ms` + `Safe-chain: connect to ${hostname}:${targetPort} timed out after ${connectTimeout}ms` ); } else { ui.writeError( - `Safe-chain: connect to ${hostname}:${ - port || 443 - } timed out after ${connectTimeout}ms` + `Safe-chain: connect to ${hostname}:${targetPort} timed out after ${connectTimeout}ms` ); } - serverSocket.destroy(); // Clean up socket to prevent event loop hanging - clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); + serverSocket.destroy(); + if (clientSocket.writable) { + clientSocket.end("HTTP/1.1 504 Gateway Timeout\r\n\r\n"); + } + }, connectTimeout); + + const serverSocket = net.connect(targetPort, hostname, () => { + // Clear timer to prevent false timeout errors after successful connection + clearTimeout(connectTimer); + + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + serverSocket.write(head); + serverSocket.pipe(clientSocket); + clientSocket.pipe(serverSocket); }); clientSocket.on("error", () => { // This can happen if the client TCP socket sends RST instead of FIN. // Not subscribing to 'error' event will cause node to throw and crash. + clearTimeout(connectTimer); + if (serverSocket.writable) { + serverSocket.end(); + } + }); + + clientSocket.on("close", () => { + // Client closed connection - clean up server socket + clearTimeout(connectTimer); if (serverSocket.writable) { serverSocket.end(); } }); serverSocket.on("error", (err) => { + clearTimeout(connectTimer); if (isImds) { ui.writeVerbose( - `Safe-chain: error connecting to ${hostname}:${port} - ${err.message}` + `Safe-chain: error connecting to ${hostname}:${targetPort} - ${err.message}` ); } else { ui.writeError( - `Safe-chain: error connecting to ${hostname}:${port} - ${err.message}` + `Safe-chain: error connecting to ${hostname}:${targetPort} - ${err.message}` ); } if (clientSocket.writable) { clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); } }); + + serverSocket.on("close", () => { + // Server closed connection - clean up client socket + clearTimeout(connectTimer); + if (clientSocket.writable) { + clientSocket.end(); + } + }); } /** From 6ce3791140d109a3c277b7cb1c8de999cad46e90 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 18 Dec 2025 13:37:29 +0100 Subject: [PATCH 026/360] Fix check --- packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js index 235528a..80d92b2 100644 --- a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js +++ b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js @@ -11,7 +11,7 @@ import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js"; * @return {NodeJS.ProcessEnv} Modified environment object */ function getPipXCaBundleEnvironmentVariables(env, combinedCaPath) { - let retVal = env; + let retVal = { ...env }; // SSL_CERT_FILE: Used by Python SSL libraries and underlying HTTP clients if (env.SSL_CERT_FILE) { From 287bd7a41ff42479ea5227431bb673ac3c130325 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 18 Dec 2025 13:41:18 +0100 Subject: [PATCH 027/360] Remove redundant comment --- packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js index 80d92b2..2f70cfa 100644 --- a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js +++ b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js @@ -13,19 +13,16 @@ import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js"; function getPipXCaBundleEnvironmentVariables(env, combinedCaPath) { let retVal = { ...env }; - // SSL_CERT_FILE: Used by Python SSL libraries and underlying HTTP clients if (env.SSL_CERT_FILE) { ui.writeWarning("Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten."); } retVal.SSL_CERT_FILE = combinedCaPath; - // REQUESTS_CA_BUNDLE: Used by the requests library (may be used by tooling under pipx) if (env.REQUESTS_CA_BUNDLE) { ui.writeWarning("Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten."); } retVal.REQUESTS_CA_BUNDLE = combinedCaPath; - // PIP_CERT: Some underlying pip operations may respect this if (env.PIP_CERT) { ui.writeWarning("Safe-chain: User defined PIP_CERT found in environment. It will be overwritten."); } From 41cc24d1f531729f047360f7398cbef0aa87536e Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 18 Dec 2025 13:52:49 +0100 Subject: [PATCH 028/360] Allow to configure custom/prinvate npm registries --- README.md | 24 ++ packages/safe-chain/src/config/configFile.js | 40 ++- .../safe-chain/src/config/configFile.spec.js | 89 +++++++ .../src/config/environmentVariables.js | 10 + packages/safe-chain/src/config/settings.js | 45 ++++ .../safe-chain/src/config/settings.spec.js | 249 ++++++++++++++++++ .../interceptors/npm/npmInterceptor.js | 9 +- .../npm/npmInterceptor.minPackageAge.spec.js | 1 + .../npmInterceptor.packageDownload.spec.js | 124 ++++++++- 9 files changed, 576 insertions(+), 15 deletions(-) create mode 100644 packages/safe-chain/src/config/settings.spec.js diff --git a/README.md b/README.md index 73735f4..7e764f3 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,30 @@ You can set the minimum package age through multiple sources (in order of priori } ``` +## Custom NPM Registries + +Configure Safe Chain to scan packages from custom or private npm registries. + +### Configuration Options + +You can set custom registries through environment variable or config file. Both sources are merged together. + +1. **Environment Variable** (comma-separated): + + ```shell + export SAFE_CHAIN_NPM_CUSTOM_REGISTRIES="npm.company.com,registry.internal.net" + ``` + +2. **Config File** (`~/.aikido/config.json`): + + ```json + { + "npm": { + "customRegistries": ["npm.company.com", "registry.internal.net"] + } + } + ``` + # Usage in CI/CD You can protect your CI/CD pipelines from malicious packages by integrating Aikido Safe Chain into your build process. This ensures that any packages installed during your automated builds are checked for malware before installation. diff --git a/packages/safe-chain/src/config/configFile.js b/packages/safe-chain/src/config/configFile.js index 23387f5..e13c1ff 100644 --- a/packages/safe-chain/src/config/configFile.js +++ b/packages/safe-chain/src/config/configFile.js @@ -7,10 +7,14 @@ import { getEcoSystem } from "./settings.js"; /** * @typedef {Object} SafeChainConfig * - * This should be a number, but can be anything because it is user-input. + * We cannot trust the input and should add the necessary validations + * @property {unknown | Number} scanTimeout + * @property {unknown | Number} minimumPackageAgeHours + * @property {unknown | SafeChainRegistryConfiguration} npm + * + * @typedef {Object} SafeChainRegistryConfiguration * We cannot trust the input and should add the necessary validations. - * @property {unknown} scanTimeout - * @property {unknown} minimumPackageAgeHours + * @property {unknown | string[]} customRegistries */ /** @@ -78,6 +82,30 @@ export function getMinimumPackageAgeHours() { return undefined; } +/** + * Gets the custom npm registries from the config file (format parsing only, no validation) + * @returns {string[]} + */ +export function getNpmCustomRegistries() { + const config = readConfigFile(); + + if (!config || !config.npm) { + return []; + } + + // TypeScript needs help understanding that config.npm exists and has customRegistries + const npmConfig = /** @type {SafeChainRegistryConfiguration} */ (config.npm); + const customRegistries = npmConfig.customRegistries; + + // Handle format: ensure it's an array of strings + if (!Array.isArray(customRegistries)) { + return []; + } + + // Filter to only string values (format checking, not validation) + return customRegistries.filter((item) => typeof item === "string"); +} + /** * @param {import("../api/aikido.js").MalwarePackage[]} data * @param {string | number} version @@ -142,6 +170,9 @@ function readConfigFile() { return { scanTimeout: undefined, minimumPackageAgeHours: undefined, + npm: { + customRegistries: undefined, + }, }; } @@ -152,6 +183,9 @@ function readConfigFile() { return { scanTimeout: undefined, minimumPackageAgeHours: undefined, + npm: { + customRegistries: undefined, + }, }; } } diff --git a/packages/safe-chain/src/config/configFile.spec.js b/packages/safe-chain/src/config/configFile.spec.js index 7da7e8d..f5c6df8 100644 --- a/packages/safe-chain/src/config/configFile.spec.js +++ b/packages/safe-chain/src/config/configFile.spec.js @@ -231,3 +231,92 @@ describe("getMinimumPackageAgeHours", async () => { assert.strictEqual(hours, -48); }); }); + +describe("getNpmCustomRegistries", async () => { + const { getNpmCustomRegistries } = await import("./configFile.js"); + + afterEach(() => { + configFileContent = undefined; + }); + + it("should return empty array when config file doesn't exist", () => { + configFileContent = undefined; + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, []); + }); + + it("should return empty array when npm config is not set", () => { + configFileContent = JSON.stringify({ scanTimeout: 5000 }); + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, []); + }); + + it("should return empty array when customRegistries is not an array", () => { + configFileContent = JSON.stringify({ + npm: { customRegistries: "not-an-array" }, + }); + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, []); + }); + + it("should return array of custom registries when set", () => { + configFileContent = JSON.stringify({ + npm: { + customRegistries: ["npm.company.com", "registry.internal.net"], + }, + }); + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, [ + "npm.company.com", + "registry.internal.net", + ]); + }); + + it("should filter out non-string values", () => { + configFileContent = JSON.stringify({ + npm: { + customRegistries: [ + "npm.company.com", + 123, + null, + "registry.internal.net", + undefined, + {}, + ], + }, + }); + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, [ + "npm.company.com", + "registry.internal.net", + ]); + }); + + it("should return empty array for empty customRegistries array", () => { + configFileContent = JSON.stringify({ + npm: { customRegistries: [] }, + }); + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, []); + }); + + it("should handle malformed JSON and return empty array", () => { + configFileContent = "{ invalid json"; + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, []); + }); +}); diff --git a/packages/safe-chain/src/config/environmentVariables.js b/packages/safe-chain/src/config/environmentVariables.js index 5c6056a..b11234a 100644 --- a/packages/safe-chain/src/config/environmentVariables.js +++ b/packages/safe-chain/src/config/environmentVariables.js @@ -5,3 +5,13 @@ export function getMinimumPackageAgeHours() { return process.env.SAFE_CHAIN_MINIMUM_PACKAGE_AGE_HOURS; } + +/** + * Gets the custom npm registries from environment variable + * Expected format: comma-separated list of registry domains + * Example: "npm.company.com,registry.internal.net" + * @returns {string | undefined} + */ +export function getNpmCustomRegistries() { + return process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; +} diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index e1cec34..1f4a058 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -98,3 +98,48 @@ export function skipMinimumPackageAge() { return defaultSkipMinimumPackageAge; } + +/** + * Normalizes a registry URL by removing protocol if present + * @param {string} registry + * @returns {string} + */ +function normalizeRegistry(registry) { + // Remove protocol (http://, https://) if present + return registry.replace(/^https?:\/\//, ""); +} + +/** + * Parses comma-separated registries from environment variable + * @param {string | undefined} envValue + * @returns {string[]} + */ +function parseRegistriesFromEnv(envValue) { + if (!envValue || typeof envValue !== "string") { + return []; + } + + // Split by comma and trim whitespace + return envValue + .split(",") + .map((registry) => registry.trim()) + .filter((registry) => registry.length > 0); +} + +/** + * Gets the custom npm registries from both environment variable and config file (merged) + * @returns {string[]} + */ +export function getNpmCustomRegistries() { + const envRegistries = parseRegistriesFromEnv( + environmentVariables.getNpmCustomRegistries() + ); + const configRegistries = configFile.getNpmCustomRegistries(); + + // Merge both sources and remove duplicates + const allRegistries = [...envRegistries, ...configRegistries]; + const uniqueRegistries = [...new Set(allRegistries)]; + + // Normalize each registry (remove protocol if any) + return uniqueRegistries.map(normalizeRegistry); +} diff --git a/packages/safe-chain/src/config/settings.spec.js b/packages/safe-chain/src/config/settings.spec.js new file mode 100644 index 0000000..05d698f --- /dev/null +++ b/packages/safe-chain/src/config/settings.spec.js @@ -0,0 +1,249 @@ +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; + +let configFileContent = undefined; +mock.module("fs", { + namedExports: { + existsSync: () => configFileContent !== undefined, + readFileSync: () => configFileContent, + writeFileSync: (content) => (configFileContent = content), + mkdirSync: () => {}, + }, +}); + +describe("getNpmCustomRegistries", async () => { + let originalEnv; + const { getNpmCustomRegistries } = await import("./settings.js"); + + beforeEach(() => { + originalEnv = process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = originalEnv; + } else { + delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; + } + configFileContent = undefined; + }); + + it("should return empty array when no registries configured", () => { + configFileContent = undefined; + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, []); + }); + + it("should return registries without protocol", () => { + configFileContent = JSON.stringify({ + npm: { + customRegistries: ["npm.company.com", "registry.internal.net"], + }, + }); + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, [ + "npm.company.com", + "registry.internal.net", + ]); + }); + + it("should strip https:// protocol from registries", () => { + configFileContent = JSON.stringify({ + npm: { + customRegistries: [ + "https://npm.company.com", + "https://registry.internal.net", + ], + }, + }); + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, [ + "npm.company.com", + "registry.internal.net", + ]); + }); + + it("should strip http:// protocol from registries", () => { + configFileContent = JSON.stringify({ + npm: { + customRegistries: [ + "http://npm.company.com", + "http://registry.internal.net", + ], + }, + }); + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, [ + "npm.company.com", + "registry.internal.net", + ]); + }); + + it("should handle mixed protocols and no protocol", () => { + configFileContent = JSON.stringify({ + npm: { + customRegistries: [ + "https://npm.company.com", + "registry.internal.net", + "http://private.registry.io", + ], + }, + }); + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, [ + "npm.company.com", + "registry.internal.net", + "private.registry.io", + ]); + }); + + it("should preserve registry path after stripping protocol", () => { + configFileContent = JSON.stringify({ + npm: { + customRegistries: [ + "https://npm.company.com/custom/path", + "registry.internal.net/npm", + ], + }, + }); + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, [ + "npm.company.com/custom/path", + "registry.internal.net/npm", + ]); + }); + + it("should parse comma-separated registries from environment variable", () => { + delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; + process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = + "env1.registry.com,env2.registry.net"; + configFileContent = undefined; + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, [ + "env1.registry.com", + "env2.registry.net", + ]); + }); + + it("should trim whitespace from environment variable registries", () => { + delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; + process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = + " env1.registry.com , env2.registry.net "; + configFileContent = undefined; + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, [ + "env1.registry.com", + "env2.registry.net", + ]); + }); + + it("should merge environment variable and config file registries", () => { + delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; + process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = "env1.registry.com"; + configFileContent = JSON.stringify({ + npm: { + customRegistries: ["config1.registry.net"], + }, + }); + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, [ + "env1.registry.com", + "config1.registry.net", + ]); + }); + + it("should remove duplicate registries when merging env and config", () => { + delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; + process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = + "npm.company.com,env.registry.com"; + configFileContent = JSON.stringify({ + npm: { + customRegistries: ["npm.company.com", "config.registry.net"], + }, + }); + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, [ + "npm.company.com", + "env.registry.com", + "config.registry.net", + ]); + }); + + it("should normalize protocols from environment variable registries", () => { + delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; + process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = + "https://env1.registry.com,http://env2.registry.net"; + configFileContent = undefined; + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, [ + "env1.registry.com", + "env2.registry.net", + ]); + }); + + it("should handle empty strings in comma-separated list", () => { + delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; + process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = + "env1.registry.com,,env2.registry.net,"; + configFileContent = undefined; + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, [ + "env1.registry.com", + "env2.registry.net", + ]); + }); + + it("should handle single registry in environment variable", () => { + delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; + process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = "single.registry.com"; + configFileContent = undefined; + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, ["single.registry.com"]); + }); + + it("should return empty array for empty environment variable", () => { + delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; + process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = ""; + configFileContent = undefined; + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, []); + }); + + it("should return empty array for whitespace-only environment variable", () => { + delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; + process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = " , , "; + configFileContent = undefined; + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, []); + }); +}); diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js index eaf50db..d7c13c0 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js @@ -1,4 +1,7 @@ -import { skipMinimumPackageAge } from "../../../config/settings.js"; +import { + getNpmCustomRegistries, + skipMinimumPackageAge, +} from "../../../config/settings.js"; import { isMalwarePackage } from "../../../scanning/audit/index.js"; import { interceptRequests } from "../interceptorBuilder.js"; import { @@ -15,7 +18,9 @@ const knownJsRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"]; * @returns {import("../interceptorBuilder.js").Interceptor | undefined} */ export function npmInterceptorForUrl(url) { - const registry = knownJsRegistries.find((reg) => url.includes(reg)); + const registry = [...knownJsRegistries, ...getNpmCustomRegistries()].find( + (reg) => url.includes(reg) + ); if (registry) { return buildNpmInterceptor(registry); diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js index 999e64a..fb7ae56 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js @@ -9,6 +9,7 @@ describe("npmInterceptor minimum package age", async () => { namedExports: { getMinimumPackageAgeHours: () => minimumPackageAgeSettings, skipMinimumPackageAge: () => skipMinimumPackageAgeSetting, + getNpmCustomRegistries: () => [], }, }); diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js index a90432e..88fcbd0 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js @@ -1,19 +1,36 @@ import { describe, it, mock } from "node:test"; import assert from "node:assert"; -describe("npmInterceptor", async () => { - let lastPackage; - let malwareResponse = false; +let lastPackage; +let malwareResponse = false; +let customRegistries = []; - mock.module("../../../scanning/audit/index.js", { - namedExports: { - isMalwarePackage: async (packageName, version) => { - lastPackage = { packageName, version }; - return malwareResponse; - }, +mock.module("../../../scanning/audit/index.js", { + namedExports: { + isMalwarePackage: async (packageName, version) => { + lastPackage = { packageName, version }; + return malwareResponse; }, - }); + }, +}); +mock.module("../../../config/settings.js", { + namedExports: { + LOGGING_SILENT: "silent", + LOGGING_NORMAL: "normal", + LOGGING_VERBOSE: "verbose", + ECOSYSTEM_JS: "js", + ECOSYSTEM_PY: "py", + getLoggingLevel: () => "normal", + getEcoSystem: () => "js", + setEcoSystem: () => {}, + getMinimumPackageAgeHours: () => 24, + getNpmCustomRegistries: () => customRegistries, + skipMinimumPackageAge: () => false, + }, +}); + +describe("npmInterceptor", async () => { const { npmInterceptorForUrl } = await import("./npmInterceptor.js"); const parserCases = [ @@ -161,3 +178,90 @@ describe("npmInterceptor", async () => { ); }); }); + +describe("npmInterceptor with custom registries", async () => { + const { npmInterceptorForUrl } = await import("./npmInterceptor.js"); + + it("should create interceptor for custom registry", async () => { + // Set custom registries for this test + customRegistries = ["npm.company.com", "registry.internal.net"]; + const url = "https://npm.company.com/lodash/-/lodash-4.17.21.tgz"; + + const interceptor = npmInterceptorForUrl(url); + + assert.ok(interceptor, "Interceptor should be created for custom registry"); + + await interceptor.handleRequest(url); + + assert.deepEqual(lastPackage, { + packageName: "lodash", + version: "4.17.21", + }); + }); + + it("should create interceptor for custom registry with scoped packages", async () => { + // Set custom registries for this test + customRegistries = ["npm.company.com", "registry.internal.net"]; + malwareResponse = false; + + const url = + "https://registry.internal.net/@company/package/-/package-1.0.0.tgz"; + + const interceptor = npmInterceptorForUrl(url); + + assert.ok( + interceptor, + "Interceptor should be created for custom registry with scoped package" + ); + + await interceptor.handleRequest(url); + + assert.deepEqual(lastPackage, { + packageName: "@company/package", + version: "1.0.0", + }); + }); + + it("should handle multiple custom registries", async () => { + // Set custom registries for this test + customRegistries = ["npm.company.com", "registry.internal.net"]; + malwareResponse = false; + + const url1 = "https://npm.company.com/lodash/-/lodash-4.17.21.tgz"; + const url2 = "https://registry.internal.net/express/-/express-4.18.2.tgz"; + + const interceptor1 = npmInterceptorForUrl(url1); + const interceptor2 = npmInterceptorForUrl(url2); + + assert.ok(interceptor1, "Should create interceptor for first registry"); + assert.ok(interceptor2, "Should create interceptor for second registry"); + + await interceptor1.handleRequest(url1); + assert.deepEqual(lastPackage, { + packageName: "lodash", + version: "4.17.21", + }); + + await interceptor2.handleRequest(url2); + assert.deepEqual(lastPackage, { + packageName: "express", + version: "4.18.2", + }); + }); + + it("should not create interceptor for non-custom registry", () => { + // Set custom registries for this test + customRegistries = ["npm.company.com", "registry.internal.net"]; + malwareResponse = false; + + const url = "https://unknown.registry.com/package/-/package-1.0.0.tgz"; + + const interceptor = npmInterceptorForUrl(url); + + assert.equal( + interceptor, + undefined, + "Should not create interceptor for unknown registry" + ); + }); +}); From e3aa2e15cb0471ef14c459bdbc6ba80e0dffdfff Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 18 Dec 2025 17:59:15 +0100 Subject: [PATCH 029/360] Add npmjs.com to known registries too. --- .../src/registryProxy/interceptors/npm/npmInterceptor.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js index d7c13c0..3d3b8b4 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js @@ -11,7 +11,11 @@ import { } from "./modifyNpmInfo.js"; import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js"; -const knownJsRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"]; +const knownJsRegistries = [ + "registry.npmjs.org", + "registry.yarnpkg.com", + "registry.npmjs.com", +]; /** * @param {string} url From deb0ad542876a76192cb779f4a73b9a4fde3e6ba Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 18 Dec 2025 18:03:09 +0100 Subject: [PATCH 030/360] Create a single emptyConfig object --- packages/safe-chain/src/config/configFile.js | 25 +++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/safe-chain/src/config/configFile.js b/packages/safe-chain/src/config/configFile.js index e13c1ff..b52b36b 100644 --- a/packages/safe-chain/src/config/configFile.js +++ b/packages/safe-chain/src/config/configFile.js @@ -160,6 +160,15 @@ export function readDatabaseFromLocalCache() { } } +/** @type {SafeChainConfig} */ +const emptyConfig = { + scanTimeout: undefined, + minimumPackageAgeHours: undefined, + npm: { + customRegistries: undefined, + }, +}; + /** * @returns {SafeChainConfig} */ @@ -167,26 +176,14 @@ function readConfigFile() { const configFilePath = getConfigFilePath(); if (!fs.existsSync(configFilePath)) { - return { - scanTimeout: undefined, - minimumPackageAgeHours: undefined, - npm: { - customRegistries: undefined, - }, - }; + return emptyConfig; } try { const data = fs.readFileSync(configFilePath, "utf8"); return JSON.parse(data); } catch { - return { - scanTimeout: undefined, - minimumPackageAgeHours: undefined, - npm: { - customRegistries: undefined, - }, - }; + return emptyConfig; } } From 9f93763b983f2a8780da2fd473f5366f6e0b2b23 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 18 Dec 2025 18:18:45 +0100 Subject: [PATCH 031/360] Handle code quality comments --- packages/safe-chain/src/config/configFile.js | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/safe-chain/src/config/configFile.js b/packages/safe-chain/src/config/configFile.js index b52b36b..1b7525b 100644 --- a/packages/safe-chain/src/config/configFile.js +++ b/packages/safe-chain/src/config/configFile.js @@ -97,12 +97,10 @@ export function getNpmCustomRegistries() { const npmConfig = /** @type {SafeChainRegistryConfiguration} */ (config.npm); const customRegistries = npmConfig.customRegistries; - // Handle format: ensure it's an array of strings if (!Array.isArray(customRegistries)) { return []; } - // Filter to only string values (format checking, not validation) return customRegistries.filter((item) => typeof item === "string"); } @@ -160,19 +158,19 @@ export function readDatabaseFromLocalCache() { } } -/** @type {SafeChainConfig} */ -const emptyConfig = { - scanTimeout: undefined, - minimumPackageAgeHours: undefined, - npm: { - customRegistries: undefined, - }, -}; - /** * @returns {SafeChainConfig} */ function readConfigFile() { + /** @type {SafeChainConfig} */ + const emptyConfig = { + scanTimeout: undefined, + minimumPackageAgeHours: undefined, + npm: { + customRegistries: undefined, + }, + }; + const configFilePath = getConfigFilePath(); if (!fs.existsSync(configFilePath)) { From 1084abe179dde4a709d3277ed42a4aceede67122 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 19 Dec 2025 10:38:05 +0100 Subject: [PATCH 032/360] Add demo gif to readme again --- docs/safe-package-manager-demo.gif | Bin 28651 -> 28114 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/safe-package-manager-demo.gif b/docs/safe-package-manager-demo.gif index 9d22d8c7dac3faf04c4e59eac6d1737a90e4c2b4..10cf9722e07a3da197d874e5983dddadfe34556f 100644 GIT binary patch literal 28114 zcmZ?wbhEHbY+%~X@LisPfq_AVfx&};A%}rs0t3Sq28J6941d6)Dk=;fDhxR)3=>os zwx}@NP+|B3R;uE`;NiiLkL2+d&#oxl*f zg(36?L+BrdPzIGy6_rpAmCziO&&y2T^( zhDYchk5Go3P?elekDSn)oX`n5p<8l7Z{&pj$q8kc5UMgE)MG+u&VTP- z&y7%qKcOmrLOuS3=KKkr@F#T3pU@kBLjU{;WnfsP!m!GNVO0*pstF9MwlJ)^!LaHN z!zu=qRVpg0JXBWYsH~cxvTBRUsv9b+{-~^C@K~kdvC6|^RgTB12_CDqc&xhNvFeY< zDu$d@Dmkk>a#rQ!teTLsYD>rFk#h}39D{QSoLSZ zDuykqRJN@0*s>~T%c==mR&Cj`>c*B;f3~b*xUov*#ww2+t8#9vns8&)mK&>X+*tMJ z#wvzCt5p81^7ykV=g+DMe^zbzv+Bm5Re%1hVqo~M!tmdN;eQUp{|OBLw=n#_!SMeN z!+!>q|0*i~JyibZsQjOx@_&oU{~Id*|ETKTN@&Av< ze}a{lM!{GX8Xe@o8)8#({~m{%_gx|HhX8f42N*xba`*#($3+|8s8qpK#;=@u`q-Vg)$iTp$_>+Z& zi$R`22b4KL*@S`PKLcaVq)94{2bN z$H{13TF}(buPPG~Y1HX9%`@RtjOXPCKC?}epY2&Gy!`w;hf1!yo?o5^EOu>Hvt1>! z^768PnO?D8Utfl-4qcqKca`Ys>+2GBcAcHYz|dgmaAsBP>1}IoZ!dU!YOZ&A-pyUv z%p0y+PuIW0lKGKK&S%Glhle|awd2n0*!cMP1ZD4ednzu+o~rf##VX+7IAPyxW)@C| zsN6$a9c@nt{|I~c0zGMA?O6y9s=a$7A zlUli0-_)r-xF;SOBFG>f*KqtgQ<_ep9MjF6>8_0o`=*{y^K@DMuuZ1y#lv=mHjSC7 z*^=tw@s2TR0nrCnPCZh=b;s?f(6-PEoeW+AI~>Y)F)o-H>0of-sQ7%J1>t?6IvZSi zxJrVKGK5xaYG4QrIOtIBGefmu^3R!~4nl@Z3@ww^32HEi#tCdtV&E=uYGl|p)AXPS zPk`fttUShzohnIPoDtC+64S*5>bo*tEbLHQ^GQ|Zb9%y{>_1I0O z*ocR&Jx&FMtc_t)j)gPC>Nq$yv6mS#EMvL!U;KcOh{?y+W(JQ3t!tuYCB76&^O-5j zApGXl;)<}44RIBZBNb)RGn%%~XT595p;IqB?U*F%w1(;7ok2O16&b|6l=Bt@c`VXv zV^CP=sWi#su=?UJ*RtR5{qgMdHH|eHN)ZgfHI2FAOd$_AnIDBVDmR8#88$ElS3G28 zHQD6p%KGD{`hg13h{O~IFOAIBDK&-m@vWb#?nxY^Z*g(0X%=fm*~vxm1* zqlI`J4%{y_cbFY#VrXCI@}ZD(YYy`nc*~3VPlwyz(Vn*od0jMt__H?Qdq~Sv7uq@A*KUI zBzI^Xa9JB4V0hsCJgzlw1*ZD(9bmYZx$ooT%`>G~e)J%-i3UUS55|Uuif66w zmPU#zZHyA~IHJud5FNwYB6dloC{f9QNB-N*6YA^#eEOAlh|gMR%hJg#K`UJLI!>GY zRp|_yxy?=?*U4#(qN$BO}Ktadyy^IqhG*8H@YKJ{n#w*ORPofoj+dUqH{ z@FlGY^UXBZ=swnrKhpNVNNvU$k6+gO4Emp^`FbRqzU;bwWz{lM2A8C)m~JM49crzQ z4d1tD_$=g_>)ci7(x}UMgGJNKkxlYaKpX2r{<1sISkG~+wSLekQTx9lVixC;F2{gR zQ&tx}?PN1(C}c`-s{FK>MPI;AcWHD?4@&@>H|Ij`NzvU52WH5hYkX&P^ik+b27$F# zEIl};TJ;M&71CQZQ#EFHcavG!@^vZQ1Uc|M2qYn)_JoqN7+y<_kNnPc`nkq?FcMIR8+QP`m7z+=W195H*%g_d`@ zZ-m7z+!Q*Kbl}f{^;{w=nwS_A82l{GcsnFF9AQh?w5a!td9;;U%#3B6k5vvf1#VVg zUnZ8xxO^2!sQjco+Fs9u3*k@aQWTR5qa;`5opF6*FKBM>Gln$qZaJ@&d!jBcHT&zm` zlm8tvtckng^v39H>lIdg+mqf|+ja+-m@L#QzV&69MXX%sjZLk(b3Dv${^a3loh80c zBf+y^&7s%^h1p_>$Gu&q-p$z(s<`0Y)5xH=!DWAhB)IHE^W~r2nZO<5 zcm340ZI`%pU&cHyQdlCsTI$m3%3Ccvu1(_bv$?dkfp<-U>6Xh|a}F(Uei|So_EO1H zL~G-O-Zd%G#%#A+{pNNBPW1ho6u#XdYtsVWwHfNc5nE-<41+RQB|R>31#L+bUK|^h zlkR={*8f$lJjV~~BrQqk4!f~PcCTu4ZoBuLM{V1-q*><1gg6=RVM^eBo8em0=*{&w z>ie!6M>VY~wZgZnJdp4E!=7{dQ1ty-+jrgcU0-$mw9V-j-<|hVkJ{gIY^&d*wEbdk zYf1F!d;flY-~X*iyZqrKmgql+#lFqZk2`()_5-FL2L<{J+9!A$9|`*)-}|S%c;W8& zS6)93EAV|xVc8?584#hnWP>}?li$-mz1qJ0h3Lkfbf4rs0TLXm?;c9zzkT_z+6)o4 zZ6Bg6Y+|F2>^SD(XEdpB_VRhFE}sm#(Ny#HViyZ{q%P03^;uKTJk@Leais8Pp7x{< z{mT56yDph-ntuIE+W)-p{{~AYIPvU`e#AC^``LYv1NoM;p}q+qUC4uXxJj-*@isGuw6jZ%*;%-*+#B zneTbdo0EC@_r258%=dkl&CdJ$`~Ge|^8@U4+2z829xT6Ren{LequKb+!=5mUBkJ!` ztAqbMYMEwn%v>+IzWC4MSTTze?(0(8C;xdO^#7X0sqlHRlQ;i)`a0C|OnO{&_vJs& zUQM+;SFRV?|M}1JZK9SJ+Wn%Z3;%u5CueqP`n-t6#(!U0T(!Kie4o?o;J>c|!>z7u z@AH^n{P%UMh}Dhb`<#|f{`)3nvem8Y>ohiR{`>YzkoBGC?-W;G{`>ChB3iJor~Pq1&n^G`Zt?!g`@!=+AK(A)!NT=_x2Es^UGv=T|BsXR{(MxA z|Mo2X&zH;n6|XnPe_vPs|M5}%|Eufw|I1!(|8HU3zrX)u|Nq-w@>;y1{COQqNBQ^i zh8Xq+E{+n;8x7&d8w7R~|21#SQEwFUC}y6~n3CQgQ&GnMqtTld@;1dd`ei#Fr**0di5@iW?9x3%ST z6lDKsdvdI`$fKw*qW!*FTg8vU)E(^#&)e#Dftw2gt0fsuiMgN1>ag$2~u#3#YZpaW`>)-i2k0JlOp zWIQ%3IM~b~%*0SMVd0^60dA`vkBy6tc1sv%-8r#w@$r5I=Pnt~O-oKr)^HSyIJs%* z>FEZ^r{;KWUgkTSF>e#gg3YSuXL?p~$$D*BadEN7WUbg!TUK6P9VvB<6D?c&sovQ)K(c$dRvPpZ&|sN$uhGU-;ch&e}4b||4Rb}OkEg*MHJ?GA6si8 zAIPy|TTqy`VFH`?rn6y}6E~baa;HT1>h_>kUC+RV_Wx5e9(8KGGv~5>y@%zI7vnCI z2u5zVZ&L$>1ybXJv!<#l`B>lc5YA&wV4tj_skZXTB%icvNzuF;zOZ_;ZgXk%HQg3= zph{PE+8l3=15YBR>iOOXnqug9VmT%#3X5^L-Zpby`SaP5DPgys^n0{=PM)imdc$R!p|0YAHuiN_G@??B zwkA$5W8<2<%z~UAy0IyHoc1?W9{`25T2Dtc^GjslVvO)g8y{UccM@K}l3Em|fM8 zP4oMXG~Ekoxrm8#-svkE^mPkhXvgKyg6XXtG&$8bIYZ^V=|w$ zKOR?TpR;7Yi|iDKLmJw1)}7GV{^nCYv+dRZ1_q470Ba1`kKKb29mNK3_>_*Ddo8J*|CEjgz5Zn~3J( zJ)dqA9OwOhz$SOQYoNvCgA?}Vy#N1Z>zxL6yYCnDZW;w&%Shkb%Cf}d?uG{w+~rDc zx{+9&hZ|NE7bb^hP)5C5N6{rPfze$uaR&)3`M|9JoY|Nm((1bORb%H%yier$lRL6 zGTEU=EpKfsE!?wMZuXSpw!aLk>v>Z4{L(n#^lVjMn~#dT;K~zj_xdOH^(^_L;_3NC zaMPq2XT(&DPkPSC-86Z@m9whOo~O3=Y?`uS&MD2{lc%oNZkoEG=d^aR=jm-J8>j8K zb4IK9(jJ8!lgYu8iyQ+z&P}R*zqvsk|gS{n?)7N}pH$_jmrZ zd4AItb%)JA&o{o?xPa}dvXgMu@m8adKEB!&(#l$ara@a4rHN>`S)V-DYxa4O+*c(J z=T%21PP)>iX1h`(cvX=4rY%dou4wp3-@G{U+T~^PQ#JjVS6!a_@8eRtyNUtLRfiV} zmA2X1zLL#ebtOb<>q_k!tq^~Wt1J69tP1<95Vm>O!L>zSn>_z!x$V^owrw_fqLUH&s>LK zaMgp&$(u_y(lte?`^^%EIguMyI_i6$*U=Rye#kIkF-MxqiqzMq6xk|;<^+7?_SzzF zT#CiWVDXZ!H8*tEUbwI-bdHlinc|CL)%&ft1=I|+h1MO|&u_2TVl&~;VFr0k|BzEV z93C~r8aAaIy7@S?h2ch-+li&`j_GnN7M-#?D&ax*!ff*;t9Nfdcke(mx4hq;q<2$< zChyky@G14u-@kV~41Qj>?|;=FhFPw%0t_mOjE$eRI?uQL&-x<4>%OP~t7LG1`7yER949u0 zB{SOPs(9@*7I`r;Y&a>#pyJTQ!g=X>U#;@7jvyw62@_dLqdgAZZ{OCU@wi`n>5fOG z(OXs|SlcoEOxFHyvxT$QEXci0B&FiT{7Df}EG|q^Y7-ZBeU{}+o|a<9qS_YiaX_(O zVcJVIXQxXZ%I%5U4n3G%u_)|u@72(occ)geMTIvOzngw<;kO=5jd{Nq15_kuPdIcp zsawxVESJeM{N=JaWv^Z?U(lxYYQ>UiS+7>EShnibsx{kQy;{BD*jBCAYqo%DlpW7j zy53p&!*>FTG`_0A^YOCLDI%D?w&E^Yk+HbdjYm^<06VY_6Kd+?{++y zmi=z$i)E|d?Rv8fRHGcze!mAguCe<4zCYhyzu(WmuJhpli+Ik5gBJ{;mPe*>yf zbUq#t3D5a>R3d%N$73?(Z$LGQ&ZiS9({nzZ)L6de(13h2Yp!^IHp7goOaOUR=yCc!K&%K-9=a^@x-);Ax&{Q?^G)` zJ0$hzy?nHGhhOeNt$yw*L4{AJz1yApf?Q|pJkYSkLYMzao4^`}-K(U{>@0RWv^H-S zuJeouT`>K;xFz?QwE=GquGpx`nG$1)?(w$WI@|lm&9L=L*3^AFDCk5|Y`P?VPN`rM))~XdL-PK~J z7J3$1c`To6UX=M_aqi9^4`!rXseGoEyT(&v$s9A5r499JAD4^F&go2EzCtM^(c45x z^Tni<$3E>6=#?>(TC`?IT1FD%w}uzlvv;2I($y?o3+^<(7(${{y7E}KA>-B_oUGUJ|x0@Nu z*M7T|v-{7>la2U@PMh^|oNhwV%o=K}xPHqmJq-zj( zZjI%n5Eo93@@*+EH!cpCrN*knx^mOip!tk*nnXOWObcA;x!y>W<4NZAF!jUVWTZu& z1k8%R9p;m?IyN@?+lEt1XCw%(?5#6b<$%>mEDSpA4D1XZ3{ngX9B&zz1tg%o9B7>+ zpzH;%lfb28Ywr)C`$scowUzG+PoiQOViM>r9ua>V=%Er^2kG zU2Ff%@i1o8T*%-dnR~Tw)up9^*=iC|TUM`K9U*^?Ws<^4pXDK!w=qj^d+4J%JIZsW zr^w3KZMovt?rb^w?8>U_&s|G*rs_59iu=grwqv{gtwjx@`eqTEH~6miR5zY8)8)`P z6~F&cSJ#L)i0HO%S5fTxq5X2QY34H(sh_2XZfr8#y{%SY`~Ai4Z$0bYN;Ay#T~emx ze`vS4lL7}b6Ev<^8FZK#7#TJ)Y{p(Ib3kfk*LFrdz9};d9y|3*f%|zWjuX^^SIJ1W zENGf)kf_$NQ^4aCt4Z-QmdsRDp}DNmIyIV?xC5HvX4$-%bZOZE6{~c(*I9uht3p+q8iN(M3^+p- z?zD6~i(0Z_gL+`&JTJ4vH9Lx&=A>x{ZM^j{@zz9zpHXtQg$0Z&6D7-3buVN(USE@Z zdYgu~CM&~x~Zq=*t z-W_&-?d1QL{NyygKe~K*`{B5Ke;zY!FrB9_C$~rU+1t;`NBZ|atbBQe^~AioCu`FJ z(s<@Rm#z5L>;9lw>F@M;4Fc1?U-kH`>v6aIZ|I6?wu?O_9+b*$TkhI&M^|E=F5k2l zF$wCtTaG$&`FSkrwosdqRC}#-#^ZjLPK#LW8=%_R!v zizmA;yCyX)rtIa@=?QI`&t{}d%X~I7W7*1Q;C|lhf@7M`=agK_d_K40*~;hhYQDXE zKEHuY>&1c=u`K9pLyy_37mFvjX}w%BB`oXZ(iv&1@Yc$Sw5*k9vz?McDk18zjz@qS zi3|*zLG!^J{}~XCL@|a1phhBRBcuieXUG|b44`2LzLXP&jgFJJMZ*Ll4IBlg86=u* zWSV@+S&f4{mw}b1A(Z#~^i-a|}4oYM`c-AyYMD3B~bk$1- zn3>=F?b=;_Xgf=BUdp9{&KL7JcxK%>q55RnqGoNaumuha&Yxs4?*9^7@aggk>4WnB zW6tf}Q@QK>`PK2~_x<&6`@lYH&ij4)YTFq;y=I@VL{!sg$q#q7vhSi9_v?OnUg*#lg3YnMFUAq1xYCLXb znRP-@lj~tbC{y$VMOI1uJ`JT7vuP2^sZt-^y@Ye11;WU=GR*6H&_q9;sR)U)oO{LbCjIh~Ks^Z9(GkK0>C zgmN|=oh<3bnbDalbVSKDV$;r}VzG5MyJmE!acQ=8oLc#ExmfLq%+&{ZBaws}^#9eZJNn&_Ys(M@eg;7XAqEDHRiN5Z zph3a0k&{WqCqkg%a65y?R;8p$slXf`A~CS`6GB})ef2F0H&tXvGxDl`>bh5lz?#8!p=C}6@mP#C-_=H#SB2d1+K z3rx`5ywsP$qL}H1(<;%GX13-x_sleAY+#Ab*}D0vm*&icy2V~Mj<^cVEL*!lPn4@v z^&HFWB{IIZwoJXbJT~-^j}sfmIo+tUw;s(BQ*F>l@hF_wG-c)evW(ZW{%9@j5n*-M zw&V1y(sc=w*K*X{O(|J^e^aA;|Cy85-k#gx`!b6!((6;;seFBF+1Mi8nJW2@^HOz- zx?i%?1W&j1uHJm^MvwD%v7gc3@1HqrEp|WCe`WE-la_kZow9^y`Y!Rf*~b_3z)^t} zUa8_7Ze@kc?Qdq-guS*E;AiRt^?|{~8+cTei@AYiX3CP2Ek0Z!mYD(#j*L3Vr@rhc zdgj1pCV1`4Pr-u@`pmmIwjA1=!8nn%W80iZ0kZ>p4EAfd{`#VFp_N(6@63*=3ZboB zBJb8neO~RO!nIvQr_4OLrTUN>YsF;Dk)0#i_~ z$5tiRwka$(%;bfa`TkB=9k%V(O(#2v9iMw=3kj@`{ZRHP?CpmCb?0Osm%L)_*Z(hn zf6s4sl|${P{gf0rel%`Bnw}qiZ%^$x#w8POI;nZ4H$GBqmx=x^S`i@4v~#*kJ%h)= zR&R$bVv3sA&u*XFC?VCtz!06INII4C;QvE>%%eogT3=Rbk7m5h|NuS&QHs-~Wz^s`n z(M)FZe#A3s8f||%?S7=EV$o;T%CyEzEz5fi`RgRoOGUJn&#{fVR(XFe>r@Y}{Mi#O zEt${O*BKDrL0WA)mzK3H1%2S6wsi}o3D(ripa}wQhGvH4;H1osF`Tm-T2pg3CZJ3Z zcy=?eE%DJbIOs54J^7Ri$AV>NXGKR%RJ__1GF#WUnQd=o@#M8(+iuCKDsNfoxyWq$ zmTc3|mukx++?`lAJY5k!H}Y`Ro|z7wOzXa_P}^D>x+c2E!m{wD(m{<&yM6Azni~g6+I1Dy5*Kj z*A>47=lwV9tT5aSnjq+2E$_c?&(F^HjM`j zEMgfC8adQfJZR!Ed-0%Iz)j;}i%3|;!&Zs36%XOdR33GxOao02EL-uYOK01QN8JX; zG#>YuT+4XeYw>Kw<35{jFCO!}q|M9E&v$6%lJzptXlQh2Rg>7UWkql^!y+%%3E;NM znn{(XLLY>z47znQE7eFeqU4F=5=j^sM&?Zw)=rqm$#z*{M~l{UM9x0IeYTMnjgi5^BGh;I;|oqvL2nUt>4=t zz3$x`i|z4p&%DD-e@uUr-z}Pb{%7Uec-5G@F01~jy?I|NZzBoI3#_0ZV_}%UAPWvM zMB^0PTXb#@lY{ma`IrL^f=h1+aTAsc1so?PYXr}`vgp&I6HF@Nei@#dm$|T+6~DT3 za`SSpi5xXtTW_Q$Tv&)SCw8revpAuNDSW-x-6+9^>+2bWCS<4ZP33-tt}vb*sSK;BpMU8k)v37LbJ7W)~_3vmtE}O z_3DzzSgO#VDX?AEr{&3uGYi55gC5S9yx`2FTYO$;Zxt~*vZlz_&0V=Pamp6fX-$7_ zPGCB|h~ur=6z=W$PHMG#fA~HLU-)2ghHzCEM@7Q*LwX;RVorWEi0SHm%-6Cj&?x1E z3UAdmkMG9rr+9VU>5j$dgU9R-Q(e%(3fw$%pYIXjq9aTI3(>$(0 zXrJ4Chn==djyOn7x+q=!?yq}d&Ef+`J)M>+dI+?cxVfCl7b>_BU-59_aj6atPliZs z=7~-M6ZPJ{m@T5k^gW?eQSJL8H$f4D1rFz)9$&!XIpN{L387Y7QfD&Xj5r|nSy$tz zSai;h2h|zZIxpt$&wVK0Q1)%6!n~ke>bGX!C8pKVUtRO3Z$W=Dlic=L4NZ>il^q~53QH#f^1&bzf~X{g8?tH)8U-rLt5+K_U< z>!#AuuuB`un7QR7%DkHnS!4&!wchbDa*yjzKiSn+Ha=a$uq{Vx7w5aA`O}@bd9Bqp ziA2|HcgOLHG_Yv43%^-$YzDx@!8PW%HuVCf?kl${D#ko!ec7nZ;oe%k;l_wYR>mebP}ZKl`su zWKP(MX4}bWGaS~uSh-qpR@&~!(l;#&-yYhx_kv0Kih$hDW)|g!nu}+9xXQ^1o;cWm zWZ7}Vp zIjCi*9kJwwp@(m)adOem%D{A=ITBlCeI~VNcx(Gf`#tQ~q9oucxKuVoDpZxZiF21z z%z>iQ4w@XwDLkDMbv?Hyu^!SjaC{ikJMod+mqp@>XYOPDl(zP#s@Bbx8vbj#JD#XL z=$1*t!o)Bk@qE?mn~&n^?O%QqP+urG zwLxUbgiWm?47aYwYX3CMn8akqy8N!#i;dGCG|9~}kq!`@YIf5>TWrG}F%_-tciSE? zK1;||6f+QYTqP)SuVRU=x@#}1+>O|3l~N8FZRV*?0_smpw|mxF6wOfb6?A&C#8IeZ zON>a0hoYm-Ix}%vZeHf{*fWd^V`jb4tav_OU@rTCUa4!GFBTec zWi2T0S=8}janW4&hialu-_@2(*d+GE5xGhRMg10xs0X!d6dT$El*MXLt7M_(1RsWv zi46UrZ4NvIn-qm680c4ZS}tWc+Txw$v?d{V`T1ThmMX0Yn-4VfhjOsJ__T4UlLB+M z(-oFa%Z|?uV>8ON__Ew#l2kU|9LoR(M{Q5;NxGf}7roar>n7~EWRUBs%2X*8bNb_o z1Jfe;U&-z`kjUs8V3^l@T4EuS7RUY%tUFs3pUjVva69?pO49QkzD8T>sDzB`M7`ed;g9*eRrZL$v9aO3KOcTGMVeDl@@&YF}P!mX3`MoLkoe7WNA9zAPs9reu`FDqEB`IyL8k$$!DELo!!4evN47c%FmQS}9#cy(`I*q7 zdr0Eqgb#luCbBHlY1NKa5u2tPeH{)}p(#$;RUvV+*%>z+RTEM%OlJ)7da=mK zV_BtuChyms7m8UcUQV6H;GlVQj!?;uL~X^gEu79lOO`)zoNTu2se4i0!3840uQF4U zoAzzbbe^fG!WhG#;@BqQYv!D}tbK{ZtMs~+ze*m9?O8P=ed>0$)xllX-y~LUI8ij~ zQCH==nQt~r3VJ_lzOpSzd&`YYGm{vX*m$1WzBEl+&pEdW-eSf%5W>iyvl=`_B*MVJ zv6X?DgJ(nL0?;N0g_sHf2ZnY&R;?)(0t^faV!CPypyIP{iCUTr=Ymk{=(Pd2yp$#^ zITogtyiNG!rj%2&wHs~r%uw)TQs4~QHhHB%=TY6+6Y@B#gw#*BORJ{vWM!|o+R4eF zVVV`3>NPD$hiT%5&K{9f&aIcu%xDP_nj9C-HepsmSBr{A^C^>+0*k{mqtBI<-nyh3 zwWu}rP|xozX-$(StoHKo`J_D4cZtobH9HDEAApydj0`%P;lp<;K|WzfNN{ZAWK&}~ zutD*#E@)OrIIzp1o!9ebR{H9z$8=c?jZ`kL3-#<%aQ2#bsUY}x-`c3glo<)aGgUYl zCYVTGzNXg1xy0knp-W5M``>P3$lQ_YeR!^VZ5+#mEl*D`HkQmQvig*A-goVcBG!a2 zsh1`&ESWG-Ns%*T^0uH$Q!Z_Jc6COi>%Ol)gx4P4VQmKZ}ePE}7?Gy^iNpAXi`)vx%R@sVy%Lu*wQs%=Oxu72xW=Fl|ZF*45Y7CmimQ z_1?DT=H`q@CW#$pYj00tcyy}w_qKH>b{4-4Gp!O{AG5c?^4C)79R>##gxI&6p0W7& z;MfF3t-hSCX&y6~)PvbzZ1>EV5e9Ep;L8NqXF=FOZA%7*)r^oH5eD6xwixm}ok zco*yPus@atoOcd)2s4ZRDN|2wiksmPVi4dU-YLd6Z|zR83kO`foV(?8KdnB%D!10= z--#*94_y=6sc~hukjcy2JBo{AY7;u#udx5wC$et$j*pMs_RE+!&sJu7a$&Le{CR<& zT=g&6x^MB_ySu#S2>+-5YBy?>h2JP#mfABXn%gP!{^Qg0`wjMF%nW?-`u_R)Eh^26 zzoXg6H$3jZUb{YbZMU ze4g{6P62^>Hm&CiTB=xDL_AfhCN7Ega?JD$N_n-&A?Qw0n$y}Xhn9K?MO1|fWgnXI za%qIos^<%%bN+8Sx}06rv2D%lq$v!VYlK=}xd%;PTFSCyNBRVoZ#O>q_p#)D^k2oS zdZ9~q=b|g;^sn+=J!QXGY>LAY$=chm9cKS2UOc;sZNu_i9K9Pom!+gwUl6+x=hC%D zed4AZrp12_pDI4?wQ>D+!Cvc0o9@2KdgcD>l2^daA6iTDkFZSq*grj&X~O{_MwSf+ z7>>OCmCh7&Buh$wu><969}l8G^Jm5A7+n$OEPC89psU)i*A z&ABC8lybJX-`;a_?fhF_T#^A^h1qABZr<|S!@l6w!nFZ*vrIHOg;Ty>Y1^8tCz&EY z;egu0qTZ~N8S)HA#5}hrpT3^5<^Q3rw+kjWeRT}d*sD7wUwiG9+o!bVnMyAGQ+wd% zN27gj)cjN@ei0KBy1jj;UvXU;6N^cJ6(bLW!%~OsMv@JuE9$2n5c3N5oh-5`YHh#< z18$d7yMwkk3a;9gr)0g#`?p>8!;5~pA2)4z?|n-5w%zQn2V%D;owwf;H-C-o5}6IH zx32BZ_1@&7?Dgfz>->p2nh%1tK72RT%09hqZB6z5zmxO*>w16fy1!XqLd)mO^J002 z@BfrJwz9tFy?nqWam{n;j4D@>s!uirF}_`Bn|AhnO|-{>pE4QTy#Jb-WlC;;TlIYD z&#Ap03<3v)WD*)!ITUP8Oupl=LTpzf3qvte<@DSCCn+#0NgWGw$iB@uWrnew*+&QC z&$2yfODFKD=|+k;hU`34&cLa&TH#!hn83EH3m5RZ#6RB@(W~=pQ~Z|ZTFXfZ7c^K| zQWg7@vNlh#%}bo|Q1f-P`W)HsLJ9mif=2E+HVsD=zILZ8a)tcAa7bdO>V^-YK?*5% z6j?-1uI2S&2#mD3Qh&+qW)qb-mPRyq(?aokXIxxo zZ*7X(BYk@9ozSiC1fRsMu@>07V}r>}!;krE3YO~U8XnZXR}>ZhM(W$T>$Xn+4h77- zb5%{dQ-_61$35tPf*ya+xh91-49|ai-FKVOQu0ir=fjrcYreJq-yhKE7w^LT&{1fC z*hA?Y|ZwQp;jf;7hg}1x_d0pgmD3*kHUeMzqYhW&E&~> z64ZaYMYT0ZHLF5m^Gu<}iXhp4zA5Yu4TiEo$K&IAME2b{!dZBH@3NF)g}9V!Y{FL! zeDaJ|dKM_l%uTajsCux`M@xh0&BJb$DUH>wi=XIUhz!x3^Fi!wz^QHP*p8f>m{|RM zmWK`h1vZXpucz-6_J}?Eq^tWRS20s{vZKdL*LFPr&uq>I6BW+*-|Y~ zo9W2K+MBWDz*MitwXMP@Yo~B2z6k2dTiEf`*MZ5aa?94Vw{>ijC$4sK>oUw}%8kGI z^ztLEBZoG84LZ^lD!Kcn*`A}THaT2ix)aiNSvBZbo|m@=BkS%X@i~X;?i>4l73f$~ zKjTJKi%sE=ma3#I#q0I)f!8?ye{`!A{gmYN`q4L^n#FHjS54UV>p17st>?|>eP(#7 zG|RT^_64U~%oALcrLtI`9dL6xnPeF<+R%^Fe^c+-8Y z@!RtMn^cwG7fd~*`co_=Pv7O$s##4}8sAhFe&zWRzQaVzQo1E2t!U+1rytt0r-t}! z?lKWso2E7EpikKpGu|5lZ_QSJHBwx%kvx@bewnhM+Pi%zSZon5)b>TbsS zmTv}eTk?(H=7v9w{_u6%mZ`e$ie~@5^TO})`BkxkN?|FR9VW^iR7u+KXIV;FrQi0F zGSk&H?kZvao6H^eEfs7^=e?no?G-1g)EV*f_mqEWoJ-jBd~4HFQ`k0Mb>L3hW^~Rv zic@m>8Z$out-ZX3+y4Jk*>Km4%VR3j);!Vp?+k2iI}XY`X;-T}QFt!G{`i$g?V6J7 z-h6woLrMS6Hopp|2HRGK{n`)9t8c0Nc~fA&J>+Hkvbhh=q-@<*_Hq5D+b+`E>N4V{ z<}BT9%OOyq(Q35dS~_dot~D&TOj&n(aK%ix>Kk=x<rzrgV!%ha4s?BZ!S9iU3;RN{=`ke%*TT`92z+d*?x;>a$hb@`R4uQQ;n00#9MY1 z(QAeKJ{7l#m&;{{XnZwcx>(?`HDax>=H=^=$J=T=o~h1CP_@>ulV>X6+*rLREkiq1 zWkGq(!|B#GDz&YK0@>zVULP}U#a+y&8_)jU@Z|s2vMrmMMKn50-35*qHEuAjO}ruW zZkmy7lkxg*!UvCQ#(nbOXf$3{SeIU2x85vpt%QqtguXI+ZCq0NBPBaGqwd@l)@|EO zQWgaDJWsb2it5@D{HY~}bs@upPhIA&T^&j37RnaBjV(KmG({fPHuz+=dwZwj#02MX zo3iBv!WE`Jip-l9=e}L;81Aeu;%NDTBh!3&?=7`{?h5U-#sXXgef{Cp4kccd?8-s~ zjSHFE{EN$Tiu+rV`){e$_$R9|hxONoy3Ibo#r?B1$Jm1TnCcec!aFO3wp$cfxp#b3 zOTT%dSXeb*c`9v&Z#jsr^fx9njkqX$#Pms zFi~m0$WcC&5?|} zw`n$S;GD3J5i4H`8GSJ{TOwfeWsa2LTz$d0%nN3V3$nC&iZCr;Y56ZW?^>kbjZC3M zmNOU?Smt`pkvTj=t8m^iK?W}ch7X=X%nUNJTW2l)DSRe#*8fU@)s=~JUe2*UIRE0M zS%)=ZyZHGgmkYa}>;(>nYs*a>2!>1!m0)xo<7pescj=mhryGS<7zD zTx2;rZ}9@Ds+rw47pxFw(5cE`yR~rF$weYui`7?&OGPaZt75Q>Dw3SF;P1%=(ywN& z&U7~IT5P^+v5wbbyHg9AZZ2{DwRqjnh3kHbN?FaBeRARCr*o`+%@@gglnVW`IQV#6Xx?tG4LEN4cRvsZdtON$#MD5%=ybV zvfTeabG}*2Tv_2+S5AsECpv%mIahk|3WozTtGJXGbLGjGi@XO z(3xT73eP!@Di?T8m_N^R#hRIm=47s9bDS@~c)9%N>AOxcI6Fvu@?6yxwbEN4GNx*E zla>I}g!w(U3?F6A`s%sZ%fb6oWRaJ`T;_)9o*N7o+**EHbNx5X)rvXyS~~Y6TQJQdY=01#VXyK?H6u(;sY6u4mR&6yRuH*(KfG1pV__^t(!*<1hJUjAqGoZRfKCRy8m zF5g==d(jEa&0W7_ilet2h+OD?d##T5oPMvh|17sfN^f0t>wuH>l1-WdKHUQ6Co(Kp zbx_57;i;9&e4^%v#~iTWUj26^gAwxqMK1wMtIeE$R!*;67;GgFT)oLIWJ}zx+3{Sn z&AS-nHZ%OnoGCMFF)LT(iiwBpSY{coSrj~bh2-j0k#A-mkCbDIlJr)X)jD%mo415{ z_Kcg^irfk;?OOAW-7>uqJ;R}4ZlTT+0k4(qS}XO^zY(O**gnr5C{^Tv70={-mHtSZ}lfc>Lk>>L%5 zR~uF=Jj%3Sxzp-x6GIm>gKdr z3-wkXxOCHS&CRtR*IamSz0~hEgYcUp@7}Dnui5(0^T?l77q0SL5V_5u_;&Vm&Pchp zi-m1xMg4zsY1OL@8Edvq6x}SYJ4ZVE!q+{Eds>uS{6wM( z^}w#16SQt^n|UhN>H0e@@$Z(h{4qlQug+X9T&%Wn%YmA_p542{g;pNAb>qs%y&4nm z?3L9Jv%Qp|wCsN4GL4Nhe&kHQ_y6zR2eS7b+1`5+d+%B8y%%%uz1n;4&E0$N{@(i_ zd;gQ|{V%cizt!IVG57wjy*WWY!ew^e_nK+)+gP`oJxi;5mND;H*1l&s_nzhbdsZO#yvXi( zN!;_Yy5|-1o>%RAUUTnx-M{Azaxa?fUbMu$XsdhCp)v18*S;4$_g?h&7&~`3+Z3PXUZO*!T3Ux^HGV=L3+t=OQUGe$V-P7CG-{0TB%#G#z93S}3 z<(-?KpNFoodwF>Ue0c$EMf;9o$j;^akbP!5K0ZdApR?F|zTe(mUtizYoP8fQG5cTM zf1gbb<1@sq6fI0zl+CbYMN*ggwiB1Sw5voN7>%u_F!ZwCT@fhcuw#XEkJciQ z7AB`F9?3#n5;FrB9ItS=Ffejlc;djQG4Y9mGuMI?A(4=atil|QnyOPmgBtERa0P5U z&|@n$!C^Avo|6Isvpp^@>)-S;f?5uPaw!m%L_T}63zoNN&ft66wtCc9_C z3L#^ygp9=@3)EXhd%_$a3&kc>r7^@*Br=LNYQ226G(IHIl_8|!|HKy#-ZM6`EaBYn zN~@1kb<@eY6~~r!_SG-q` z7ntVGSh@VbPqU2zb1#WpP!{04Q)$Q~6+NeL!@P}~IGL(HRR%D$^3`MWPHzDjw&w9cA!Dg-u&O!D}zbXLt% zWWo`(&J3kql><#ZH#NRw1RG6I_y2fQR4n3RE5r4$4KG%E$#m%-;_~^}C@P|}(Li(- zcf%@%xQZalU7?$@8^zBVJ>cBMs*zYM`cCT3tA%dA_?Eq!-!pIJdtaV6Zy4r=ELi%a zqpH#1aF{{jLG^4d6NiQWrCN@tb#PBP@?(-foWswHDvQplHK=UtRq6ED#M|GM?RLuQ zi$`O_Np{T(FBo_-#FuA0(REgRm0*8*s(8VahRvoAx$mC3vS{6h*67&Ww4YT|9Hu>B z*vMynP=ixwLKDk|6-PBx9Aa~Y;}3Eb ztUX`rT`t7UusAJb>C4h2#*ht73)>c0wQx3YUs#mzjVrd7Z4&z0n-9n;vp;m{SPb!@9PTdrOw8q=_lXX}b+ z!B;s`lth=a%-$Ao@?+Yxq>$TbTUIU-RoFCV6_d@`mAH{M(Gf_EC(k!rLDmdAs3gSFMi1tJ+6JGMyhCCna>( z-#8@a9k#yD^iG#%u%rXyi~|f;ehR!|D!Iwcb@Pb5&M*G2Vh2u2hJBpLS>mo^p2GCy z;d8Y&Is!&F4xiZXaAHQ~p684+C%3czeAFY*pcN;{!oh#%vOy~biBC&0o9p~_* zYMMtmcz@mO7!uydsNujFpX3pfuG<1GI0qYoP6-9Kma#Qd)Y zrI3dX3Vh05A{>QSkI_+QUI1QYjg&%U)dDykG2EQxhehJV(60L;3;u@5rUSk z4_KDOt>UPKHb$#tt0w9kRAmw`__K0qLiF<4M^4T4-oEbcQa9CAM=eSh+?>F~_3rKu z^Thj2f$z1zhdC}5n>WcfXF`Wx(_EP_iyvVN)tWrzKpQ?xZXDudFL_fl+4T59)vYr? z+beUAuCfiA(zz?)$!$<$v~IeQqkg0LyAYYq9iN|HSbUkss&G})s;+YVDJq~55(kK@&N1Zw87aEL-`kJSt`9v)Kiw z)KihSS2kIsBW1lNw)V;YEpt2_j?8+sR`T!5z>FPYTU^(NK09`yoB8L9RWAGIsjMv7 z-V>&{o^>wQngtiMj;wIoW%cE>!^vkUt0U&9&=!YJ>{+@SCCfp|bxPttW4EM<; zOEjIX+nH|iSl>2h)rv~p-K^>fb9E*hi1*yE;k3>EDMp_6wXVE%(d^o3QRJiNBz;wG2>zcu$N{IC1tL5q0akB1%V>wY}yF@N{t@dS6hpHHTQ=ly&-BYoY^ zXLHKm{d~TlUGLY6CDZeMy$7Fr+h*_qdMx64l6i2+}1dRcA<9NIU30z)Gw1H&Wp37=#g7*ZkK*krQm5o1BzmRhoq}7bb8` za(dwC!>JVF!M8JA;7P7Qd&$Q03ICNU1g`t~Wbrcj!uiu|(prWmZ8}jUs$WY^ zPTJ3s)|@J%DjL`!pmCGEa-U7?Bo`xJw$RV2qFoaWb}#mM`E{j|bIb*^y`C!yCw-pt z!e_bF^^@nyc72}vp=Y_xbIiYA5qh9}%hz0~=&SZsMOYWPbF zEj}yLV$14yfa~mnKx3C9t9^PeE2&Oe#KUB|jNL%f(N1t8`z@EnGMp|E##gp?Zh4w< zd}E5}gIj_d|z4U{RX>Hyswl?>LQ5oT{Kwcx`XRc}ozt=aqbslw{p=QO{m zIlf+7(4MU@^~tmyCw$i(Zdx7Fsi~*{Ta8P4T|>GKXzf(Q)Pt>da$W19*z~ZqGSkVQfhmz4QO)*a!z4&X~BRGXDHN z+rTI6+07#TC0(w1??AQF{6|GUco;1vHt3wZdcP60hb!on=$4aB0=V~Zc}O-UOk6lI zUv1lqM|BPlTu*hIT+3MOXn3h5rcYWjLZCnOuEvuIE`CRnCVHq5AisqQIqE}`!EN+Hi@RXp>& zFt-YQ^S{-i$SyPR=6|kd`W_!OCoi3p78N+hBhylF`K&gpClZHeo_x`{XxXkQ404gI zTEV$G|4UvuIvD=c4B|hO^4cY1)h~^J+@O#y$DVywlXS)Z8V9TwR=gc(EfE_oB(zl~ zrGNbmq2;Q}I3=|wZB{Ilj>u;|I_vF}HD*Ur=KQc+vdD9d!ciUX>tbK^Jj$`>KT!9=R$P^FWBHZuf%0y*Vk*6 zk8U;tIfP!^J$`#_#I_U+{gc`t53$AGq`V3D8l)XUyOK`Fz1$ z|L>P8;rV~R-bi2n_uHNF_kX`XXxIPu&MgV{0)P}w11AlK6ECOPkE&T;`uE|xyIgURfvhnyK1-`1BMnIgAjiohuRra`BPtLPR8f3lmF9vehBC_KOqI^u zc`TDfBo)*WBL$-54)kztRF#(A`30R`x3#4>?%eIW~D2NCqk}aCJ;Nm@}dMl*o#t zh?lPSHW)HUrY>?UMpvTkA zOF1@W_)iqOuu$sj3J>2^LDNrNS!VThWkBz$kmX)iS4DkY6>)Y|*!ELb*HwL8oxr;~ z;<(qfO|!nP$?(lyQZZ{n?7w`=If1Lc-tgRft7fgjmz^GAOk#pRPki;yvF%Vd3zFWS zT*U><+*4C90kf}amubDnH1=lp2a1ib?STgwvFoTW-8 zu$8{s9_ZAq?&dA<)M}GPd2`;1ORX_15!-i}P1Nz_cep;mWs$;JO^(cX z`o|%uq|j{7MLImyKMn^e>2H)tt+p`dawxTmE(ZwV22z`)WcW^ zu9+ES@EuG9-CYJdeigN5UItp1O~E8apk^NE5&&9HVkrAA0$&1vZ4x67-|jO0>H`e_ zz`Lv4{v}_)9q4&kb9^S9cbSOYi+4Y*LbRJ^>=^ASqWUeZD zvGYC1@Bn14s{GlceGSCSRdKtQ=%{Xad3l9spV!%4Tc4>+ci#;f9(a3uN9>_`69PdoBA`=tiP=Ptxrwj>`Sc|@=B6t3hLZn(r%z;Pw=-#Qc2p4+_vC7Dc`?IX zt@NdHMrUdA!bzsTR(Lx}gsu!(yzlA?*QHY5rgzMh`oBUWVEM5vT233ILbO-NKlQr0 zS}N~Uw)0B6DViZHYo2QRZI;^dW`nTnuYk?^qSk8zE@hQv`&`lb^2Yh*FZJge%inRm zCGAW^&WYZlqCEXK7CRhpo3n7MMrn3o;kS>ed2ZghI-gEBa(>VaRMdCb)aq;U<>V=8 zZ{-Pf2KC>IKxZO;h8!4YH<{6-Cwc;-3zMgc!N&{o?>a81F}UzLFkLX*`$#N^`N5W} zT28vAwK4H?ONE@;dFP9(SIU}ROi*hRM(8g$D$qxZ;~-YlQ5*C-ecdQIL;tZ?|-c0r2(hQCb0VlqjxU zE8uZg$k_!*qiT?i__UkiP2L(;oz8|hdxCFS425S;tbE+! zrnYi66Av##3InL$G9_deOX}2!w3~{PM2c3XPUNW3e3s(f^K%hH8nK6#U2}6>)C20b za4h6pnaQ>H>d_@L(xN7a`h+fcxqQJXvxO!L=RM0rIZBiVwu5|0sUQtz1;A;InW2rb z93`#A@qtEg361err7QsN3Wgkn4ylPFKXap`H8GzRu!GQvUQWR5`4GOG;Mh!-teFQ< zuHzvz#;^YM5VVMfEhperWq_nL)a3+!Ur%{;WApyK@4jyM_Wr?A=J}jtpL3$X`QQcv z6RX3+HkmSw>8-MjFCKQ3X5J9@{x$Q*LILfZ{U|(jUBf;@_ tel%(sv>C@T6T6)WWxk5rnz>H&nl*&7>}duaXN)oTGdFGOK1N0cYXESFB#Hn4 literal 28651 zcmZ?wbh9u|G+;7d{LTOZObooNjKX}3j2uk*l1%10Ow7D2a!jlWp=?}?T+EVOE;3x& z9Nf&@+}s)5%%(iPRy=G35CBs;y#3iL9o26vD zrPG;Y7^cYtt(IXuEg#3KEcZk?PDMo}QoT%DTbor!sZdXSv0fRwp^mY!QIe@aidlV@ zS@Bu3%4g=SJXVZWR#u$W2GVwEQTAD0PI27MMtm;T&MwaC9?QJEy!gCbg1zI+1K1S; zr7S~=froukuWuO z%G4>Brhe0yR%t!0M}KOvSo|UR@?fmb;q_|iP;viedqS?yZ(pm_CC1#*sa}fC+(T?e9zy#d-n$I zb1vMMpSeGI>i!P_2b_)`ICJvgwcNv--H+}%di3ar0Q#itivIekCzZ1kkFlkzX-%(*i6%GE26uYLJ^?ccrYFI#Rk{JHi2 z_U+rt?k|3G|LepD6Xrg+u@nyk3E*kiJm?yM<{%?w;R9&^36uDZHXqu|1i1rAHDuTN0dn!wm{wtaKPy;DSOZJ*}3WI z=^4hsVaZ9)4={KfDLHv*>6Vw5R|Kz)JG-m&r9t97nN2Rcx4pf+qtIC@NJ4S*{R1;w zW&L6nPx9Q`rWt>3&(6=!&-7bPw|g_=>#OUtgTwQ-tv;wV+phD!l>fdxKR>^?ygL5; zzCX(~q(zK&G)0_EG0nPjqpg6?QO_u~LPBa!$Rai=F0X0xWjhom zwafmseCDQk$z!rY%T(WpM7c_q$)1f993>XkGuTx~w&cxlVDM++e9)4y?rFp<37dJx z9?Mv~S`j6YyGPI?&qFQpwqntO2hj!lMb)29;jcY#LT8so`rT#~^Xt zrc*k5RhFNgxvS^r(`l!_7PYY66I|jTI=!R)p4#-5=kqkGR({P6IWb1rRsG}?KnHTqonH8Ki{&SPd&L; z^TYJREzSJ4xqQw&c7MJs=ACx#%3*)IJ7*TD&WQ~xKP^})rs6vPRidE8()mvkJQlo| zTC+)Gwn5_|HUn)Z%}U+{-{e zL(x=zQVZ7t*@6Rw>@sBmYq!+?dfIrxdT9<9d*|<+I~1fk_bBsfZCUa7!A-HGj`=#D zJc2yrgm3*Xe0ZsQ?QJc6scOGblVGt`C4Tlow!3B4*S2h9E?xaD z^ID7EF4JW-dTr+aemv&wJ9%ON~f@-I2_n{w)3Pu2hMVlw}sy)w?{Z4W${%x}2)!eV}d?@yY!t-cCe zPBZ-4BD|q=?~K*};fV~faUv!c3|i*Te&EEH7Pi8%SG|F`MMSu5K?mD2&eTN*jtKAw zcE~+Zs5j|wQkdbu?BnpkhVf0WVU_{|iwdK&c2SS)Jx0Fz1&?wL1{{Cj!@9sG#YyI% zfD6z4i#?Hd1LWRLVAuNp#(*Pp&tusw52Tleu;@fB;c_|2C8o6`tZVtgq>d~P7vp;$ z*Z5}o$)5F?E&1ugN(Db=!Mq!f)-B6um;H9Z?omdY)gM8(oJ>F7Il?CxmwniIX^XOG z1B;5;q(rNn8A_7(40?CCFiS^lQI>lr(QeQ2vqS6@v$R8!@9wn8vxDa_OI_G-{6oj_ zsZA>Vl`C}m#BZ5wNH)-s=Py2HwyDtY^@}FiTM6vns{;9p*C@%09Co!oVaVO~_K^Hv zhvPb3h5U6h7FVS_y5Tr$f&KS_g96-FE~^#^w9ZUy;w=1dS=ETcL9OJn&5RBw86FQg zy#)?}MK2T<6bYQ;pY>c=BcOq)$MC-s(}aV(3SB%M4Np(V?NSt8A<%A;;jyE!$Wo6L7E97xDkDsyE(Nf=-JjSI zxzdAo>ICU}IhSMUf1Tv^YPITwXl?bG;~0FypGoN`~VyQOvWIWKWt+usCjMva!GJ zjkDwg302;{@>3yF-?S$uJU#H@Ab;Hz<(gjx$N#(#SBqVyDgE_Eo8`Mk{-Pa=e{5b}tvB)P$NxtdSsNc5vO2nXv*L@F+ZqoXvWmXO zT+F6qGrxnGuO@q*M$82PlL?1a^%|M=LO$DATu`^~oychGFx60?YoW@rg?;~SEOa*c zbIkgd;8ym7jC(CYk89s?U@xs;5o^@?zYYf++me)-iwk=9<;upEtoY$p3_q9y_BFVdF8H^gaZ6g}b94hvm zlDKHs!;_But0pju{PEfL^krk&&(4;NRuA4+0>|}FId1dqoY5BJ)(%sdc*Qrut9vsQ-}yM^e0ij|<*@wA8HbgVmP^UJ{=xsxgZ15$i9IPCGf%kv zW40)1<(1vKkhgrRO9J!7aAW4fqH8;LOxol#;ZWJn$I?HGzO!vpi(xR{#QFd8B)ta* zpT(s-miw~exXvp^{=|FE@(WL}TbCR>b8b`Srp*>|467#W|F%JAcJrb}5w(fUiZbE> zD#5>`XT4kD8(}M;Y@xUC&gJI+PtOQAYv^$u$}9{{N5%$03>6V>x9Bu>`#biGmN%TIYEyZ(f;=CaxcMt3(UbV_ zSMYn=`X79e8vFL}2bp)?)?Hn0tjjO3VwD3EL#v%y(ZiPo$K`+DV3ny~Y?3^|UC+h2 zpqXECotxfSnMn`%>$bB!nlr^hg<)cUor3U`&kRQtnN}~U$&&O^IA7=Ha_^MG_2z1( z=*s`6=N>f3HMl(eZ^Kf9odf z5_0(u1%`|8ToeijJgU&q&ehgzA-SzG*SN)X279MNlUfHyR)f~zYq`CJmUB1iGHzhm zqRzkYG4HYoR=cN5)dzUK{lHQDl=p3chr_dK4s-Sg30(CKHQf%?OFtI)yV*=A;CQZ( z`kR4kEd$4d{~tQ8luP$_=wBC?xmDgNIx#}|x|g)e!mQ1o zy1Np3CKhl8IymYrE^TXI?%T%6`-AP)4D}fez9s^hZ$ENMFYwQ8V6abQo~kS<{nafa zfa?DM z@)WpV1z2_}@I6=JGkx09)u?mokj|tAm$D6&Wd|l~S!UtCx&KRtR(wQNKq-5whd{;- zscwPE-5=OjPT;98NVfmKt^b4qTm1ySmChb)P?@2S8hVXu zGh^q@K%3@kh0$%D5uAb!3~9`lxt}T6&)=cC{YuTs&Bff84X!g73OO6jdQg|!)b*o* zyUUd4Yw*;s>g+QX$lbVMEM35OWs33a3mJ7v?8+bP8--@vb%{1Bu5Z2+U8}@y@R&g~ zfHA*-Gs1w$wqd&4^q9SgeeTK(vnEMQS}1Rw%9iJ#RQaK?Nr7XMvg!2ZS*uRY%5yNE zsBHQx&^+lyZe6ls?_#!c1EsPFX3KYKCO9jKB?Tu}%-S<^mc%1-2`2Ufl5^@d&ssB6 z-nvy@Y}y>1WmOChm_1%9?<`JE`tM-9Be*(=L1mk<^`_)`#R2nHJ9c=_ROw3K|Gk0# zvxELA@y>099M{4-4@{iOa7%#8fHOmATAb7L)upznNopBFYDWsxGM&_(1go)nO;2L7 zEt%QPwvo{y*}tbrqch1N%w2Q#g3@=1rL97mwU@f*H*oGbVbp%fVR92^XnWSvM$W&H z0<0hWk0mMCWl7%u8XFiYDWSQC0!enbkzjz?bHjhTDm4s<>ODiQy+LH ze(3OG;*@zl|F_VBq%HyZ1)X=xm&F%xU*EWl&C4L>5QkKhfQ(k7Tln&_s^#62n4Pv) z@i?&bTu$VAwAk^~5+>IrjkA{M8!ee>xT58MmZje=-McU6g;=c&+GQ;jt{<~%{;6_~ z1_f42u4Si;mo17C5DQ?-3G&X+GGNQHmE626e^a{q2gVYm<+5GNw^gk!J*DA$Nt){* zqgjE|vg<1jS*YW=sW^*^fCvwT=Gf1${~ zU+Wp9H!yZHh6iq7(-!?TWi3~>U?8J7-|r0qzr{AM>Ms4rSYNnNV)jPy-OIUeZlJzE)=uK+Xn>1!`(%QXA=k_MO-&C+jUP(OcZAw|LCn;uo8~+tRAHWz61|wR>C6?QMC#w-rclFS6cV61}~wdV9s}?Nz(C*WBJ-_j`MT z^o}O$9WBv2+NyVS%-+$pdq>ah9euxdOpx9=$$ICM=$+H5cg~o-bJp&ib8hdP_j~68 z>0OJgcP)wDwXAyAirKqX?cTNK_O5lmcWsc~y~%p_mgwEvs(0_0y?fX0-Ft5D-uHX= z0qH%5toIy=-gB&a&xzT4PVL@v=JuX*zxP~_-h0V<@0IAi*Q)p4n7#Mb?!9+z@4feX z?}Pu+`yN^EdlJ3xS@pgbv-iE)z3gH z@ArNNnFCBV2Uubbu+<#km~((@&jFr02l)OR5Rf@2WOGm?=Ac;3L5VpBrS=?@xpPqN z&p`#5LrOM>RALUP)g028b4Y8?A)Pyi^!^+&kU4B*bJ!&2uvyJvi#dm__8hjkbJ*_B zVF#HbPBuqeVve}g9PyZQ#B0wHpF2nV{u~LAIT~bhG$iI|Sk2LhIY*=R9F4hiH15yQ z1es$=Hpfz8j-}Nc%b0U4YtONqJIC_=94nAHUSxB;B<6To&GCvk$E)@nueo!)?$7ZC znG;PmCt6}owAGyGm~*1*|DF>)cTV*EIWa-zu@IW6tSadrt4Ub9&#O z(+6bE9I`ocB<9SqnlmTnoH@1U%$Yl9&iy%aLFViwo3mG9&R(lIdt=VoTYJvlxpVg3 zpR*5S&ONd@_ax@rvzl`+=A3)A=iHk+=idD}_d(|TC!6zMV$Of7IsaqM`Cog^|G9Ji z-=FggvKN?aFR;X3uzk(I6wAnTmWfy3f`0&`O!xWP56l98&(|7U6uofaZ_UNp4=hF# zF8=w$EFQ}qufSqlz@!?$B(vd?&fQCTe=ixxUN*A5Y@!x>*{t@m#oWtQdoSDEy=?dQ zvV-guC)+D7u~*z`uXxP8Vq43|Cm_I}2pc+)V4T6g#K6SBz`@4wpYxCS;FDrtWMbfB z;Addq2nG%Aa5T(3*nEbA&!r+@p+g&+tXK<2gNR(%{|`PX91b%K6a)UvZI}@tA;KW~ z@1?@cLQV#Dtw^q7&SKT0Gxd{$wmcPT@ayA?WLs(>vchQ+BO@XtSQwcY1i|)XG5qIb zaoC{1kj5cwB>dxif@3SEq?HPX;KC!FqT+lit|zYP8L(?dNox4q5OVrolCsA^h>L;! z|C*F38Iu<1+8BH4RZ1GCJ0~uQSmXIpxWORo(7&~uoD-i3vGZw!m1J$um?9jyobO14 zp#s8s@*;u{9ylR50;hGVLG+ZC#>NvoJsVX2TRq%(RFFrN;eW*kfnyg>ot&cWUFNiM zlB&yOlgdBYH#r(M%#u-ME42to3~Z8NW+KXY0t`$H!i=B@`pxj4Q^uoUfs_d=meDsp zmIE7=8PXJ-jf6W45>Iw!nXH12zUfxI(J*>qZ=%k*$WvtV^7Hc@nz>3XCMWw&NGrJY z^Shs-{3OoleBjZy1+MmTt*16f&Y8`u9V_d-ZB1lnWPhKJmO<#@Iq8p2&CND_8)lqV zA$UdkgSOj_5@v3>H5c-HH!u}fe%742;qmb_-fo>0c@Hi(=7pKHo!PlL;G`Kl*RSd7 z8d~R91aB8%lX|lMY?{KS5Z~QpSFb7S-`jY0ce($a{~x$|WdG<&J=!-}`*_Fi9|cdC z1=Wu3+_URz*Marf_sk!l7m+*?>zAKO{OeiOMrE9PSdG z(Zj(w=YnIGfJX-t=S-c9!Ud-$v&F94GGl@>XV4U{m<5~+=l9S5zu?1>6G6%s7fMVN zS|UkY_Em;E!wBIF$hrX6%-M_{5`ACGApOJ_ie=Oo@hDIN}@JW5__rl&3~ zco@WC4+D+^j0@`zP4h|NSa4BKqvBs|!-+Eo?G5B<-nT7?rEaVT{51V zmYkf-Yg{D2rux{uUjsa(|7^d%ka66CBZr@QPcUodlFbsnEHFvh^dF1YmX(*63mIx2 zc`48!8n))Zj~dRctFNzTn|VjYYAXA)m;%eA6EoZHvO zFHAj~cILF$v-^iTTDwelFs;A0v{TtTuHxnV6-}keOb-H@M3xGPD)`I!&Pq_3$nZg+ zMQPWT>?1+EX=WMj!0++@^Eu0%ay>D@y5Wo=gWBFqhhD2`$i-ea#%Y0@D>F?>W-2Bv? zVOtW@ftM4M9H;ZxiutZGS$MHch{*C^hmoCugMp2KfkP71ggE|Ces!ZLn^w#Y1I5E! zoRt%je&{3BbH4C|6@^Gx;Ic=7Uy)lWd#NXe?R|PtDGNf&IWzu}qy^Tpb z&Sl1>MGl?9oI7n!3Nc7T>!igrXl`Ix+3Qrnx934ansciguad7-pyHVZM4k(QTW5`C z9klfTDe4#>C?1&@(AksV)MhhLP}y}dkM_Kfh)GYqnsgJL2CB@9hPxdpsMyZ^SiV@n zshQb7!0O=##ekoYhX_zrVL|fKI z%JKn6!xUXbak(I!!jJc7b@H&sF?{Z4b!Ts>n6e;K_z|nq|E7H^niCE?b@6p-g?JHL znBegl3j-5F4x=0c1II5MwZRlesOt)Yh9n1?$+1WMZ>qZ`R@MabaPwrYq^M7H{adgTFor~F3RHL z<3+v7@p+M!pPzTmb)Nq(-0JJ=inYo2@0lUxP6Y-=hR@)(+<*MR0hviAmghv`w*Wy8|M4H6ZWI?wb0nJX4Gah!R93rx7#lpbP@QXo^0W_BlYE5HQ zd=?vfUA(9LOI>1NaMIBvgQx9@#;5xpT?(G7OgKNOI9X^XpPJJt(BM0pk?#U1LHaN{ zu>OcynUHaCwufk(#3@G!k(pN49&AkdBF}A*utF&3SBT>ERe}dUF#Hy1V3Lv+S))-E z&|#2v#%k(=mkpEpX6VcaWZJwei_>69i|87M%r#Dj%1>JLEDinW#Go8)2Q5?@?&W=!bVl;k{_m;JMpTE@|^JHr33?Rk5| zNkP+Q&5VbJeK9*7wAVy5N>O=3slZr6Kb3r!MX5A&jm+~ zH7!S#>|-nLJYovwXA!Rbb>Lp%+7J9Cmv~L)8SI|(onJHA@&H$X&IWZJId54PF2!v( z9?h>k^7nbP`hksUp^Y+C-_G;gJSckFo=-rj$AHW2#F-XRx0GN$={qeK^bR!`UN1hg zhLKhE=tluL%_SUP772tIFyEQv%T(UPH+RSKB?dlLKl|gXcD!J4(p37Xs=1BlBoBMo z+e18>YhuC$BqCn5w(I_vSjjwz`K9`WDdPXcpG-LF8>w2FYF6~j-r!#_qj9p=is5%9_Nm4NO*VSrq z*vL_QQ0TrC3!7@RROKw@!(y}VXR=sY3uqsdQh9GbbMAB9Mb9k@G-oM9n$WXC3zo^xjSRAFPG&87URnkV5Jguz?(@9ivvKF2a7jmdr zAmt;w?>fD2w~F;I95LSTA@xV8_I>+h;kFMLCe(OW%5UsC%;hzwA>1lMkpG|3p2mrb zVyzCyS3h%dIJ)r|W8+S_?VrrWzXYDQ*mmLI2CV=_C!Y%?KO`l~KW&k1IuTIGU~%I1 zgQj+U%L4(a+I;KZeA&dkYKFUlDfgY&Hje4bWeUB^6CIUJj;(Dm=~vJRxV-V-#5ty_ zEIhpqQ+Ba&hBfR>D^Nt%%9XZurV(C{zw;yQ@6lYMnrY1VymhWOu$jWZ% z?qIc=rDquHMFhDy9FHz9Q1E$rIXV8@a`7e~owDgi76v(dwr;dokZ2vLa>L}SK;y(G zli3|pPgu#VUDBrwdU9-TZK4W8+~SzS$=?J+n7X(|h*N*g)NRj>N?UKh3W^ zb2W6EtQC7|%A<{OtjTWn`9$7n=9Hxqax$KnOlkao;CsInKi`#mj+;Ec`>QgYyndl2 zZ>QOVc7?VtckMU-VP4>H^eg)fb*A2L{9MdaRsEc#gi;qfJWA-0alFFW64(3xfx18~ z-@@rL&6#2aA5HQx3K1<3dpR{ajH97U=?ik#EOAhb+D)&m1ZxQrhd7 zYD3c_ONCrCU1oE!%&eWu#WFiO{ZHnql|hpI*K zFaI{{TAokSw(CB|XTMI0JHn@(GdYphG;!_LTUu}ZIjxQ?TrcIwbkhEI(raDkn!BI; zS3Ty6n6UBwveoZ)z1jBq-R=*^wBPUfaxMG)-XG6azu))g+w1rH8Q6U;a59PKd^pHu zXe^=4Z2E}dkbt|4fu>M+&c~w?>1#gD=g)uh@%TNf4;%OtrssS*sj+;Gk-O~rH%2au zNP~xq7#JDK8F?8PI4&|U|B==RIB=kuNl48G=L$5WLAi?|;<-1MRKr~={dA*Fp4z%Pd~(vhO-o!hUUqTf(f^~Y zx@~QA-+YT@zs1WE?rsOIK-*o&bFF!q&acO>Ke0SK+`;Y@YN__ohrLf(zt_cR>XW5> z3a@vZk<1X8dBSRUj>@ht=XVI}+xAFa-tzkT2L3Lgs{)e``y%@?fI)ykgn@^Ffy08~ z?2Mo8ALM5>b8y4f*>Og7?hxQQ+WlYZ@9ox&$?jqQwM4eOnDE#+npHMI<>f_1uXbd^ z@HEOtrHE`SQazwp8JHPFm~a)o7%M47?Hgf*uU^Rl@JdS6B&nc+$tPx56hpg14(8`{ zDvUwhq5dY%Q{e8cIG(lY`g(5jM$lL;sQtex_Vl&H&TR#=#d^KB zzq`m)_Ici(CEM5EpD+61(+}?%%99Uu2+vkovg4!cMs7pPd7zb)9rK_4YxW8F{Pg?+ z=We-NOP-gPSDc*{c{WOUR?C{ROXm6RE=y!@@e|>3DCp~(FOkD7zfZWKkU{n!k6eX} z<-y0=);Dj>D(E#3)94kxf9|e2e{{>1;{QFVPc2J6pA&8pbcw zTyK0l-efR$!xFXG;eQYFsP5-H9HhM4p|QRH|CAr{+G}1Ma8|C!Om8&^Gs_g17OvO% zvN@pZ<;t32=+ z4UD-c(cN#utLsIxn8KTB>q7Q5j}$B96u&kk09X2e~-Yj)T0$P3?-@2)&a`}_8qLit^B zc6EV6r#M}cG$ZH(T#L(_!$b6pR!*d4BLvuEmiKv$Re)P?L?uMk+J0~V4 zb@Od`RN83pYo8YfzpBpFb@Q#IwzC+o=_s!ZU!51HdVE3syUg8-f^R;U{HAKHRc!mE zh`7mzHaMjnI`_!^q+ZybR~vLocU3v=<=f)NS-)8*XoG{y7td4C3<4g;45B87dggE0 zFI7?8tg9Q)KgHm9qOux6bX2s%tzkZdST)voDhnHo)Bj1CoxsD&CpEGdx zaHe$EF?HT|;85(@&a=dpY3E!<_9T0Tnai18Fi)K%!?N$U?>o!srxw)TIPQ4p1&7LE zC04n#7KN`m9~5jRcWg8Jw3BaFfpx$IUJt(nRs#;lHoZtiZrg8%Ffg5ML%Ssijjt#Ss8>G*cg}@7&rtO7&%xP9^H44T35w$MBqt0k}75f0S0y^TckFq zr1Ad?3j{mZ7CN=^aC^DJF-1v2-(JRO-@z(%S-=s^)2ekaz_|H z++=oFgTnYQJ-hlTdo{4J;0pR_TfOK zkZp7}r|6UoE(sR5*pK-$82nydn$2L)9QEqoX1APwl5ThG=SLpr5s?bh64?F!g_)@UcV+Y_Guq8t9Om;(f<+Vcl$2|@)YzcdaF-3 zt`krbYQwjJU8;I_NcQKm7Vk|Cu-x3#8Rxj+X8R(0?;D=)+MX>s-8`Q`puDNz=KkH6%7y1K;N57(#a#>P}<2ZUu8MjT(pe2n3M=7sdE>$5(U25i+$ zzRukF-{$+Re+N$T?2CDp^W$O1@(?F(S(|`37JK28`Bissu-~vUX1Lbj|81-H{9Ozi z1ZD^@Okr3*|G!DoBYS;wtiWK!7`8>cT0xj|7!%e(*PJ+t75gOR-`&aE!~Txxev_>K4BU zceiPpjvpRvY}3E>O1`y{hy5R;!)+6T3gxb8tVeffY-T)Qx+cL<>~SEk^&`VlXQ37?VWmy>#qh-k9}NvyN@ zIAw*2OsQW1jx#?X{>9ttk!t~LTQ!ijQ_lAb)(hF0UMjSp>|MRu{6oIye zh;0jnG=v!T<)o%W1T*mk1W(&gv(#ZuSN%B$d;Cl~Mf^$~bY@-Q4ExWqa4iqliiGVeBf?g6h~8CpiFoN1Gu13f zd`2RhzUM(M*$)i@vm~Fy|1V^colu~lBh)0i#-ur-B#G_D%wE2dfQUwe>pNRs^zdmo zYKa**bDWuRP$)vN=b+FMmXCAuYfYVQ^{`yq+53V=aY8ry(|;Um0v5Q0cFdCJJ=?JL z%-xjI_1Y68H$2)My7R!ke}Rl?4cm4ogsIDI=nBm#>yS(+aA=?C&K1pZaKWXi9x<$! zF4kx4h@W!eRflJfuUC#xydK*#p*k-o-u&sTF@p0+u^DrV=hX1_YX)b{0Fx8xLwrMhpl{XD#9T`I49z2JGx9GCX1 z=gPxOy7tbw&ZaJSxykQy@L4Yo-@`wza-H>;v^>ywH-v%b%mKg9)HNXv+%6ZSEWbG9 z&CPBKJAd&*DjUBQPn7JQO@~iiE7*Kq>uKbpDD#A+rEC8uavKO9x&FUFXFcblSHk(* zmu>L-ZWq0VS!~ThrWH>ey^D1P~ zdam(D8Pl2Ug3lA&H79KA3wv~CLB$C_2aYh2V5N`q%0q9w%{wb{V?yiyf9%=?Q3<>= zSM=0gHs9aK$rimK;lciInI%`x7MB(+SaU|zmV8m_bp(%>wUYmnoXN-eLgh*GNTIbO&7fh6%!5aO=CT$*;BcpMaX8(d6wrJ zT#WXAo}3bQbhf_7WA9HtxUBDfSmAD~7$I_LQr8U07XQ6(F0Vas(Yl0l?fkv(%FqA4 z_xs=X1N{3wwEO>gB>n%#3IBbcrl0@w%=-V&3;p}PEcgHWD*FGg8|U|Z+kXD6O@$o-W}vQG%th<@+N+zaJ8s2V2w?MgOroY*!SB=*VPkY+16T#bBYVwqvX049VGy zAu-CW`i^ZvA6ujxIT};AXNIQ-ZWmg@wfq29^no<`FLc!3hXlKzzl0p90IcQRPjmE?JiLUfCmyd6}-f?8hP zP`R)}LHmTb&Xn*QD|&T|lwREEW8NgM_ekNphZ2X8h0q}dwv>}OFD_*c6NMM5Iwwg=b}HL!;I=l3V+rE@cz{RMVe;>To^M~MT(p?@ zRzgYQr;5Zurr(LIQwkJSH%v%SpQ2o;|1u-Y%4m|kl7gOzw0s!ji(^bOJ6H}J(GOk7 zYXJA<@)7FoAC3p6Sk3YlomFH&DLN%Qz4 z?UHRqWll3&JG+G)b($y5+$A*gSkcV=htwOdNG|wkttz1Nk0F(L;|v`Z^{6JR$v?Fh z?3}zPbK;yyvvzKpC7r3UtW$I5BdtkEteY*n`#X)EDOoOhp*g8Y{ix@x6_01Gj+`xY zP$yxD&Mu`n8=PbkLKye`RNlUF_L~1mv-dj9KH$lDk+pL(LcFbw!3#pF*j8hc- zSspsPpQ!oSXrbsYYvDxsf}$z^7BI)Q8*n|AV=Xq9(Gcv}u~3Ao?Vf~0VyLKel>Xxl z1};pUX`g3b2r}4z*hoQHP~nuL@-2lGix*|ySlsC;FtuY5V~CMJm!S2mPL^OvvCH~> zFBl#bnV27$6l)d1UTk93WuQ z=~c^51-2N8@OK$54WHF%Xk^j+%lzGgCZ>Z-9RIDD^}Z)E&T8aa&|Op+-x<-vr6s@^ z(82d>L3^Wtmx*asguu3khAE~>D$XVyoy(X7H5#wTSt*#QN6DOj*lAcL`>b`9oUvey zbHpww2@YqAX^fp8KUwb&Ha9X7T-T+UEu!x+tMd?-Y?asQsf}wEM+qF=wd#b5^2Y}u zJrZVHgY@EN&AFnw`g~OW3T4xkQfpRQ2sA0LxpvCzj#qGhqRXq$jh*lTY(%YsM>N{pH3AWx=v3vdB zPu3SMZ;=pmF3#HG&b95-!DTVerF54YNs+lkDUj3SXt+t@V zmRSe0qi@|>YFWa#CRID^gZ1WYZR;gY)|I<;N*Mn?-lIL!x#ai42t}9u|Bd$wXbYU_ zb~wL#m7s~^zYk%1XYW40Naoc;zS*rS{yg6IWAk38kbUQ>_uReRVx+S7$6@D}-i}8S zr%YYApRr}X^ARVTDS~^Y_x%WV7A|D+Vu~@lwujN=z-w=rZzn_7S5EtOaMrerq#mv) z)qwWggUkUFf~TtQlKaii7{Flez_9%HmYOSKnI$f}Z_HZosnuwUTds)3y4`Yxk9xiy zc2C>9R^+;;#6?fbH)(%3-DiI}A~WGg{S#07J!Wz`>lce0SuEoDt|2JD+O{ysEyl?+ zOy`Jij`}GxL#G_iB%iGbQ;y}UD0d$5%*iSeQ2zMo(zG+jdCr{h z*=M=+w4&e{rHlDiu07g}XH^r={_#AkA?W*hrSH+2CN(kdQZ}EbEoWICo>33#bCB<(j1M>n5j0^%yo=Afq$h+y8 zIYbfy7CCeahzHI2;s5AZj{;&zOkYUDgr%pZE1W&i;<;JvtezQU4A_76;eWi`Q3V=- zE$;Q-Op*n7FE?het;&=zeSD?9qsmO$;`2*S3pqZGzv^k=H6VR9$2N#N#^#C3C0h#| zaMVjJ55E*%VO@s`3{*1X%h_Cb}Bz~ zbmB=lYVR3zC_tTC(CGpztLY|zDJ+J`0xj-k%lNH19yVVn#1?Nexk# zJ-RH99slr#@HtLBaXldM)!$$Vr@Sja{pT3kvP~@J{rga$R_&?s1&vrE7ggUNH>sDl z9-L1mEme4`E}+FDsuYmJF6brgxa{9#<#095tP88uCjRddC}BNoHbIl`=BWwhDv_Vg ztFk(Ba;O|}T69V8e?#L^PxXRVI?f4dPrO>$BOgrGxPNfTGRJvJPhQy{lN8aYQLLTJ z=~41WS);iAQ_2hbvbjfAd+c&b>0JL?#YM;0@6wY44wLO}rv@Y(+Zx=V!kZ`%ufb#D zqQbt=Q>d5c?s0b|`>&l7X7gVZdgssJPC)fgBDe2cZ!YXNIQw9eAKUE^ z)>N{1^Upua{Bn{tNwD?n%cc zSzjr7y&>BDnZ3+8JAw8s=Sz17I3Kl>C~mTQdNp{}Z1dok$L0SDbn%GRPmtFCV?6Uc ztL;1OuQ%qK&U$-u^%NVY73E=}KkiP?|M&C7^7a3Iz1i+>%l7WL{{KH;uIK;%`{Vig z|Ns7cfB*kK!Z`)fKq&_roESY~s=)?iGNBL|YjX2I6bL!e6ak5e3jKZ4=0YyHu zCGk=`3pw0ROyHh()X|<%ku&^+(;LB;b=xu)@}zqlmb&$!&BEq1L-C2ja=$*bJMb(L z_}}hvL`mvnhlkH1q3I`%s9AmN)K+Pk#&M|e(@Tb~2%RKGW1+)3RUf;r&wLc2_Cg@6 z<4JackMi;}8UiMLOS&Z!7cUJtA;7kgAyS!hhSc{6S(`;mn+zUa@?fYGVADJ@f$5^M zym+O(xl~F!&r7MVPfnchYVw_^T&Ut=;3>ebr;^ao!l|z7>Gez3Taio2Q~AXSCaYI1 zJxhC3f5&jH4}Rq`>FPz5s1nW7acS$jIGEBL6F9x&r9RI%;j_$W`pGkCR-b2H=vij6 z-1BT!)aO|@&MY(Ae)4Qy)#up{c$Qln_dHiL>+_r!pcM!w&z0@^JoiJ-a+~L#=c{gg zo(CHI_a@_mr0=u%ve_D{`E`~mRdPIZ1bikJufD@oc6iEWVkN4E8YQV z8HNZ0Bg1P(9JK&HGfRWV#Ro2o|JAszL|}~k&ottO4uNl8c6PSt-3K8rkFhuzS~qja zLKa5m%6XjHlIbMtx7f>+GhIQ(T18mZ<%oM)Xj2y3vy~lgiPQTX#OImLC|d;@07Gd?XjGx0#ZPNGdJrYr{_V~8{>lC)6ZkYS1 z-us@?Kc8JDP6u2MuVV=)@IC3eESWz?W%st@77DBw zIwzz0s_6jdE6vzzK3|^Q$iH;%++J(f-BF=I|K)bD7`vW+$I`|4eDB`s_xBd;zvB6| z(Bp>aW!WRY_wBDg8XFlZ6wjU*dZ|=-=f|rR0+GiqNIo-U32FHsn(?qz#g<*D=~d#3 zhwb+=fAC99Xbf2tsHDfjUZ}9`#iM!^A=Vq69+nk%3iY3@c-*(tSLLET6N~1P2`-;9 zo=o&mTlr*?kJ-y7lLOo|pH2z+cieGmMB2)w!7nvSj@i%2)qFO?G}PhQ%#39#pUuiS zwbgHS!7tZjF0HW&n7EXArsjm@GunPFS;pGfC7{Z|srYJ@(%b~aDz9qME)3vPE&b!YP zyk58G+pE{>53p&!*>FTG`_0A^YOCLDI%D?w&E^Yk+Hbd93Cn)F^+ww2x7+TNy?(p> zL7Vow9Z#lZzuWl&bs5IVQ08D8E<@)Tdzn6VPcTrAy}8uspHPjCoORHur&GV!rL1aS z!|Ad*aItC5$LfICHG2F~&Mo;)OAf5mpStj`u#u~H$)9V-weFvEIxRnSLoU+PjXMJ~ z!*xbS9IY3W8SIbp)B2EFFRNmH76_m26o;+EK+Iql-s6@kWCfoE6#FVb{&I&2n_DgD zETGlK{fT!^ffn@gmNtfVB!OEmt71=o)9&6@V7RNtd;7ZB&c7m$gjRfAcOS=$6=(+g z^bF(dduMiTetsS_gYCNueiqQy*Vi{BpPuInJ`1S$^}VyZx4*xCpqX3VZ_kd8k55e2 zhMWa-VX^mozrDM@zP_wR_B`^ZXnN(jAF-Qz#X0jt?8)O)48>Bdi+aL`YoEW$mI2af>BtXG! zu|eu!^M4tIm=hZmotpXn&&`k!K&;gpBw>Tc84L_|3LW6##t0$ogTIQ}2Ji5tO!Y=2K zP>7=EWUW}PT+Pi(o0f)Lh8_*Zx>oD3p_7O>tBqmKfdGLw*Ow;km0QYrz-gwD*aImQ zl?gMG`v1v+js{zHHFa9RjnD?B%bQXIe{7P8x{~_f;!^hWQw%w$Pg<9=I}}_TYz@=Z zJ-qPMI;DWXCmPa*QeW*aXK-qT@(RCSlAD@ve;&9v;92VyDYpDON4QVbuK%wtn(Ewq zB_>nxcyfSS=E6{ugafSomTyihZmi{eqEa0vwK63# z!7KCG%nUy^5eD0jM}@hn3oV0Yvh~hNepYtHCQ z3}Bt|!a;!FNu*`@ zE_Q95mTE?qUIVj<%rYMqWV${TO3`tByG~@iledp|z+RDstMlA%SuJ_L>n@v(qTCXJ z5=QNWKgCSi>)g6GIH+zo)MK#5L}|idPHvY|+s`~uyuh>Mg4AipB{IMJnb*uoVAbaT zFZ}U{D7T7_x={5We)cIGn@?|CvZL!0r+#pbp_2svmcp}4a~)0|sfZCMIrD#wB9HOh z8ZCi+-)~hLo|gPyTyk8#-RAWP72%}QdXnD0Td)3)NaVh%{^znjH~;F`(i>Yg6&WcC z%l}_(a``cr&X1h?MF$)ogc%%W(yY>6hIH@oH?*T(i&ziJU?!{-d@|YD}p1j-c(5SH?V*0KF?*pD|$rYSe z$bQknD)~%;UGGDqfX&VK76*E~LmMRbzPkI~=7xa4C%q=^J#z(^)F)KV&Eh@7A%Bf+ z(_xk@zBa1`5A6TzbT~9ec(vUA#m-&eVHH-gNMYNFNcDfqUDUWHcI;zrX7;*R%jKl3Ovxv;V6+%F_Cl&r;aO-k_5A(ve_nP(ynJLH_$zWM2gs>tY`XH1;#mX{0_-%px-Tx(~yx6$Hk zrb=lp;S)UGll1zEbr!tJ5IyOB&TCTQ8P#ZxunF969_1cMO6HZmaoo2nAUsTfP4hqi zQwz@|(a!GHvt28Xv8r7vZ#0>z{QtxRx1u5iafaqcZ9jKK{dH!a{>PpFYNlVKpnyq9o%>h63f-)=dRmiW-mM}A%@)IpLemm5&#J8U&R^cF8g^i1 zfavn8XFA+Eq`s+HEV(Lm;pHl?$ef1FmqgstuWFw-cSY=Ylp;5W9Gyh*bVCoIf*_vYQ;PpnSGpTsx^)%C{s5q{z zcQ0Q}v*tD^_gp>g|BJWz-`x&;bN!IRH(}k%X}`C57dqrD`H_*AZz;h2>hp#dwP#pY z?3i$t?}%8 z94LCcxcOk_1g<6D58ZIQd(7$DoxIE$3G+-BowWLt$d~-%h<}izL`%StgDYnoYt|*_%_ct?(@uO_D&~8jswnD=d56qNchjd{KGJ=M}|Re#{te9 zw#A=5^~fI1Y+>S=;lS`{PckcmBj?4HJxoOgvjv|dur3PpVBmYe)cmCi+^2R`)$keMA_Go-BwptOxwE7oHsjge%1AL zabH(7-hJs^{eYpS?U2;9g0?j4-#0JxZQHWk_ia{m^(}pd#%R|a zFRnkY`2T(`f6X;%`&UdSjIXYhD>K~j|L>dgzhu@Z@>lM07W#O8m&CsdeZLMLdi7Pj zq4feQqXz%K1g_-irQrc(N(E)P?PZ1njr=Fdqzo7f%>|0DJ2ow5=rQ2l+fdQ?U7>=# zso^^JW(VG14*c>JJVyige=XpD_(9>Q1OL|o{%0TZD!w=FGT>IeQQlV0A|{~RU(Pvu zLFN1noP`d|&J!xglwAML z^~s=pe|oKxIM1j5AGl{KaGzD+{N%vhwxjT&c}eYx;>rh%Sr!7Xzn5NX;9U~YwQNFt z><9Lz30(aFd>;$gR@`7sV&MB8&iBuN{r7f`?g>1f8tUsea7>!O^Us0njX4|V4OZ6+ z4Z=R7&RX6gj?4h7zm>fD>I7tU1R z-|3K5Q&4ESv&QH-+tLd>ew|$6)U0=?(`#pYb%CdnfjupfqhUtPS%s-5)p?Dc zPd4kEyrX@}9rv180TvUup6*$yutc*0#}pfj2jbpR&dmBDCpj3!T#?4^ zF!S!W59-3ryPq<2cQf#POXrOfFXPTApAhs9=A3q`9Isx0U8 z`^o*jp=9=r!pZ=K8a0Nxou&US@V$M&mnOyEcCxfSf$J>;--aEf^#(l~9`Jpdux#ZE z{*?mt-3t7bTr=J!aIIwEo2yxNa>Xna1*Yl^>;?}Ozxuy`btPx73Pbt2nT-r4oSP*p z9-rX<&cO5c0RNo_jVe1T-YjU^JAwCW2H)0`d~>*X?kwQ{)4=!IgLg**|IY(_3vQKf zW>99`ICtWQ3DdW8KAKrsWWbc)z#225GGYTGr)K5hs5Qr>I5jRXYdbJc*xq_kit~x& z8Zl4iHig!h4HK1TGFK!nI5vT?+@RR$d(puKe3Ke@-!G`jn!tX^VQHEI-~A7K_Zv8b zq}HD;SeR$P@k)Warh&sni=+Ppdxj^sczdn;O1}3Be1=)X|g$of0iD+3Oj<4J^CcmHtN;mnAT<2ME-5 zcHL88TQ-4jNdeozAJaD^H0=AZEJ~g8@D09Y5BP2zsN3T)bJL0L9XD9-3GlBpVE584 z3tzz0KdVgY!qi6$OsCnIPjU24@)DS0WwE$mWy5yfttw-O}Rnxxn0Iz!JD& z;*5a#N;`MX{k^ks1EY2Wt4{+{(FRVgldNm4CkAe4nKYf*`2ef)2BvQ>S5I@7W6fIN zv4EXNYMPlR_tS($%IWN%yB2;G-z2$w&%alkF1xB;J>Zmz>bQ5Er{qSVxjOf!3q0lj z8>TW=u&@5k_1>Uec-E4C8}?m((6--viR-K)53M4$)l(ly^M7*Km&UMH;ulxJ0`{xj zMHL;jdJQ=&f()U{OYa%5y??;}(ty3cp+4T6{m>7NjXya4G4Q?H!1gX-Mv?)0sZ_l} z&hmN&zSr5ylc%#T(A@SanlFlh*)U-R|Bh`E9J8A~Z)@4!yVrsDPr~*)4BM-3Z(mx) zZ?b~>s{`MT3;cH{@GlHly=w#4%2$mOq<0zyu|BYxbiix({08P20gi|cPX3)oWB(lO z+Q1kl!>RpY=e}K>n`c+HU0{w_v76Cq(!T5kz8e^HPA+iHnylnCt>WhTf(=!V40ykH zl^AfJ5MIT%<$uBy*62=MZT^EBHXK-9m{+hdPl4m@2i^<77zmMAYT>3-_!{ozl z9Lm#+^_TM<445W9dqY9M{@HgWUM%>Jv(PI+v$knDMTB%lV$t&dRf!D%eEc@C&`!ny`A7(u2kS6!@1# zavR+#Gs)?F!RBDGr*}sb|JMh3!Ec&twCB`C&)%_;|Kv&jp9^>wUf{ppz};)jz1WK< z*oIqh)9TO*tEbOC>f6B??!nA1$;#ciR?zmMXz#K6t2i>>>~gO-nscXBWhduE1D3y* z#}{p9dh7*0#dE^~r|Gqi{yWszX>oKO;53`bd1!WZ(}N8&8Q4n>On!9Wa><686&HBa zv^ef7@GUjq{S?r_ti#}(ODOlghaH{4(iBcEW%mdsR1_z{`ckFNA z+VX*SN=E0`)mLNQUj6TN#bzzrPMHJduM4fJs}7tg{p8T~GJ)$g_smFj&Zi1lYCYXa z6K1?)=uS^KbnpfL`5LxdsWZ7%Y~kkY_a2-%^y6&&@+>ydrgMGzuzAyo z`hU;ssdAev`3-H~IV4#MNcIFBY?{;4|duz2woGlYN+}nb~H=cEt?7hZD9p zrSq9q@P8HH(|OVKU7&fFL9^+MIlmt4sBq{1zJWLRPXEpSo&pjVE?kjiwGe2tJir=K zv3sUCyY_{*Xk^=n_rfFVp>)BhQ0pB_sU$K z^sv16PW2J#23s^41W%hY}Az$JrQ{1J_82?Dv=7G=k0Z@&AkdiwUgl|0)UDh@y3 z(SGr)(VS;jmOyvi(dpb(;*~oS?o=vUe)w-!IlZ8bEs&vp0((N|8-f1Lo%@!WrPoRx z?~u^{m?-(WXGZ&)>mLQr7v_CnvhfqJyRh}_yiZ#DKgB~Xu=ETM#hE*7ufHA@%#T}0sFm2vji9&ST@Lg zikSa3YX8@m{a^JH4lqAtu0Q!HCH`CT_Cph9eN#zz$1(9;bF#$ts4qqK-|MGu?=2UQ z5a9U7_qFE!_eJ9DM>YyDZD7!wz;dMQN5}jhUHgCZ-2c(HKKs-@?bBtsQ{sP4tN(eP zjrHcY+&TAu-nzypcH>s&zgvt;7~~JIrB29Swg1yBEZOcAtU{F$q!Lyv%p1 z`tv`+$)H0_9FqBdm**^CYMIz%HD&pfghk7^N}c9B!bQ(g(qdeElM z5$C}1EPl=LiqDpBq64yzd$Ab@F6q3vbmmr$$~2qFI*Vcto|(q8CTEhfqmy8(6plD`>^anj5yoVoA!SiToTG|1}&JZCE>JJeqh&_Oa+Bv&f7|6P{Y0crr!jrTT?Qf{~pU+oYY2q)v|x zs$4iFY1+)|I&zkrf=SC~?z||$vXW)#?2>Dp_nFGFR1VlPy^%Zsnybs0hcQ>D^>WFS zu&kF$XNc`O=Tz}$v-h$EZMQVELvJd*TDgMjy637j+q_;%tvmK#>-Cx~*Ro!(-SKP{ zuDQC|f-84SdhupUz|0?lA|kb3Z@0~y7cNxFZhHLfjwCN>;jX4iZ-H$bowF~dJUN#A zD%se<&uy2+b@>`+;zWR2?@{rdNm?_?bmBD&ixmPWi4G#w{{~vN$U!RJisX|I+B-kdUJ5J#~MJx$AuMCwyL81dmjB^Jw!tJW@LEpTH?;%ixJS3)0=I zU1WoA>R;TQe9QXG{{O*MK8p2!w(?az%e(YJ+E8Axg2QpO;Daea`GqcfPL{l1a#P#n z=QsUc=6}r{_FR9LEIvKw-;{}lH|yT^9pXb-v9X=!%GXBP66pExYX$HJSXBx3YPY0)g^x9jE2BI7;xO%#w>s@WmBG0lYt}FA; z-eM1B-qs5%6oP7e#Mc@f=~}W-@4v@!`(wwqRw=l2a~uo`IP_xD=87lXEQvi1FFGb! z&SkN&Q}R&Q&&J=*gK{` zp96^k?`I?_Hj8*@u`9KOyqcI@q{3mk!bq%bg>%Ge9aWce*SAJkwMf`@K2e&@gDf&Je9f$FCpY%s$?cR+3jJ;BrW1CP$)*+1B6*xoJmc+x7HWuqU1EXJJ9iJ}EFT zGBn^U_LRM3G!hn}T>FB&Iq_4{^l8v*U(U{k%t)pmF|Y&ANWM6l^8b-y5Bs?lmzM`D zUR9GB&=WFA{NF^W8!uK}U!QQe3u&`5=-QWp$ER2`OV=G;SN3_G?c(k0p;Nsx3}zjI zY)&+E)PRkjsGPJChRGHww<*p{bbkcpWmG_)noqmvmS}~ z@OiSzlKu4;FaGm%pOu-4E}b&T*_QL^l#sB@r=TSYPp4IVJn(dSLfgue>ZECzuq6uHUOppoiNXq}4K}YP zN{8Df352=@otmH^mKim3Y0a%hq2;W$E7@XZY^%5tz35zM_{>)+Vri$GS9<-rwtAaX z$}y+i&t8SE-}g@#X`)h)ftg_kV+pK=)DSppZ^eS;j8gawLC8r3yssU6oFI4_@YS80 zffD|mEp?F9ENzEcWMaYl+I93D=Kc!iy}CNW_m+@{=IZbnHTw`rWocI0+S^MRq@pUl z%NNhxRRO&rh~BFLb`iZQAR+MajAK0xR|TG&Z*+Yq7^N%_mZ9jmF~;Iv)BlpC58Inf zIUe~c34MFefs#~|b433fKHLLND!i#D9``%2fmXAKfs@Kgl+`ToqyoAjXnI1M=Cc_o fSdvQWE2rmahmmtWo(bPkDRHE9KN=cCA~dW4Ap*#= From 5fec23018188e4b33bec26794e0e557dc669c23d Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 19 Dec 2025 10:42:17 +0100 Subject: [PATCH 033/360] Also commit readme --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 73735f4..2013eb0 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ Aikido Safe Chain supports the following package managers: # Usage +![Aikido Safe Chain demo](https://raw.githubusercontent.com/AikidoSec/safe-chain/main/docs/safe-package-manager-demo.gif) + ## Installation Installing the Aikido Safe Chain is easy with our one-line installer. @@ -49,11 +51,13 @@ iex (iwr "https://github.com/AikidoSec/safe-chain/releases/latest/download/insta To install a specific version instead of the latest, replace `latest` with the version number in the URL (available from version 1.3.2 onwards): **Unix/Linux/macOS:** + ```shell curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/x.x.x/install-safe-chain.sh | sh ``` **Windows (PowerShell):** + ```powershell iex (iwr "https://github.com/AikidoSec/safe-chain/releases/download/x.x.x/install-safe-chain.ps1" -UseBasicParsing) ``` From b571aad6a0be5b54fe32148d69bad7b5057c250a Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 19 Dec 2025 16:18:21 +0100 Subject: [PATCH 034/360] Add command to verify safe-chain is intercepting the package managers commands --- README.md | 15 ++++++++++++++- packages/safe-chain/bin/safe-chain.js | 7 +++++-- packages/safe-chain/src/main.js | 13 +++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 29c6510..0d7866c 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,20 @@ You can find all available versions on the [releases page](https://github.com/Ai - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, poetry, uv and pipx are loaded correctly. If you do not restart your terminal, the aliases will not be available. -2. **Verify the installation** by running one of the following commands: +2. **Verify the installation** by running the verification command: + + ```shell + npm safe-chain-verify + pnpm safe-chain-verify + pip safe-chain-verify + uv safe-chain-verify + + # Any other supported package manager: {packagemanager} safe-chain-verify + ``` + + - The output should display "OK: Safe-chain works!" confirming that Aikido Safe Chain is properly installed and running. + +3. **(Optional) Test malware blocking** by attempting to install a test package: For JavaScript/Node.js: diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index aed77f0..841ccee 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -3,7 +3,10 @@ import chalk from "chalk"; import { ui } from "../src/environment/userInteraction.js"; import { setup } from "../src/shell-integration/setup.js"; -import { teardown, teardownDirectories } from "../src/shell-integration/teardown.js"; +import { + teardown, + teardownDirectories, +} from "../src/shell-integration/teardown.js"; import { setupCi } from "../src/shell-integration/setup-ci.js"; import { initializeCliArguments } from "../src/config/cliArguments.js"; import { setEcoSystem } from "../src/config/settings.js"; @@ -45,7 +48,7 @@ if (tool) { const args = process.argv.slice(3); setEcoSystem(tool.ecoSystem); - + // Provide tool context to PM (pip uses this; others ignore) const toolContext = { tool: tool.tool, args }; initializePackageManager(tool.internalPackageManagerName, toolContext); diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index 0e895b3..9b7ba53 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -13,6 +13,10 @@ import { getAuditStats } from "./scanning/audit/index.js"; * @returns {Promise} */ export async function main(args) { + if (isSafeChainVerify(args)) { + return 0; + } + process.on("SIGINT", handleProcessTermination); process.on("SIGTERM", handleProcessTermination); @@ -104,3 +108,12 @@ export async function main(args) { function handleProcessTermination() { ui.writeBufferedLogsAndStopBuffering(); } + +/** @param {string[]} args */ +function isSafeChainVerify(args) { + const safeChainCheckCommand = "safe-chain-verify"; + if (args.length > 0 && args[0] === safeChainCheckCommand) { + ui.writeInformation("OK: Safe-chain works!"); + return true; + } +} From bd19f477f7835936d42272fc0d0a38abba6ee4f8 Mon Sep 17 00:00:00 2001 From: cherryace Date: Fri, 19 Dec 2025 17:57:33 -0800 Subject: [PATCH 035/360] Using port from req url when creating proxy request instead of hardcoded port 443 --- .../src/registryProxy/mitmRequestHandler.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js index cf2af5b..6218280 100644 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js @@ -15,7 +15,7 @@ import { gunzipSync, gzipSync } from "zlib"; */ export function mitmConnect(req, clientSocket, interceptor) { ui.writeVerbose(`Safe-chain: Set up MITM tunnel for ${req.url}`); - const { hostname } = new URL(`http://${req.url}`); + const { hostname, port } = new URL(`http://${req.url}`); clientSocket.on("error", (err) => { ui.writeVerbose( @@ -26,7 +26,7 @@ export function mitmConnect(req, clientSocket, interceptor) { // Not subscribing to 'close' event will cause node to throw and crash. }); - const server = createHttpsServer(hostname, interceptor); + const server = createHttpsServer(hostname, port, interceptor); server.on("error", (err) => { ui.writeError(`Safe-chain: HTTPS server error: ${err.message}`); @@ -46,10 +46,11 @@ export function mitmConnect(req, clientSocket, interceptor) { /** * @param {string} hostname + * @param {string} port * @param {Interceptor} interceptor * @returns {import("https").Server} */ -function createHttpsServer(hostname, interceptor) { +function createHttpsServer(hostname, port, interceptor) { const cert = generateCertForHost(hostname); /** @@ -80,7 +81,7 @@ function createHttpsServer(hostname, interceptor) { } // Collect request body - forwardRequest(req, hostname, res, requestInterceptor); + forwardRequest(req, hostname, port, res, requestInterceptor); } const server = https.createServer( @@ -109,11 +110,12 @@ function getRequestPathAndQuery(url) { /** * @param {import("http").IncomingMessage} req * @param {string} hostname + * @param {string} port * @param {import("http").ServerResponse} res * @param {import("./interceptors/interceptorBuilder.js").RequestInterceptionHandler} requestHandler */ -function forwardRequest(req, hostname, res, requestHandler) { - const proxyReq = createProxyRequest(hostname, req, res, requestHandler); +function forwardRequest(req, hostname, port, res, requestHandler) { + const proxyReq = createProxyRequest(hostname, port, req, res, requestHandler); proxyReq.on("error", (err) => { ui.writeVerbose( @@ -144,13 +146,14 @@ function forwardRequest(req, hostname, res, requestHandler) { /** * @param {string} hostname + * @param {string} port * @param {import("http").IncomingMessage} req * @param {import("http").ServerResponse} res * @param {import("./interceptors/interceptorBuilder.js").RequestInterceptionHandler} requestHandler * * @returns {import("http").ClientRequest} */ -function createProxyRequest(hostname, req, res, requestHandler) { +function createProxyRequest(hostname, port, req, res, requestHandler) { /** @type {NodeJS.Dict | undefined} */ let headers = { ...req.headers }; // Remove the host header from the incoming request before forwarding. @@ -163,7 +166,7 @@ function createProxyRequest(hostname, req, res, requestHandler) { /** @type {import("http").RequestOptions} */ const options = { hostname: hostname, - port: 443, + port: port, path: req.url, method: req.method, headers: { ...headers }, From 3b6beb7f1665523132117bbbd1f05068390ad869 Mon Sep 17 00:00:00 2001 From: jassanw Date: Fri, 19 Dec 2025 18:49:58 -0800 Subject: [PATCH 036/360] default to port 443 if port is null or empty --- packages/safe-chain/src/registryProxy/mitmRequestHandler.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js index 6218280..8268559 100644 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js @@ -166,7 +166,7 @@ function createProxyRequest(hostname, port, req, res, requestHandler) { /** @type {import("http").RequestOptions} */ const options = { hostname: hostname, - port: port, + port: port || 443, path: req.url, method: req.method, headers: { ...headers }, From c53a7347e22105f0e3304540393447166648e5c8 Mon Sep 17 00:00:00 2001 From: galargh Date: Mon, 22 Dec 2025 13:49:45 +0100 Subject: [PATCH 037/360] feat: allow python custom registries configuration through config file --- README.md | 12 +- packages/safe-chain/src/config/configFile.js | 26 ++ .../safe-chain/src/config/configFile.spec.js | 144 +++--- packages/safe-chain/src/config/settings.js | 5 +- .../safe-chain/src/config/settings.spec.js | 421 +++++++++--------- 5 files changed, 325 insertions(+), 283 deletions(-) diff --git a/README.md b/README.md index 29c6510..a55c63b 100644 --- a/README.md +++ b/README.md @@ -188,9 +188,13 @@ You can set the minimum package age through multiple sources (in order of priori } ``` -## Custom NPM Registries +## Custom Registries -Configure Safe Chain to scan packages from custom or private npm registries. +Configure Safe Chain to scan packages from custom or private registries. + +Supported ecosystems: +- Node.js +- Python ### Configuration Options @@ -200,6 +204,7 @@ You can set custom registries through environment variable or config file. Both ```shell export SAFE_CHAIN_NPM_CUSTOM_REGISTRIES="npm.company.com,registry.internal.net" + export SAFE_CHAIN_PIP_CUSTOM_REGISTRIES="pip.company.com,registry.internal.net" ``` 2. **Config File** (`~/.aikido/config.json`): @@ -208,6 +213,9 @@ You can set custom registries through environment variable or config file. Both { "npm": { "customRegistries": ["npm.company.com", "registry.internal.net"] + }, + "pip": { + "customRegistries": ["pip.company.com", "registry.internal.net"] } } ``` diff --git a/packages/safe-chain/src/config/configFile.js b/packages/safe-chain/src/config/configFile.js index 1b7525b..a98304e 100644 --- a/packages/safe-chain/src/config/configFile.js +++ b/packages/safe-chain/src/config/configFile.js @@ -11,6 +11,7 @@ import { getEcoSystem } from "./settings.js"; * @property {unknown | Number} scanTimeout * @property {unknown | Number} minimumPackageAgeHours * @property {unknown | SafeChainRegistryConfiguration} npm + * @property {unknown | SafeChainRegistryConfiguration} pip * * @typedef {Object} SafeChainRegistryConfiguration * We cannot trust the input and should add the necessary validations. @@ -104,6 +105,28 @@ export function getNpmCustomRegistries() { return customRegistries.filter((item) => typeof item === "string"); } +/** + * Gets the custom npm registries from the config file (format parsing only, no validation) + * @returns {string[]} + */ +export function getPipCustomRegistries() { + const config = readConfigFile(); + + if (!config || !config.pip) { + return []; + } + + // TypeScript needs help understanding that config.pip exists and has customRegistries + const pipConfig = /** @type {SafeChainRegistryConfiguration} */ (config.pip); + const customRegistries = pipConfig.customRegistries; + + if (!Array.isArray(customRegistries)) { + return []; + } + + return customRegistries.filter((item) => typeof item === "string"); +} + /** * @param {import("../api/aikido.js").MalwarePackage[]} data * @param {string | number} version @@ -169,6 +192,9 @@ function readConfigFile() { npm: { customRegistries: undefined, }, + pip: { + customRegistries: undefined, + }, }; const configFilePath = getConfigFilePath(); diff --git a/packages/safe-chain/src/config/configFile.spec.js b/packages/safe-chain/src/config/configFile.spec.js index f5c6df8..601b0d0 100644 --- a/packages/safe-chain/src/config/configFile.spec.js +++ b/packages/safe-chain/src/config/configFile.spec.js @@ -232,91 +232,95 @@ describe("getMinimumPackageAgeHours", async () => { }); }); -describe("getNpmCustomRegistries", async () => { - const { getNpmCustomRegistries } = await import("./configFile.js"); +for (const packageManager of ["npm", "pip"]) { + const fnName = `get${packageManager.charAt(0).toUpperCase()}${packageManager.slice(1)}CustomRegistries`; - afterEach(() => { - configFileContent = undefined; - }); + describe(fnName, async () => { + const fn = (await import("./configFile.js"))[fnName]; - it("should return empty array when config file doesn't exist", () => { - configFileContent = undefined; - - const registries = getNpmCustomRegistries(); - - assert.deepStrictEqual(registries, []); - }); - - it("should return empty array when npm config is not set", () => { - configFileContent = JSON.stringify({ scanTimeout: 5000 }); - - const registries = getNpmCustomRegistries(); - - assert.deepStrictEqual(registries, []); - }); - - it("should return empty array when customRegistries is not an array", () => { - configFileContent = JSON.stringify({ - npm: { customRegistries: "not-an-array" }, + afterEach(() => { + configFileContent = undefined; }); - const registries = getNpmCustomRegistries(); + it("should return empty array when config file doesn't exist", () => { + configFileContent = undefined; - assert.deepStrictEqual(registries, []); - }); + const registries = fn(); - it("should return array of custom registries when set", () => { - configFileContent = JSON.stringify({ - npm: { - customRegistries: ["npm.company.com", "registry.internal.net"], - }, + assert.deepStrictEqual(registries, []); }); - const registries = getNpmCustomRegistries(); + it(`should return empty array when ${packageManager} config is not set`, () => { + configFileContent = JSON.stringify({ scanTimeout: 5000 }); - assert.deepStrictEqual(registries, [ - "npm.company.com", - "registry.internal.net", - ]); - }); + const registries = fn(); - it("should filter out non-string values", () => { - configFileContent = JSON.stringify({ - npm: { - customRegistries: [ - "npm.company.com", - 123, - null, - "registry.internal.net", - undefined, - {}, - ], - }, + assert.deepStrictEqual(registries, []); }); - const registries = getNpmCustomRegistries(); + it("should return empty array when customRegistries is not an array", () => { + configFileContent = JSON.stringify({ + [packageManager]: { customRegistries: "not-an-array" }, + }); - assert.deepStrictEqual(registries, [ - "npm.company.com", - "registry.internal.net", - ]); - }); + const registries = fn(); - it("should return empty array for empty customRegistries array", () => { - configFileContent = JSON.stringify({ - npm: { customRegistries: [] }, + assert.deepStrictEqual(registries, []); }); - const registries = getNpmCustomRegistries(); + it("should return array of custom registries when set", () => { + configFileContent = JSON.stringify({ + [packageManager]: { + customRegistries: [`${packageManager}.company.com`, "registry.internal.net"], + }, + }); - assert.deepStrictEqual(registries, []); + const registries = fn(); + + assert.deepStrictEqual(registries, [ + `${packageManager}.company.com`, + "registry.internal.net", + ]); + }); + + it("should filter out non-string values", () => { + configFileContent = JSON.stringify({ + [packageManager]: { + customRegistries: [ + `${packageManager}.company.com`, + 123, + null, + "registry.internal.net", + undefined, + {}, + ], + }, + }); + + const registries = fn(); + + assert.deepStrictEqual(registries, [ + `${packageManager}.company.com`, + "registry.internal.net", + ]); + }); + + it("should return empty array for empty customRegistries array", () => { + configFileContent = JSON.stringify({ + [packageManager]: { customRegistries: [] }, + }); + + const registries = fn(); + + assert.deepStrictEqual(registries, []); + }); + + it("should handle malformed JSON and return empty array", () => { + configFileContent = "{ invalid json"; + + const registries = fn(); + + assert.deepStrictEqual(registries, []); + }); }); - - it("should handle malformed JSON and return empty array", () => { - configFileContent = "{ invalid json"; - - const registries = getNpmCustomRegistries(); - - assert.deepStrictEqual(registries, []); - }); -}); +} diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index 3a756ea..573c3ab 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -152,11 +152,10 @@ export function getPipCustomRegistries() { const envRegistries = parseRegistriesFromEnv( environmentVariables.getPipCustomRegistries() ); - // const configRegistries = configFile.getPipCustomRegistries(); + const configRegistries = configFile.getPipCustomRegistries(); // Merge both sources and remove duplicates - // const allRegistries = [...envRegistries, ...configRegistries]; - const allRegistries = [...envRegistries]; + const allRegistries = [...envRegistries, ...configRegistries]; const uniqueRegistries = [...new Set(allRegistries)]; // Normalize each registry (remove protocol if any) diff --git a/packages/safe-chain/src/config/settings.spec.js b/packages/safe-chain/src/config/settings.spec.js index 05d698f..778628b 100644 --- a/packages/safe-chain/src/config/settings.spec.js +++ b/packages/safe-chain/src/config/settings.spec.js @@ -11,239 +11,244 @@ mock.module("fs", { }, }); -describe("getNpmCustomRegistries", async () => { - let originalEnv; - const { getNpmCustomRegistries } = await import("./settings.js"); +for (const packageManager of ["npm", "pip"]) { + const fnName = `get${packageManager.charAt(0).toUpperCase()}${packageManager.slice(1)}CustomRegistries`; + const envVarName = `SAFE_CHAIN_${packageManager.toUpperCase()}_CUSTOM_REGISTRIES`; - beforeEach(() => { - originalEnv = process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; - }); + describe(fnName, async () => { + let originalEnv; + const fn = (await import("./settings.js"))[fnName]; - afterEach(() => { - if (originalEnv !== undefined) { - process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = originalEnv; - } else { - delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; - } - configFileContent = undefined; - }); - - it("should return empty array when no registries configured", () => { - configFileContent = undefined; - - const registries = getNpmCustomRegistries(); - - assert.deepStrictEqual(registries, []); - }); - - it("should return registries without protocol", () => { - configFileContent = JSON.stringify({ - npm: { - customRegistries: ["npm.company.com", "registry.internal.net"], - }, + beforeEach(() => { + originalEnv = process.env[envVarName]; }); - const registries = getNpmCustomRegistries(); - - assert.deepStrictEqual(registries, [ - "npm.company.com", - "registry.internal.net", - ]); - }); - - it("should strip https:// protocol from registries", () => { - configFileContent = JSON.stringify({ - npm: { - customRegistries: [ - "https://npm.company.com", - "https://registry.internal.net", - ], - }, + afterEach(() => { + if (originalEnv !== undefined) { + process.env[envVarName] = originalEnv; + } else { + delete process.env[envVarName]; + } + configFileContent = undefined; }); - const registries = getNpmCustomRegistries(); + it("should return empty array when no registries configured", () => { + configFileContent = undefined; - assert.deepStrictEqual(registries, [ - "npm.company.com", - "registry.internal.net", - ]); - }); + const registries = fn(); - it("should strip http:// protocol from registries", () => { - configFileContent = JSON.stringify({ - npm: { - customRegistries: [ - "http://npm.company.com", - "http://registry.internal.net", - ], - }, + assert.deepStrictEqual(registries, []); }); - const registries = getNpmCustomRegistries(); + it("should return registries without protocol", () => { + configFileContent = JSON.stringify({ + [packageManager]: { + customRegistries: [`${packageManager}.company.com`, "registry.internal.net"], + }, + }); - assert.deepStrictEqual(registries, [ - "npm.company.com", - "registry.internal.net", - ]); - }); + const registries = fn(); - it("should handle mixed protocols and no protocol", () => { - configFileContent = JSON.stringify({ - npm: { - customRegistries: [ - "https://npm.company.com", - "registry.internal.net", - "http://private.registry.io", - ], - }, + assert.deepStrictEqual(registries, [ + `${packageManager}.company.com`, + "registry.internal.net", + ]); }); - const registries = getNpmCustomRegistries(); + it("should strip https:// protocol from registries", () => { + configFileContent = JSON.stringify({ + [packageManager]: { + customRegistries: [ + `https://${packageManager}.company.com`, + "https://registry.internal.net", + ], + }, + }); - assert.deepStrictEqual(registries, [ - "npm.company.com", - "registry.internal.net", - "private.registry.io", - ]); - }); + const registries = fn(); - it("should preserve registry path after stripping protocol", () => { - configFileContent = JSON.stringify({ - npm: { - customRegistries: [ - "https://npm.company.com/custom/path", - "registry.internal.net/npm", - ], - }, + assert.deepStrictEqual(registries, [ + `${packageManager}.company.com`, + "registry.internal.net", + ]); }); - const registries = getNpmCustomRegistries(); + it("should strip http:// protocol from registries", () => { + configFileContent = JSON.stringify({ + [packageManager]: { + customRegistries: [ + `http://${packageManager}.company.com`, + "http://registry.internal.net", + ], + }, + }); - assert.deepStrictEqual(registries, [ - "npm.company.com/custom/path", - "registry.internal.net/npm", - ]); - }); + const registries = fn(); - it("should parse comma-separated registries from environment variable", () => { - delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; - process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = - "env1.registry.com,env2.registry.net"; - configFileContent = undefined; - - const registries = getNpmCustomRegistries(); - - assert.deepStrictEqual(registries, [ - "env1.registry.com", - "env2.registry.net", - ]); - }); - - it("should trim whitespace from environment variable registries", () => { - delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; - process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = - " env1.registry.com , env2.registry.net "; - configFileContent = undefined; - - const registries = getNpmCustomRegistries(); - - assert.deepStrictEqual(registries, [ - "env1.registry.com", - "env2.registry.net", - ]); - }); - - it("should merge environment variable and config file registries", () => { - delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; - process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = "env1.registry.com"; - configFileContent = JSON.stringify({ - npm: { - customRegistries: ["config1.registry.net"], - }, + assert.deepStrictEqual(registries, [ + `${packageManager}.company.com`, + "registry.internal.net", + ]); }); - const registries = getNpmCustomRegistries(); + it("should handle mixed protocols and no protocol", () => { + configFileContent = JSON.stringify({ + [packageManager]: { + customRegistries: [ + `https://${packageManager}.company.com`, + "registry.internal.net", + "http://private.registry.io", + ], + }, + }); - assert.deepStrictEqual(registries, [ - "env1.registry.com", - "config1.registry.net", - ]); - }); + const registries = fn(); - it("should remove duplicate registries when merging env and config", () => { - delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; - process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = - "npm.company.com,env.registry.com"; - configFileContent = JSON.stringify({ - npm: { - customRegistries: ["npm.company.com", "config.registry.net"], - }, + assert.deepStrictEqual(registries, [ + `${packageManager}.company.com`, + "registry.internal.net", + "private.registry.io", + ]); }); - const registries = getNpmCustomRegistries(); + it("should preserve registry path after stripping protocol", () => { + configFileContent = JSON.stringify({ + [packageManager]: { + customRegistries: [ + `https://${packageManager}.company.com/custom/path`, + `registry.internal.net/${packageManager}`, + ], + }, + }); - assert.deepStrictEqual(registries, [ - "npm.company.com", - "env.registry.com", - "config.registry.net", - ]); + const registries = fn(); + + assert.deepStrictEqual(registries, [ + `${packageManager}.company.com/custom/path`, + `registry.internal.net/${packageManager}`, + ]); + }); + + it("should parse comma-separated registries from environment variable", () => { + delete process.env[envVarName]; + process.env[envVarName] = + "env1.registry.com,env2.registry.net"; + configFileContent = undefined; + + const registries = fn(); + + assert.deepStrictEqual(registries, [ + "env1.registry.com", + "env2.registry.net", + ]); + }); + + it("should trim whitespace from environment variable registries", () => { + delete process.env[envVarName]; + process.env[envVarName] = + " env1.registry.com , env2.registry.net "; + configFileContent = undefined; + + const registries = fn(); + + assert.deepStrictEqual(registries, [ + "env1.registry.com", + "env2.registry.net", + ]); + }); + + it("should merge environment variable and config file registries", () => { + delete process.env[envVarName]; + process.env[envVarName] = "env1.registry.com"; + configFileContent = JSON.stringify({ + [packageManager]: { + customRegistries: ["config1.registry.net"], + }, + }); + + const registries = fn(); + + assert.deepStrictEqual(registries, [ + "env1.registry.com", + "config1.registry.net", + ]); + }); + + it("should remove duplicate registries when merging env and config", () => { + delete process.env[envVarName]; + process.env[envVarName] = + `${packageManager}.company.com,env.registry.com`; + configFileContent = JSON.stringify({ + [packageManager]: { + customRegistries: [`${packageManager}.company.com`, "config.registry.net"], + }, + }); + + const registries = fn(); + + assert.deepStrictEqual(registries, [ + `${packageManager}.company.com`, + "env.registry.com", + "config.registry.net", + ]); + }); + + it("should normalize protocols from environment variable registries", () => { + delete process.env[envVarName]; + process.env[envVarName] = + "https://env1.registry.com,http://env2.registry.net"; + configFileContent = undefined; + + const registries = fn(); + + assert.deepStrictEqual(registries, [ + "env1.registry.com", + "env2.registry.net", + ]); + }); + + it("should handle empty strings in comma-separated list", () => { + delete process.env[envVarName]; + process.env[envVarName] = + "env1.registry.com,,env2.registry.net,"; + configFileContent = undefined; + + const registries = fn(); + + assert.deepStrictEqual(registries, [ + "env1.registry.com", + "env2.registry.net", + ]); + }); + + it("should handle single registry in environment variable", () => { + delete process.env[envVarName]; + process.env[envVarName] = "single.registry.com"; + configFileContent = undefined; + + const registries = fn(); + + assert.deepStrictEqual(registries, ["single.registry.com"]); + }); + + it("should return empty array for empty environment variable", () => { + delete process.env[envVarName]; + process.env[envVarName] = ""; + configFileContent = undefined; + + const registries = fn(); + + assert.deepStrictEqual(registries, []); + }); + + it("should return empty array for whitespace-only environment variable", () => { + delete process.env[envVarName]; + process.env[envVarName] = " , , "; + configFileContent = undefined; + + const registries = fn(); + + assert.deepStrictEqual(registries, []); + }); }); - - it("should normalize protocols from environment variable registries", () => { - delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; - process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = - "https://env1.registry.com,http://env2.registry.net"; - configFileContent = undefined; - - const registries = getNpmCustomRegistries(); - - assert.deepStrictEqual(registries, [ - "env1.registry.com", - "env2.registry.net", - ]); - }); - - it("should handle empty strings in comma-separated list", () => { - delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; - process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = - "env1.registry.com,,env2.registry.net,"; - configFileContent = undefined; - - const registries = getNpmCustomRegistries(); - - assert.deepStrictEqual(registries, [ - "env1.registry.com", - "env2.registry.net", - ]); - }); - - it("should handle single registry in environment variable", () => { - delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; - process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = "single.registry.com"; - configFileContent = undefined; - - const registries = getNpmCustomRegistries(); - - assert.deepStrictEqual(registries, ["single.registry.com"]); - }); - - it("should return empty array for empty environment variable", () => { - delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; - process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = ""; - configFileContent = undefined; - - const registries = getNpmCustomRegistries(); - - assert.deepStrictEqual(registries, []); - }); - - it("should return empty array for whitespace-only environment variable", () => { - delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; - process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = " , , "; - configFileContent = undefined; - - const registries = getNpmCustomRegistries(); - - assert.deepStrictEqual(registries, []); - }); -}); +} From 7bfbe1376bf8c1e84c0b5b32ab40dee27e7d8e41 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Tue, 30 Dec 2025 09:22:03 -0800 Subject: [PATCH 038/360] Jenkins CI pipeline --- README.md | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/README.md b/README.md index 29c6510..9767b6c 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,7 @@ iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download - ✅ **GitHub Actions** - ✅ **Azure Pipelines** - ✅ **CircleCI** +- ✅ **Jenkins** ## GitHub Actions Example @@ -288,4 +289,70 @@ workflows: - build ``` +## Jenkins Example + +```groovy +pipeline { + agent any + + environment { + // Jenkins does not automatically persist PATH updates from setup-ci, + // so add the shims + binary directory explicitly for all stages. + PATH = "${env.HOME}/.safe-chain/shims:${env.HOME}/.safe-chain/bin:${env.PATH}" + } + + stages { + stage('Install Node.js') { + steps { + sh ''' + set -euo pipefail + + # install Node.js + npm (requires root, or passwordless sudo on the agent) + sudo -n apt-get update + sudo -n apt-get install -y nodejs npm + + node -v + npm -v + ''' + } + } + + stage('Install safe-chain') { + steps { + sh ''' + set -euo pipefail + + # Install Safe Chain for CI + curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci + ''' + } + } + + stage('Verify safe-chain on PATH') { + steps { + sh ''' + set -euo pipefail + + command -v safe-chain + command -v npm + + # Test: npm should resolve to the safe-chain shim + test "$(command -v npm)" = "$HOME/.safe-chain/shims/npm" + ''' + } + } + + stage('Install project dependencies etc...') { + steps { + sh ''' + set -euo pipefail + npm ci + ''' + } + } + } +} +``` + + After setup, all subsequent package manager commands in your CI pipeline will automatically be protected by Aikido Safe Chain's malware detection. From 8d0dcd00680297c2d3a98ee2e8fcfc02ec5656b2 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Tue, 30 Dec 2025 10:11:25 -0800 Subject: [PATCH 039/360] Small fix --- README.md | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 9767b6c..14388cb 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,8 @@ workflows: ## Jenkins Example +Note: This assumes Node.js and npm are installed on the Jenkins agent. + ```groovy pipeline { agent any @@ -302,21 +304,6 @@ pipeline { } stages { - stage('Install Node.js') { - steps { - sh ''' - set -euo pipefail - - # install Node.js + npm (requires root, or passwordless sudo on the agent) - sudo -n apt-get update - sudo -n apt-get install -y nodejs npm - - node -v - npm -v - ''' - } - } - stage('Install safe-chain') { steps { sh ''' From bc4370348fac041b2ed331f42d31a5baf8d6cd56 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Tue, 30 Dec 2025 11:19:00 -0800 Subject: [PATCH 040/360] Adapt per review --- README.md | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/README.md b/README.md index 14388cb..b62c4f2 100644 --- a/README.md +++ b/README.md @@ -315,20 +315,6 @@ pipeline { } } - stage('Verify safe-chain on PATH') { - steps { - sh ''' - set -euo pipefail - - command -v safe-chain - command -v npm - - # Test: npm should resolve to the safe-chain shim - test "$(command -v npm)" = "$HOME/.safe-chain/shims/npm" - ''' - } - } - stage('Install project dependencies etc...') { steps { sh ''' From a0e19818a095b3e5241494d1c8f277a15658d74c Mon Sep 17 00:00:00 2001 From: Graeme Chapman Date: Wed, 31 Dec 2025 10:18:58 +0000 Subject: [PATCH 041/360] fix: Allow running commands if safe-chain npm package is not installed --- .../src/shell-integration/startup-scripts/init-posix.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh index f22f79b..7085465 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh @@ -83,6 +83,10 @@ function wrapSafeChainCommand() { # If the aikido command is not available, print a warning and run the original command printSafeChainWarning "$original_cmd" + # Remove the first argument (original_cmd) from $@ + # so that "$@" now contains only the arguments passed to the original command + shift 1 + command "$original_cmd" "$@" fi } From c510d886a95f62070355f3ee49efe1bbee7b2d70 Mon Sep 17 00:00:00 2001 From: Graeme Chapman Date: Wed, 31 Dec 2025 10:57:08 +0000 Subject: [PATCH 042/360] Simplify command execution in init-posix.sh --- .../src/shell-integration/startup-scripts/init-posix.sh | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh index 7085465..e649909 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh @@ -83,10 +83,6 @@ function wrapSafeChainCommand() { # If the aikido command is not available, print a warning and run the original command printSafeChainWarning "$original_cmd" - # Remove the first argument (original_cmd) from $@ - # so that "$@" now contains only the arguments passed to the original command - shift 1 - - command "$original_cmd" "$@" + command "$@" fi } From b23ba9d9c400f7d0e1a2bb063b3ebc774b91c379 Mon Sep 17 00:00:00 2001 From: galargh Date: Fri, 2 Jan 2026 10:39:15 +0100 Subject: [PATCH 043/360] chore: update test parametrization --- .../safe-chain/src/config/configFile.spec.js | 34 +++++++----- .../safe-chain/src/config/settings.spec.js | 52 ++++++++++++------- 2 files changed, 54 insertions(+), 32 deletions(-) diff --git a/packages/safe-chain/src/config/configFile.spec.js b/packages/safe-chain/src/config/configFile.spec.js index 601b0d0..eff4048 100644 --- a/packages/safe-chain/src/config/configFile.spec.js +++ b/packages/safe-chain/src/config/configFile.spec.js @@ -232,12 +232,22 @@ describe("getMinimumPackageAgeHours", async () => { }); }); -for (const packageManager of ["npm", "pip"]) { - const fnName = `get${packageManager.charAt(0).toUpperCase()}${packageManager.slice(1)}CustomRegistries`; - - describe(fnName, async () => { - const fn = (await import("./configFile.js"))[fnName]; +const { getNpmCustomRegistries, getPipCustomRegistries } = await import( + "./configFile.js" +); +for (const { packageManager, getCustomRegistries } of [ + { + packageManager: "npm", + getCustomRegistries: getNpmCustomRegistries, + }, + { + packageManager: "pip", + getCustomRegistries: getPipCustomRegistries, + }, +]) +{ + describe(getCustomRegistries.name, async () => { afterEach(() => { configFileContent = undefined; }); @@ -245,7 +255,7 @@ for (const packageManager of ["npm", "pip"]) { it("should return empty array when config file doesn't exist", () => { configFileContent = undefined; - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, []); }); @@ -253,7 +263,7 @@ for (const packageManager of ["npm", "pip"]) { it(`should return empty array when ${packageManager} config is not set`, () => { configFileContent = JSON.stringify({ scanTimeout: 5000 }); - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, []); }); @@ -263,7 +273,7 @@ for (const packageManager of ["npm", "pip"]) { [packageManager]: { customRegistries: "not-an-array" }, }); - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, []); }); @@ -275,7 +285,7 @@ for (const packageManager of ["npm", "pip"]) { }, }); - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, [ `${packageManager}.company.com`, @@ -297,7 +307,7 @@ for (const packageManager of ["npm", "pip"]) { }, }); - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, [ `${packageManager}.company.com`, @@ -310,7 +320,7 @@ for (const packageManager of ["npm", "pip"]) { [packageManager]: { customRegistries: [] }, }); - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, []); }); @@ -318,7 +328,7 @@ for (const packageManager of ["npm", "pip"]) { it("should handle malformed JSON and return empty array", () => { configFileContent = "{ invalid json"; - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, []); }); diff --git a/packages/safe-chain/src/config/settings.spec.js b/packages/safe-chain/src/config/settings.spec.js index 778628b..db513f3 100644 --- a/packages/safe-chain/src/config/settings.spec.js +++ b/packages/safe-chain/src/config/settings.spec.js @@ -11,13 +11,25 @@ mock.module("fs", { }, }); -for (const packageManager of ["npm", "pip"]) { - const fnName = `get${packageManager.charAt(0).toUpperCase()}${packageManager.slice(1)}CustomRegistries`; - const envVarName = `SAFE_CHAIN_${packageManager.toUpperCase()}_CUSTOM_REGISTRIES`; +const { getNpmCustomRegistries, getPipCustomRegistries } = await import( + "./settings.js" +); - describe(fnName, async () => { +for (const { packageManager, getCustomRegistries, envVarName } of [ + { + packageManager: "npm", + getCustomRegistries: getNpmCustomRegistries, + envVarName: "SAFE_CHAIN_NPM_CUSTOM_REGISTRIES", + }, + { + packageManager: "pip", + getCustomRegistries: getPipCustomRegistries, + envVarName: "SAFE_CHAIN_PIP_CUSTOM_REGISTRIES", + }, +]) +{ + describe(getCustomRegistries.name, async () => { let originalEnv; - const fn = (await import("./settings.js"))[fnName]; beforeEach(() => { originalEnv = process.env[envVarName]; @@ -35,7 +47,7 @@ for (const packageManager of ["npm", "pip"]) { it("should return empty array when no registries configured", () => { configFileContent = undefined; - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, []); }); @@ -47,7 +59,7 @@ for (const packageManager of ["npm", "pip"]) { }, }); - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, [ `${packageManager}.company.com`, @@ -65,7 +77,7 @@ for (const packageManager of ["npm", "pip"]) { }, }); - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, [ `${packageManager}.company.com`, @@ -83,7 +95,7 @@ for (const packageManager of ["npm", "pip"]) { }, }); - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, [ `${packageManager}.company.com`, @@ -102,7 +114,7 @@ for (const packageManager of ["npm", "pip"]) { }, }); - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, [ `${packageManager}.company.com`, @@ -121,7 +133,7 @@ for (const packageManager of ["npm", "pip"]) { }, }); - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, [ `${packageManager}.company.com/custom/path`, @@ -135,7 +147,7 @@ for (const packageManager of ["npm", "pip"]) { "env1.registry.com,env2.registry.net"; configFileContent = undefined; - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, [ "env1.registry.com", @@ -149,7 +161,7 @@ for (const packageManager of ["npm", "pip"]) { " env1.registry.com , env2.registry.net "; configFileContent = undefined; - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, [ "env1.registry.com", @@ -166,7 +178,7 @@ for (const packageManager of ["npm", "pip"]) { }, }); - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, [ "env1.registry.com", @@ -184,7 +196,7 @@ for (const packageManager of ["npm", "pip"]) { }, }); - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, [ `${packageManager}.company.com`, @@ -199,7 +211,7 @@ for (const packageManager of ["npm", "pip"]) { "https://env1.registry.com,http://env2.registry.net"; configFileContent = undefined; - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, [ "env1.registry.com", @@ -213,7 +225,7 @@ for (const packageManager of ["npm", "pip"]) { "env1.registry.com,,env2.registry.net,"; configFileContent = undefined; - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, [ "env1.registry.com", @@ -226,7 +238,7 @@ for (const packageManager of ["npm", "pip"]) { process.env[envVarName] = "single.registry.com"; configFileContent = undefined; - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, ["single.registry.com"]); }); @@ -236,7 +248,7 @@ for (const packageManager of ["npm", "pip"]) { process.env[envVarName] = ""; configFileContent = undefined; - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, []); }); @@ -246,7 +258,7 @@ for (const packageManager of ["npm", "pip"]) { process.env[envVarName] = " , , "; configFileContent = undefined; - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, []); }); From a910851422fa9238f0ded30272963e257224fa13 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 5 Jan 2026 14:15:28 +0100 Subject: [PATCH 044/360] Build for linuxstatic and alpine --- .github/workflows/create-artifact.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/create-artifact.yml b/.github/workflows/create-artifact.yml index d7729fd..5168d6e 100644 --- a/.github/workflows/create-artifact.yml +++ b/.github/workflows/create-artifact.yml @@ -39,6 +39,26 @@ jobs: runner: ubuntu-24.04-arm target: node20-linux-arm64 extension: "" + - os: linux + arch: x64 + runner: ubuntu-latest + target: node20-linuxstatic-x64 + extension: "" + - os: linux + arch: arm64 + runner: ubuntu-24.04-arm + target: node20-linuxstatic-arm64 + extension: "" + - os: linux + arch: x64 + runner: ubuntu-latest + target: node20-alpine-x64 + extension: "" + - os: linux + arch: arm64 + runner: ubuntu-24.04-arm + target: node20-alpine-arm64 + extension: "" - os: win arch: x64 runner: windows-latest From 40b8638dddbda703ab3ebe76cb30ec4778a40f4f Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 5 Jan 2026 14:24:19 +0100 Subject: [PATCH 045/360] Fix artifact name --- .github/workflows/create-artifact.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/create-artifact.yml b/.github/workflows/create-artifact.yml index 5168d6e..d11447e 100644 --- a/.github/workflows/create-artifact.yml +++ b/.github/workflows/create-artifact.yml @@ -39,22 +39,22 @@ jobs: runner: ubuntu-24.04-arm target: node20-linux-arm64 extension: "" - - os: linux + - os: linuxstatic arch: x64 runner: ubuntu-latest target: node20-linuxstatic-x64 extension: "" - - os: linux + - os: linuxstatic arch: arm64 runner: ubuntu-24.04-arm target: node20-linuxstatic-arm64 extension: "" - - os: linux + - os: alpine arch: x64 runner: ubuntu-latest target: node20-alpine-x64 extension: "" - - os: linux + - os: alpine arch: arm64 runner: ubuntu-24.04-arm target: node20-alpine-arm64 From 35ca2233f82a257ffe931b37656d41c561508be7 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 5 Jan 2026 15:45:57 +0100 Subject: [PATCH 046/360] Use linuxstatic target for linux --- .github/workflows/create-artifact.yml | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/.github/workflows/create-artifact.yml b/.github/workflows/create-artifact.yml index d11447e..bba0d46 100644 --- a/.github/workflows/create-artifact.yml +++ b/.github/workflows/create-artifact.yml @@ -32,33 +32,13 @@ jobs: - os: linux arch: x64 runner: ubuntu-latest - target: node20-linux-x64 + target: node20-linuxstatic-x64 extension: "" - os: linux - arch: arm64 - runner: ubuntu-24.04-arm - target: node20-linux-arm64 - extension: "" - - os: linuxstatic - arch: x64 - runner: ubuntu-latest - target: node20-linuxstatic-x64 - extension: "" - - os: linuxstatic arch: arm64 runner: ubuntu-24.04-arm target: node20-linuxstatic-arm64 extension: "" - - os: alpine - arch: x64 - runner: ubuntu-latest - target: node20-alpine-x64 - extension: "" - - os: alpine - arch: arm64 - runner: ubuntu-24.04-arm - target: node20-alpine-arm64 - extension: "" - os: win arch: x64 runner: windows-latest From 52a096b7395c0caa05fa74b83d77abaa86f3718d Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 5 Jan 2026 15:47:31 +0100 Subject: [PATCH 047/360] Re-order steps --- .github/workflows/build-and-release.yml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 83c11d9..93cfb8d 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -55,17 +55,6 @@ jobs: - name: Run tests run: npm run test - - name: Copy documentation files to package - run: | - cp README.md packages/safe-chain/ - cp LICENSE packages/safe-chain/ - cp -r docs packages/safe-chain/ - - - name: Publish to npm - run: | - echo "Publishing version ${{ needs.set-version.outputs.version }} to NPM" - npm publish --workspace=packages/safe-chain --access public --provenance - - name: Download all binary artifacts uses: actions/download-artifact@v4 with: @@ -107,3 +96,14 @@ jobs: release-artifacts/install-safe-chain.ps1 \ release-artifacts/uninstall-safe-chain.sh \ release-artifacts/uninstall-safe-chain.ps1 + + - name: Copy documentation files to package + run: | + cp README.md packages/safe-chain/ + cp LICENSE packages/safe-chain/ + cp -r docs packages/safe-chain/ + + - name: Publish to npm + run: | + echo "Publishing version ${{ needs.set-version.outputs.version }} to NPM" + npm publish --workspace=packages/safe-chain --access public --provenance From d530b9a1de6d75286c82e83fa5c8501c78a210c8 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 6 Jan 2026 08:17:35 +0100 Subject: [PATCH 048/360] Run tests with 0.0.1-docker-linux-exec-beta --- .github/workflows/build-and-release.yml | 2 +- .github/workflows/create-artifact.yml | 4 ++-- .github/workflows/test-on-pr.yml | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 83c11d9..fe180b3 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -44,7 +44,7 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} - name: Setup safe-chain - run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci + run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/0.0.1-docker-linux-exec-beta/install-safe-chain.sh | sh -s -- --ci - name: Set the version in safe-chain package run: npm --no-git-tag-version version ${{ needs.set-version.outputs.version }} --workspace=packages/safe-chain diff --git a/.github/workflows/create-artifact.yml b/.github/workflows/create-artifact.yml index bba0d46..f5bc9f8 100644 --- a/.github/workflows/create-artifact.yml +++ b/.github/workflows/create-artifact.yml @@ -61,12 +61,12 @@ jobs: - name: Setup safe-chain (Mac/Linux) if: runner.os != 'Windows' - run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci + run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/0.0.1-docker-linux-exec-beta/install-safe-chain.sh | sh -s -- --ci - name: Setup safe-chain (Windows) if: runner.os == 'Windows' shell: pwsh - run: iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -ci" + run: iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/download/0.0.1-docker-linux-exec-beta/install-safe-chain.ps1' -UseBasicParsing) } -ci" - name: Install dependencies run: npm ci --ignore-scripts diff --git a/.github/workflows/test-on-pr.yml b/.github/workflows/test-on-pr.yml index 9e4a5ec..bff7e51 100644 --- a/.github/workflows/test-on-pr.yml +++ b/.github/workflows/test-on-pr.yml @@ -24,12 +24,12 @@ jobs: - name: Setup safe-chain (Mac/Linux) if: runner.os != 'Windows' - run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci + run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/0.0.1-docker-linux-exec-beta/install-safe-chain.sh | sh -s -- --ci - name: Setup safe-chain (Windows) if: runner.os == 'Windows' shell: pwsh - run: iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -ci" + run: iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/download/0.0.1-docker-linux-exec-beta/install-safe-chain.ps1' -UseBasicParsing) } -ci" - name: Install dependencies run: npm ci --ignore-scripts @@ -114,7 +114,7 @@ jobs: node-version: "lts/*" - name: Setup safe-chain - run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci + run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/0.0.1-docker-linux-exec-beta/install-safe-chain/install-safe-chain.sh | sh -s -- --ci - name: Install dependencies (root) run: npm ci From ff4618602a00ff3a28c534defa0cdbef3acdd9ae Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 6 Jan 2026 09:02:22 +0100 Subject: [PATCH 049/360] Add extra artifact for linuxstatic, change install script to use it. --- .github/workflows/create-artifact.yml | 12 +++++++++++- install-scripts/install-safe-chain.sh | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/create-artifact.yml b/.github/workflows/create-artifact.yml index f5bc9f8..b9a538e 100644 --- a/.github/workflows/create-artifact.yml +++ b/.github/workflows/create-artifact.yml @@ -32,9 +32,19 @@ jobs: - os: linux arch: x64 runner: ubuntu-latest - target: node20-linuxstatic-x64 + target: node20-linux-x64 extension: "" - os: linux + arch: arm64 + runner: ubuntu-24.04-arm + target: node20-linux-arm64 + extension: "" + - os: linuxstatic + arch: x64 + runner: ubuntu-latest + target: node20-linuxstatic-x64 + extension: "" + - os: linuxstatic arch: arm64 runner: ubuntu-24.04-arm target: node20-linuxstatic-arm64 diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 94a9b55..1de2d23 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -34,7 +34,7 @@ error() { # Detect OS detect_os() { case "$(uname -s)" in - Linux*) echo "linux" ;; + Linux*) echo "linuxstatic" ;; Darwin*) echo "macos" ;; *) error "Unsupported operating system: $(uname -s)" ;; esac From 50f20cc30dcef460fc17041043c51d7fe764d542 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 6 Jan 2026 08:17:35 +0100 Subject: [PATCH 050/360] Run tests with 0.0.1-docker-linux-exec-beta --- .github/workflows/build-and-release.yml | 2 +- .github/workflows/create-artifact.yml | 4 ++-- .github/workflows/test-on-pr.yml | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 93cfb8d..fcb010a 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -44,7 +44,7 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} - name: Setup safe-chain - run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci + run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/0.0.1-docker-linux-exec-beta/install-safe-chain.sh | sh -s -- --ci - name: Set the version in safe-chain package run: npm --no-git-tag-version version ${{ needs.set-version.outputs.version }} --workspace=packages/safe-chain diff --git a/.github/workflows/create-artifact.yml b/.github/workflows/create-artifact.yml index bba0d46..f5bc9f8 100644 --- a/.github/workflows/create-artifact.yml +++ b/.github/workflows/create-artifact.yml @@ -61,12 +61,12 @@ jobs: - name: Setup safe-chain (Mac/Linux) if: runner.os != 'Windows' - run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci + run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/0.0.1-docker-linux-exec-beta/install-safe-chain.sh | sh -s -- --ci - name: Setup safe-chain (Windows) if: runner.os == 'Windows' shell: pwsh - run: iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -ci" + run: iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/download/0.0.1-docker-linux-exec-beta/install-safe-chain.ps1' -UseBasicParsing) } -ci" - name: Install dependencies run: npm ci --ignore-scripts diff --git a/.github/workflows/test-on-pr.yml b/.github/workflows/test-on-pr.yml index 9e4a5ec..bff7e51 100644 --- a/.github/workflows/test-on-pr.yml +++ b/.github/workflows/test-on-pr.yml @@ -24,12 +24,12 @@ jobs: - name: Setup safe-chain (Mac/Linux) if: runner.os != 'Windows' - run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci + run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/0.0.1-docker-linux-exec-beta/install-safe-chain.sh | sh -s -- --ci - name: Setup safe-chain (Windows) if: runner.os == 'Windows' shell: pwsh - run: iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -ci" + run: iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/download/0.0.1-docker-linux-exec-beta/install-safe-chain.ps1' -UseBasicParsing) } -ci" - name: Install dependencies run: npm ci --ignore-scripts @@ -114,7 +114,7 @@ jobs: node-version: "lts/*" - name: Setup safe-chain - run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci + run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/0.0.1-docker-linux-exec-beta/install-safe-chain/install-safe-chain.sh | sh -s -- --ci - name: Install dependencies (root) run: npm ci From eb32da49aad4632f2f172392327807714ee322e9 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 6 Jan 2026 09:02:22 +0100 Subject: [PATCH 051/360] Add extra artifact for linuxstatic, change install script to use it. --- .github/workflows/create-artifact.yml | 12 +++++++++++- install-scripts/install-safe-chain.sh | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/create-artifact.yml b/.github/workflows/create-artifact.yml index f5bc9f8..b9a538e 100644 --- a/.github/workflows/create-artifact.yml +++ b/.github/workflows/create-artifact.yml @@ -32,9 +32,19 @@ jobs: - os: linux arch: x64 runner: ubuntu-latest - target: node20-linuxstatic-x64 + target: node20-linux-x64 extension: "" - os: linux + arch: arm64 + runner: ubuntu-24.04-arm + target: node20-linux-arm64 + extension: "" + - os: linuxstatic + arch: x64 + runner: ubuntu-latest + target: node20-linuxstatic-x64 + extension: "" + - os: linuxstatic arch: arm64 runner: ubuntu-24.04-arm target: node20-linuxstatic-arm64 diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 94a9b55..1de2d23 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -34,7 +34,7 @@ error() { # Detect OS detect_os() { case "$(uname -s)" in - Linux*) echo "linux" ;; + Linux*) echo "linuxstatic" ;; Darwin*) echo "macos" ;; *) error "Unsupported operating system: $(uname -s)" ;; esac From 24230da4a7a9e706a3b625b4baf8132689524b59 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 6 Jan 2026 10:05:52 +0100 Subject: [PATCH 052/360] Add nvm safe-chain uninstallation in install script --- install-scripts/install-safe-chain.sh | 57 ++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 94a9b55..6f0dd26 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -159,6 +159,57 @@ remove_volta_installation() { fi } +# Check and uninstall nvm-managed package if present across all Node versions +remove_nvm_installation() { + # Check if nvm is available as a command + if ! command_exists nvm; then + return + fi + + # Get list of installed Node versions + nvm_versions=$(nvm list 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' || echo "") + + if [ -z "$nvm_versions" ]; then + return + fi + + # Track if we found any installations + found_installation=false + uninstall_failed=false + current_version=$(nvm current 2>/dev/null || echo "") + + # Check each version for safe-chain installation + for version in $nvm_versions; do + # Check if this version has safe-chain installed + # Use nvm exec to run npm list in the context of that Node version + if nvm exec "$version" npm list -g @aikidosec/safe-chain >/dev/null 2>&1; then + if [ "$found_installation" = false ]; then + info "Detected nvm installation(s) of @aikidosec/safe-chain" + info "Uninstalling from all Node versions..." + found_installation=true + fi + + info " Removing from Node $version..." + if nvm exec "$version" npm uninstall -g @aikidosec/safe-chain >/dev/null 2>&1; then + info " Successfully uninstalled from Node $version" + else + warn " Failed to uninstall from Node $version" + uninstall_failed=true + fi + fi + done + + # Restore original Node version if it was set + if [ -n "$current_version" ] && [ "$current_version" != "none" ] && [ "$current_version" != "system" ]; then + nvm use "$current_version" >/dev/null 2>&1 || true + fi + + # If any uninstall failed, error out instead of continuing + if [ "$uninstall_failed" = true ]; then + error "Failed to uninstall @aikidosec/safe-chain from all nvm Node versions. Please uninstall manually and try again." + fi +} + # Parse command-line arguments parse_arguments() { for arg in "$@"; do @@ -204,9 +255,11 @@ main() { info "$INSTALL_MSG" - # Check for existing safe-chain installation through npm or volta - remove_npm_installation + # Check for existing safe-chain installation through nvm, volta, or npm + # nvm must be checked first as it manages multiple Node versions + remove_nvm_installation remove_volta_installation + remove_npm_installation # Detect platform OS=$(detect_os) From efe3b24ab9906482fb36982ef7cdb1e1745ac8ff Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 6 Jan 2026 10:07:40 +0100 Subject: [PATCH 053/360] Comment npm publish step --- .github/workflows/build-and-release.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 83c11d9..1c05824 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -61,10 +61,10 @@ jobs: cp LICENSE packages/safe-chain/ cp -r docs packages/safe-chain/ - - name: Publish to npm - run: | - echo "Publishing version ${{ needs.set-version.outputs.version }} to NPM" - npm publish --workspace=packages/safe-chain --access public --provenance + # - name: Publish to npm + # run: | + # echo "Publishing version ${{ needs.set-version.outputs.version }} to NPM" + # npm publish --workspace=packages/safe-chain --access public --provenance - name: Download all binary artifacts uses: actions/download-artifact@v4 From 6bbd3f59558b1ccfddb10854a75161c969d6cd9f Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 6 Jan 2026 10:35:10 +0100 Subject: [PATCH 054/360] Add nvm detection to uninstall script --- install-scripts/uninstall-safe-chain.sh | 56 ++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/install-scripts/uninstall-safe-chain.sh b/install-scripts/uninstall-safe-chain.sh index 4b2d7ec..8d1fbdf 100755 --- a/install-scripts/uninstall-safe-chain.sh +++ b/install-scripts/uninstall-safe-chain.sh @@ -75,6 +75,58 @@ remove_volta_installation() { fi } +# Check and uninstall nvm-managed package if present across all Node versions +remove_nvm_installation() { + # Check if nvm is available as a command + if ! command_exists nvm; then + return + fi + + # Get list of installed Node versions + nvm_versions=$(nvm list 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' || echo "") + + if [ -z "$nvm_versions" ]; then + return + fi + + # Track if we found any installations + found_installation=false + uninstall_failed=false + current_version=$(nvm current 2>/dev/null || echo "") + + # Check each version for safe-chain installation + for version in $nvm_versions; do + # Check if this version has safe-chain installed + # Use nvm exec to run npm list in the context of that Node version + if nvm exec "$version" npm list -g @aikidosec/safe-chain >/dev/null 2>&1; then + if [ "$found_installation" = false ]; then + info "Detected nvm installation(s) of @aikidosec/safe-chain" + info "Uninstalling from all Node versions..." + found_installation=true + fi + + info " Removing from Node $version..." + if nvm exec "$version" npm uninstall -g @aikidosec/safe-chain >/dev/null 2>&1; then + info " Successfully uninstalled from Node $version" + else + warn " Failed to uninstall from Node $version" + uninstall_failed=true + fi + fi + done + + # Restore original Node version if it was set + if [ -n "$current_version" ] && [ "$current_version" != "none" ] && [ "$current_version" != "system" ]; then + nvm use "$current_version" >/dev/null 2>&1 || true + fi + + # Show warning if any uninstall failed (but don't error out during uninstall) + if [ "$uninstall_failed" = true ]; then + warn "Failed to uninstall @aikidosec/safe-chain from some nvm Node versions" + warn "You may need to manually run: nvm exec npm uninstall -g @aikidosec/safe-chain" + fi +} + # Main uninstallation main() { SAFE_CHAIN_LOCATION="$INSTALL_DIR/safe-chain" @@ -89,8 +141,10 @@ main() { warn "safe-chain command not found. Proceeding with uninstallation." fi - remove_npm_installation + # Remove npm-based installations (nvm must be checked first) + remove_nvm_installation remove_volta_installation + remove_npm_installation # Remove install dir recursively if it exists if [ -d "$INSTALL_DIR" ]; then From 10a2407b3227a67c9cd9ec36e85037a154b9fad4 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 6 Jan 2026 10:43:15 +0100 Subject: [PATCH 055/360] Source nvm in script --- install-scripts/install-safe-chain.sh | 10 +++++++++- install-scripts/uninstall-safe-chain.sh | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 6f0dd26..63e622e 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -161,7 +161,15 @@ remove_volta_installation() { # Check and uninstall nvm-managed package if present across all Node versions remove_nvm_installation() { - # Check if nvm is available as a command + # nvm is a shell function, not a binary, so we need to source it first + if [ -s "$HOME/.nvm/nvm.sh" ]; then + # Source nvm to make it available in this script + . "$HOME/.nvm/nvm.sh" >/dev/null 2>&1 + elif [ -s "$NVM_DIR/nvm.sh" ]; then + . "$NVM_DIR/nvm.sh" >/dev/null 2>&1 + fi + + # Check if nvm is now available if ! command_exists nvm; then return fi diff --git a/install-scripts/uninstall-safe-chain.sh b/install-scripts/uninstall-safe-chain.sh index 8d1fbdf..15c4f96 100755 --- a/install-scripts/uninstall-safe-chain.sh +++ b/install-scripts/uninstall-safe-chain.sh @@ -77,7 +77,15 @@ remove_volta_installation() { # Check and uninstall nvm-managed package if present across all Node versions remove_nvm_installation() { - # Check if nvm is available as a command + # nvm is a shell function, not a binary, so we need to source it first + if [ -s "$HOME/.nvm/nvm.sh" ]; then + # Source nvm to make it available in this script + . "$HOME/.nvm/nvm.sh" >/dev/null 2>&1 + elif [ -s "$NVM_DIR/nvm.sh" ]; then + . "$NVM_DIR/nvm.sh" >/dev/null 2>&1 + fi + + # Check if nvm is now available if ! command_exists nvm; then return fi From 5a28d6646f28394eb1018d345b4c158f41cb639f Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 6 Jan 2026 10:53:24 +0100 Subject: [PATCH 056/360] Update comments --- install-scripts/install-safe-chain.sh | 5 +++-- install-scripts/uninstall-safe-chain.sh | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 63e622e..8e184da 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -161,7 +161,9 @@ remove_volta_installation() { # Check and uninstall nvm-managed package if present across all Node versions remove_nvm_installation() { - # nvm is a shell function, not a binary, so we need to source it first + # This script is run in sh shell for greatest compatibility. + # Because nvm is usually setup in bash/zsh/fish startup scripts, we need to source it. + # Otherwise it won't be available in sh. if [ -s "$HOME/.nvm/nvm.sh" ]; then # Source nvm to make it available in this script . "$HOME/.nvm/nvm.sh" >/dev/null 2>&1 @@ -174,7 +176,6 @@ remove_nvm_installation() { return fi - # Get list of installed Node versions nvm_versions=$(nvm list 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' || echo "") if [ -z "$nvm_versions" ]; then diff --git a/install-scripts/uninstall-safe-chain.sh b/install-scripts/uninstall-safe-chain.sh index 15c4f96..7b226a5 100755 --- a/install-scripts/uninstall-safe-chain.sh +++ b/install-scripts/uninstall-safe-chain.sh @@ -77,7 +77,9 @@ remove_volta_installation() { # Check and uninstall nvm-managed package if present across all Node versions remove_nvm_installation() { - # nvm is a shell function, not a binary, so we need to source it first + # This script is run in sh shell for greatest compatibility. + # Because nvm is usually setup in bash/zsh/fish startup scripts, we need to source it. + # Otherwise it won't be available in sh. if [ -s "$HOME/.nvm/nvm.sh" ]; then # Source nvm to make it available in this script . "$HOME/.nvm/nvm.sh" >/dev/null 2>&1 From d7d5bacd2158ffed87171519148d7cb54915419e Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 6 Jan 2026 10:53:32 +0100 Subject: [PATCH 057/360] Remove warning from readme --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index a13395c..f08daad 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,6 @@ Aikido Safe Chain supports the following package managers: Installing the Aikido Safe Chain is easy with our one-line installer. -> ⚠️ **Already installed via npm?** See the [migration guide](https://github.com/AikidoSec/safe-chain/blob/main/docs/npm-to-binary-migration.md) to switch to the binary version. - ### Unix/Linux/macOS ```shell @@ -206,6 +204,7 @@ You can set the minimum package age through multiple sources (in order of priori Configure Safe Chain to scan packages from custom or private registries. Supported ecosystems: + - Node.js - Python @@ -348,5 +347,4 @@ pipeline { } ``` - After setup, all subsequent package manager commands in your CI pipeline will automatically be protected by Aikido Safe Chain's malware detection. From 4aca6ef86a9f564c7bf0e18b44079e0cac4f9180 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 6 Jan 2026 10:54:34 +0100 Subject: [PATCH 058/360] Restore publish script --- .github/workflows/build-and-release.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 1c05824..83c11d9 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -61,10 +61,10 @@ jobs: cp LICENSE packages/safe-chain/ cp -r docs packages/safe-chain/ - # - name: Publish to npm - # run: | - # echo "Publishing version ${{ needs.set-version.outputs.version }} to NPM" - # npm publish --workspace=packages/safe-chain --access public --provenance + - name: Publish to npm + run: | + echo "Publishing version ${{ needs.set-version.outputs.version }} to NPM" + npm publish --workspace=packages/safe-chain --access public --provenance - name: Download all binary artifacts uses: actions/download-artifact@v4 From 4e098bcff746f3ed0c0904e357be5671dd88ea16 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 6 Jan 2026 11:23:47 +0100 Subject: [PATCH 059/360] Change order of removal for npm-based installations --- install-scripts/install-safe-chain.sh | 5 ++--- install-scripts/uninstall-safe-chain.sh | 6 +++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 8e184da..80e4493 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -265,10 +265,9 @@ main() { info "$INSTALL_MSG" # Check for existing safe-chain installation through nvm, volta, or npm - # nvm must be checked first as it manages multiple Node versions - remove_nvm_installation - remove_volta_installation remove_npm_installation + remove_volta_installation + remove_nvm_installation # Detect platform OS=$(detect_os) diff --git a/install-scripts/uninstall-safe-chain.sh b/install-scripts/uninstall-safe-chain.sh index 7b226a5..e208319 100755 --- a/install-scripts/uninstall-safe-chain.sh +++ b/install-scripts/uninstall-safe-chain.sh @@ -151,10 +151,10 @@ main() { warn "safe-chain command not found. Proceeding with uninstallation." fi - # Remove npm-based installations (nvm must be checked first) - remove_nvm_installation - remove_volta_installation + # Check for existing safe-chain installation through nvm, volta, or npm remove_npm_installation + remove_volta_installation + remove_nvm_installation # Remove install dir recursively if it exists if [ -d "$INSTALL_DIR" ]; then From 66c1da0f1e36ebe1845db9ca7e54816f5c788092 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 6 Jan 2026 11:48:06 +0100 Subject: [PATCH 060/360] Rework release workflow (split npm and github release), and skip npm publish for prereleases --- .github/workflows/build-and-release.yml | 86 ++++++++++++++++--------- 1 file changed, 54 insertions(+), 32 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 83c11d9..c0256a9 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -11,9 +11,11 @@ permissions: jobs: set-version: + name: Set version number runs-on: ubuntu-latest outputs: version: ${{ steps.get_version.outputs.tag }} + is_prerelease: ${{ steps.check_prerelease.outputs.is_prerelease }} steps: - name: Set version number id: get_version @@ -21,13 +23,23 @@ jobs: version="${{ github.ref_name }}" echo "tag=$version" >> $GITHUB_OUTPUT + - name: Check if pre-release + id: check_prerelease + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + IS_PRERELEASE=$(gh release view ${{ steps.get_version.outputs.tag }} --json isPrerelease --jq '.isPrerelease') + echo "is_prerelease=$IS_PRERELEASE" >> $GITHUB_OUTPUT + echo "Release ${{ steps.get_version.outputs.tag }} is pre-release: $IS_PRERELEASE" + create-binaries: needs: set-version uses: ./.github/workflows/create-artifact.yml with: version: ${{ needs.set-version.outputs.version }} - build: + publish-binaries: + name: Publish to GitHub release needs: [set-version, create-binaries] runs-on: ubuntu-latest @@ -35,37 +47,6 @@ jobs: - name: Checkout code uses: actions/checkout@v3 - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - node-version: "lts/*" - registry-url: "https://registry.npmjs.org/" - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} - - - name: Setup safe-chain - run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci - - - name: Set the version in safe-chain package - run: npm --no-git-tag-version version ${{ needs.set-version.outputs.version }} --workspace=packages/safe-chain - - - name: Install dependencies - run: npm ci - - - name: Run tests - run: npm run test - - - name: Copy documentation files to package - run: | - cp README.md packages/safe-chain/ - cp LICENSE packages/safe-chain/ - cp -r docs packages/safe-chain/ - - - name: Publish to npm - run: | - echo "Publishing version ${{ needs.set-version.outputs.version }} to NPM" - npm publish --workspace=packages/safe-chain --access public --provenance - - name: Download all binary artifacts uses: actions/download-artifact@v4 with: @@ -107,3 +88,44 @@ jobs: release-artifacts/install-safe-chain.ps1 \ release-artifacts/uninstall-safe-chain.sh \ release-artifacts/uninstall-safe-chain.ps1 + + publish-npm: + name: Publish to npm + needs: [set-version, create-binaries] + if: needs.set-version.outputs.is_prerelease != 'true' + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: "lts/*" + registry-url: "https://registry.npmjs.org/" + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} + + - name: Setup safe-chain + run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci + + - name: Set the version in safe-chain package + run: npm --no-git-tag-version version ${{ needs.set-version.outputs.version }} --workspace=packages/safe-chain + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm run test + + - name: Copy documentation files to package + run: | + cp README.md packages/safe-chain/ + cp LICENSE packages/safe-chain/ + cp -r docs packages/safe-chain/ + + - name: Publish to npm + run: | + echo "Publishing version ${{ needs.set-version.outputs.version }} to NPM" + npm publish --workspace=packages/safe-chain --access public --provenance From 1f4e50df9db9dbf63aa5f9182b10a99a6f01d8e9 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 6 Jan 2026 11:51:01 +0100 Subject: [PATCH 061/360] Checkout code in set version --- .github/workflows/build-and-release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index c0256a9..a372e1e 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -17,6 +17,9 @@ jobs: version: ${{ steps.get_version.outputs.tag }} is_prerelease: ${{ steps.check_prerelease.outputs.is_prerelease }} steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Set version number id: get_version run: | From e8f993623bceeb11032015cca37be03db6fcb6d6 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 6 Jan 2026 15:48:15 +0100 Subject: [PATCH 062/360] Add troubleshooting docs --- README.md | 4 + docs/npm-to-binary-migration.md | 89 ------------ docs/troubleshooting.md | 248 ++++++++++++++++++++++++++++++++ 3 files changed, 252 insertions(+), 89 deletions(-) delete mode 100644 docs/npm-to-binary-migration.md create mode 100644 docs/troubleshooting.md diff --git a/README.md b/README.md index f08daad..14dc26c 100644 --- a/README.md +++ b/README.md @@ -348,3 +348,7 @@ pipeline { ``` After setup, all subsequent package manager commands in your CI pipeline will automatically be protected by Aikido Safe Chain's malware detection. + +# Troubleshooting + +Having issues? See the [Troubleshooting Guide](https://github.com/AikidoSec/safe-chain/blob/main/docs/troubleshooting.md) for help with common problems. diff --git a/docs/npm-to-binary-migration.md b/docs/npm-to-binary-migration.md deleted file mode 100644 index c29a044..0000000 --- a/docs/npm-to-binary-migration.md +++ /dev/null @@ -1,89 +0,0 @@ -# Migrating from npm global tool to binary installation - -If you previously installed safe-chain as an npm global package, you need to migrate to the binary installation. - -Depending on the version manager you're using, the uninstall process differs: - -### Standard npm (no version manager) - -1. **Clean up shell aliases:** - - ```bash - safe-chain teardown - ``` - -2. **Restart your terminal** - -3. **Uninstall the npm package:** - - ```bash - npm uninstall -g @aikidosec/safe-chain - ``` - -4. **Install the binary version** (see [Installation](https://github.com/AikidoSec/safe-chain/blob/main/README.md#installation)) - -### nvm (Node Version Manager) - -**Important:** nvm installs global packages separately for each Node version, so safe-chain must be uninstalled from each version where it was installed. - -1. **Clean up shell aliases:** - - ```bash - safe-chain teardown - ``` - -2. **Restart your terminal** - -3. **Uninstall from all Node versions:** - - **Option A** - Automated script (recommended): - - ```bash - for version in $(nvm list | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+'); do nvm use $version && npm uninstall -g @aikidosec/safe-chain; done - ``` - - **Option B** - Manual per version: - - ```bash - nvm use - npm uninstall -g @aikidosec/safe-chain - ``` - - Repeat for each Node version where safe-chain was installed. - -4. **Install the binary version** (see [Installation](https://github.com/AikidoSec/safe-chain/blob/main/README.md#installation)) - -### Volta - -1. **Clean up shell aliases:** - - ```bash - safe-chain teardown - ``` - -2. **Restart your terminal** - -3. **Uninstall the Volta package:** - - ```bash - volta uninstall @aikidosec/safe-chain - ``` - -4. **Install the binary version** (see [Installation](https://github.com/AikidoSec/safe-chain/blob/main/README.md#installation)) - -## Troubleshooting - -### Shell aliases still present after migration - -1. Run `safe-chain teardown` (if the binary is installed) -2. Manually remove any safe-chain entries from your shell config files: - - Bash: `~/.bashrc` - - Zsh: `~/.zshrc` - - Fish: `~/.config/fish/config.fish` - - PowerShell: `$PROFILE` -3. Restart your terminal -4. Re-run the install script - -### "command not found: safe-chain" after migration - -The binary installation directory (`~/.safe-chain/bin`) may not be in your PATH. Restart your terminal. If the problem persists: re-run the installation of safe-chain. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..0e95f56 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,248 @@ +# Troubleshooting + +This guide helps you diagnose and resolve common issues with Aikido Safe Chain. + +## Verification & Diagnostics + +### Check Installation + +```bash +# Check version +safe-chain --version +``` + +### Verify Shell Integration + +Run the verification command for your package manager: + +```bash +npm safe-chain-verify +pnpm safe-chain-verify +pip safe-chain-verify +uv safe-chain-verify + +# Any other supported package manager: {packagemanager} safe-chain-verify +``` + +Expected output: `OK: Safe-chain works!` + +### Test Malware Blocking + +Verify that malware detection is working: + +**For JavaScript/Node.js:** + +```bash +npm install safe-chain-test +``` + +**For Python:** + +```bash +pip3 install safe-chain-pi-test +``` + +These test packages are flagged as malware and should be blocked by Safe Chain. + +### Logging Options + +Use logging flags to get more information: + +```bash +# Verbose mode - detailed diagnostic output for troubleshooting +npm install express --safe-chain-logging=verbose + +# Silent mode - suppress all output except malware blocking +npm install express --safe-chain-logging=silent +``` + +## Common Issues + +### Shell Aliases Not Working After Installation + +**Symptom:** Running `npm` shows regular npm instead of safe-chain wrapped version + +**First step:** Restart your terminal (most common fix) + +**Verify it's working:** + +```bash +type npm +``` + +Should show: `npm is a function` + +**If still not working:** + +Check that your startup file sources safe-chain scripts from `~/.safe-chain/scripts/`: + +- Bash: `~/.bashrc` +- Zsh: `~/.zshrc` +- Fish: `~/.config/fish/config.fish` +- PowerShell: `$PROFILE` + +### "Command Not Found: safe-chain" + +**Symptom:** Binary not found in PATH + +**First step:** Restart your terminal + +**Check PATH:** + +```bash +echo $PATH +``` + +Should include `~/.safe-chain/bin` + +**If persists:** Re-run the installation script + +### Shell Aliases Persist After Uninstallation + +**Symptom:** safe-chain commands still active after running uninstall script + +**Steps:** + +1. Run `safe-chain teardown` (if binary still exists) +2. Restart your terminal +3. If still present, manually edit shell config files: + - Bash: `~/.bashrc` + - Zsh: `~/.zshrc` + - Fish: `~/.config/fish/config.fish` + - PowerShell: `$PROFILE` +4. Remove lines that source scripts from `~/.safe-chain/scripts/` +5. Restart terminal again + +## Manual Verification Steps + +### Check Installation Status + +```bash +# Check installation location (helps identify if installed via npm or as standalone binary) +which safe-chain + +# Verify binary exists +ls ~/.safe-chain/bin/safe-chain + +# Check version +safe-chain --version + +# Test shell integration +type npm +type pip +``` + +**Expected `which` output:** +- Standalone binary (correct): `~/.safe-chain/bin/safe-chain` or `/Users//.safe-chain/bin/safe-chain` +- npm global (outdated): path containing `node_modules` or nvm version paths + +If `which` shows an npm installation, see [Check for Conflicting Installations](#check-for-conflicting-installations). + +### Check Shell Integration + +```bash +# Which shell you're using +echo $SHELL + +# Check if startup file sources safe-chain +# For Bash: +grep safe-chain ~/.bashrc + +# For Zsh: +grep safe-chain ~/.zshrc + +# For Fish: +grep safe-chain ~/.config/fish/config.fish + +# Verify scripts exist +ls ~/.safe-chain/scripts/ +``` + +### Check for Conflicting Installations + +The install/uninstall scripts automatically detect and remove conflicting installations, but you can manually check: + +```bash +# Check npm global +npm list -g @aikidosec/safe-chain + +# Check Volta +volta list safe-chain + +# Check nvm (all versions) +for version in $(nvm list | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+'); do + nvm exec "$version" npm list -g @aikidosec/safe-chain 2>/dev/null && echo "Found in $version" +done +``` + +## Manual Cleanup + +> **Note:** The install and uninstall scripts automatically handle these cleanup steps. Use these manual commands only if automatic cleanup fails. + +### Remove npm Global Installation + +```bash +npm uninstall -g @aikidosec/safe-chain +``` + +### Remove Volta Installation + +```bash +volta uninstall @aikidosec/safe-chain +``` + +### Remove nvm Installations (All Versions) + +```bash +# Automated approach +for version in $(nvm list | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+'); do + nvm exec "$version" npm uninstall -g @aikidosec/safe-chain +done + +# Or manual per version +nvm use +npm uninstall -g @aikidosec/safe-chain +``` + +### Clean Shell Configuration Files + +Manually remove safe-chain entries from: + +- Bash: `~/.bashrc` +- Zsh: `~/.zshrc` +- Fish: `~/.config/fish/config.fish` +- PowerShell: `$PROFILE` + +Look for and remove: + +- Lines sourcing from `~/.safe-chain/scripts/` +- Any safe-chain related function definitions + +### Remove Installation Directory + +```bash +rm -rf ~/.safe-chain +``` + +## Getting More Information + +### Enable Verbose Logging + +Get detailed diagnostic output: + +```bash +npm install express --safe-chain-logging=verbose +pip install requests --safe-chain-logging=verbose +``` + +### Report Issues + +If you encounter problems: + +1. Visit [GitHub Issues](https://github.com/AikidoSec/safe-chain/issues) +2. Include: + - Operating system and version + - Shell type and version + - `safe-chain --version` output + - Output from verification commands + - Verbose logs of the failing command From 504b3ca596ae50f747088e0bab524c7824ce1169 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 6 Jan 2026 16:04:15 +0100 Subject: [PATCH 063/360] Update Conflicting Installations note --- docs/troubleshooting.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 0e95f56..398ef4a 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -133,6 +133,7 @@ type pip ``` **Expected `which` output:** + - Standalone binary (correct): `~/.safe-chain/bin/safe-chain` or `/Users//.safe-chain/bin/safe-chain` - npm global (outdated): path containing `node_modules` or nvm version paths @@ -160,7 +161,7 @@ ls ~/.safe-chain/scripts/ ### Check for Conflicting Installations -The install/uninstall scripts automatically detect and remove conflicting installations, but you can manually check: +> **Note:** The install/uninstall scripts automatically detect and remove conflicting installations, but you can manually check: ```bash # Check npm global From b19d67f8539b33b0a5f6623e4a1136fea740abcf Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 7 Jan 2026 08:55:20 +0100 Subject: [PATCH 064/360] Add linuxstatic artifact to release --- .github/workflows/build-and-release.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index a372e1e..a752eb8 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -64,6 +64,8 @@ jobs: mv binaries/safe-chain-macos-arm64/safe-chain release-artifacts/safe-chain-macos-arm64 mv binaries/safe-chain-linux-x64/safe-chain release-artifacts/safe-chain-linux-x64 mv binaries/safe-chain-linux-arm64/safe-chain release-artifacts/safe-chain-linux-arm64 + mv binaries/safe-chain-linuxstatic-x64/safe-chain release-artifacts/safe-chain-linuxstatic-x64 + mv binaries/safe-chain-linuxstatic-arm64/safe-chain release-artifacts/safe-chain-linuxstatic-arm64 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 @@ -85,6 +87,8 @@ jobs: release-artifacts/safe-chain-macos-arm64 \ release-artifacts/safe-chain-linux-x64 \ release-artifacts/safe-chain-linux-arm64 \ + release-artifacts/safe-chain-linuxstatic-x64 \ + release-artifacts/safe-chain-linuxstatic-arm64 \ release-artifacts/safe-chain-win-x64.exe \ release-artifacts/safe-chain-win-arm64.exe \ release-artifacts/install-safe-chain.sh \ From 7a4b7057bc5b015463464ee6cc4fc098be274c8a Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 7 Jan 2026 09:40:40 +0100 Subject: [PATCH 065/360] Test on gh actions --- .github/workflows/build-and-release.yml | 2 +- .github/workflows/create-artifact.yml | 4 ++-- .github/workflows/test-on-pr.yml | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index a752eb8..e64bc4a 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -115,7 +115,7 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} - name: Setup safe-chain - run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci + run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/0.0.4-docker-linux-exec-beta/install-safe-chain.sh | sh -s -- --ci - name: Set the version in safe-chain package run: npm --no-git-tag-version version ${{ needs.set-version.outputs.version }} --workspace=packages/safe-chain diff --git a/.github/workflows/create-artifact.yml b/.github/workflows/create-artifact.yml index b9a538e..5486401 100644 --- a/.github/workflows/create-artifact.yml +++ b/.github/workflows/create-artifact.yml @@ -71,12 +71,12 @@ jobs: - name: Setup safe-chain (Mac/Linux) if: runner.os != 'Windows' - run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/0.0.1-docker-linux-exec-beta/install-safe-chain.sh | sh -s -- --ci + run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/0.0.4-docker-linux-exec-beta/install-safe-chain.sh | sh -s -- --ci - name: Setup safe-chain (Windows) if: runner.os == 'Windows' shell: pwsh - run: iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/download/0.0.1-docker-linux-exec-beta/install-safe-chain.ps1' -UseBasicParsing) } -ci" + run: iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/download/0.0.4-docker-linux-exec-beta/install-safe-chain.ps1' -UseBasicParsing) } -ci" - name: Install dependencies run: npm ci --ignore-scripts diff --git a/.github/workflows/test-on-pr.yml b/.github/workflows/test-on-pr.yml index bff7e51..2b37deb 100644 --- a/.github/workflows/test-on-pr.yml +++ b/.github/workflows/test-on-pr.yml @@ -24,12 +24,12 @@ jobs: - name: Setup safe-chain (Mac/Linux) if: runner.os != 'Windows' - run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/0.0.1-docker-linux-exec-beta/install-safe-chain.sh | sh -s -- --ci + run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/0.0.4-docker-linux-exec-beta/install-safe-chain.sh | sh -s -- --ci - name: Setup safe-chain (Windows) if: runner.os == 'Windows' shell: pwsh - run: iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/download/0.0.1-docker-linux-exec-beta/install-safe-chain.ps1' -UseBasicParsing) } -ci" + run: iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/download/0.0.4-docker-linux-exec-beta/install-safe-chain.ps1' -UseBasicParsing) } -ci" - name: Install dependencies run: npm ci --ignore-scripts @@ -114,7 +114,7 @@ jobs: node-version: "lts/*" - name: Setup safe-chain - run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/0.0.1-docker-linux-exec-beta/install-safe-chain/install-safe-chain.sh | sh -s -- --ci + run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/0.0.4-docker-linux-exec-beta/install-safe-chain/install-safe-chain.sh | sh -s -- --ci - name: Install dependencies (root) run: npm ci From b2a5336556d2ff08ba595199a8b01ae271af36a7 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 7 Jan 2026 11:39:22 +0100 Subject: [PATCH 066/360] Use latest build of safe-chain in CI again --- .github/workflows/build-and-release.yml | 2 +- .github/workflows/create-artifact.yml | 4 ++-- .github/workflows/test-on-pr.yml | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index e64bc4a..a752eb8 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -115,7 +115,7 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} - name: Setup safe-chain - run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/0.0.4-docker-linux-exec-beta/install-safe-chain.sh | sh -s -- --ci + run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci - name: Set the version in safe-chain package run: npm --no-git-tag-version version ${{ needs.set-version.outputs.version }} --workspace=packages/safe-chain diff --git a/.github/workflows/create-artifact.yml b/.github/workflows/create-artifact.yml index 5486401..00fc58a 100644 --- a/.github/workflows/create-artifact.yml +++ b/.github/workflows/create-artifact.yml @@ -71,12 +71,12 @@ jobs: - name: Setup safe-chain (Mac/Linux) if: runner.os != 'Windows' - run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/0.0.4-docker-linux-exec-beta/install-safe-chain.sh | sh -s -- --ci + run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci - name: Setup safe-chain (Windows) if: runner.os == 'Windows' shell: pwsh - run: iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/download/0.0.4-docker-linux-exec-beta/install-safe-chain.ps1' -UseBasicParsing) } -ci" + run: iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -ci" - name: Install dependencies run: npm ci --ignore-scripts diff --git a/.github/workflows/test-on-pr.yml b/.github/workflows/test-on-pr.yml index 2b37deb..9e4a5ec 100644 --- a/.github/workflows/test-on-pr.yml +++ b/.github/workflows/test-on-pr.yml @@ -24,12 +24,12 @@ jobs: - name: Setup safe-chain (Mac/Linux) if: runner.os != 'Windows' - run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/0.0.4-docker-linux-exec-beta/install-safe-chain.sh | sh -s -- --ci + run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci - name: Setup safe-chain (Windows) if: runner.os == 'Windows' shell: pwsh - run: iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/download/0.0.4-docker-linux-exec-beta/install-safe-chain.ps1' -UseBasicParsing) } -ci" + run: iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -ci" - name: Install dependencies run: npm ci --ignore-scripts @@ -114,7 +114,7 @@ jobs: node-version: "lts/*" - name: Setup safe-chain - run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/0.0.4-docker-linux-exec-beta/install-safe-chain/install-safe-chain.sh | sh -s -- --ci + run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci - name: Install dependencies (root) run: npm ci From 6820e1e76c5003347381e7dda767113f459ecba5 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 7 Jan 2026 14:09:18 +0100 Subject: [PATCH 067/360] Fix broken compatibility in install --- install-scripts/install-safe-chain.sh | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 88cabe7..7ee07c2 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -32,9 +32,16 @@ error() { } # Detect OS +# For legacy versions (when SAFE_CHAIN_VERSION is set), use 'linux' instead of 'linuxstatic' detect_os() { case "$(uname -s)" in - Linux*) echo "linuxstatic" ;; + Linux*) + if [ -n "$SAFE_CHAIN_VERSION" ]; then + echo "linux" + else + echo "linuxstatic" + fi + ;; Darwin*) echo "macos" ;; *) error "Unsupported operating system: $(uname -s)" ;; esac @@ -244,6 +251,20 @@ main() { # Parse command-line arguments parse_arguments "$@" + # Show deprecation warning if SAFE_CHAIN_VERSION is set + if [ -n "$SAFE_CHAIN_VERSION" ]; then + warn "SAFE_CHAIN_VERSION environment variable is deprecated." + warn "" + warn "Please use direct download URLs for version pinning instead:" + warn "" + if [ "$USE_CI_SETUP" = "true" ]; then + warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh -s -- --ci" + else + warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh" + fi + warn "" + fi + # Fetch latest version if VERSION is not set if [ -z "$VERSION" ]; then info "Fetching latest release version..." From 43eda4fadf46035ebf65bf4202b61f5d4bcab441 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 7 Jan 2026 14:20:16 +0100 Subject: [PATCH 068/360] Add deprecation message to powershell version as well --- install-scripts/install-safe-chain.ps1 | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index 51d15ba..ffe2505 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -149,6 +149,20 @@ function Remove-VoltaInstallation { # Main installation function Install-SafeChain { + # Show deprecation warning if SAFE_CHAIN_VERSION is set + if (-not [string]::IsNullOrWhiteSpace($env:SAFE_CHAIN_VERSION)) { + Write-Warn "SAFE_CHAIN_VERSION environment variable is deprecated." + Write-Warn "" + Write-Warn "Please use direct download URLs for version pinning instead:" + Write-Warn "" + if ($ci) { + Write-Warn " iex `"& { `$(iwr 'https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1' -UseBasicParsing) } -ci`"" + } else { + Write-Warn " iex (iwr `"https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1`" -UseBasicParsing)" + } + Write-Warn "" + } + # Fetch latest version if VERSION is not set if ([string]::IsNullOrWhiteSpace($Version)) { Write-Info "Fetching latest release version..." From 3bfca9e296470c6429dfa46d86d0be7827a5804a Mon Sep 17 00:00:00 2001 From: Uriel Corfa Date: Wed, 7 Jan 2026 17:18:48 +0100 Subject: [PATCH 069/360] Propagate command-not-found errors when invoking wrapped commands Before this change, if a package manager was not installed, safe-chain still sets the function and when invoked, the wrapper will invoke safe-chain, which will exit with error code 127 when it fails to invoke the wrapped command. As an example (with a shell prompt that shows $? when non-zero): ``` $ type -f pip bash: type: pip: not found 1$ pip 127$ ``` With this patch, the wrapper first checks for the existence of the wrapped command (ignoring functions), and if no such command exists, it instructs the shell to invoke it anyway. This results in the shell failing to find the command, and reporting an error as if the wrapper function wasn't there: ``` $ source init-posix.sh $ type -f pip bash: type: pip: not found 1$ pip Command 'pip' not found, but can be installed with: sudo apt install python3-pip 127$ ``` --- .../src/shell-integration/startup-scripts/init-posix.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh index e649909..b9eebeb 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh @@ -76,6 +76,14 @@ function printSafeChainWarning() { function wrapSafeChainCommand() { local original_cmd="$1" + if ! type -f "${original_cmd}" > /dev/null 2>&1; then + # If the original command is not available, don't try to wrap it: invoke it + # transparently, so the shell can report errors as if this wrapper didn't + # exist. + command $@ + return $? + fi + if command -v safe-chain > /dev/null 2>&1; then # If the aikido command is available, just run it with the provided arguments safe-chain "$@" From 59f8b55bdac485f4cebbf651f711fbd741f598cf Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 8 Jan 2026 08:00:26 +0100 Subject: [PATCH 070/360] Add a section about troubleshooting when the package is already in the cache --- docs/troubleshooting.md | 50 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 398ef4a..34b2099 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -44,6 +44,8 @@ pip3 install safe-chain-pi-test These test packages are flagged as malware and should be blocked by Safe Chain. +**If the test package installs successfully instead of being blocked**, see [Malware Not Being Blocked](#malware-not-being-blocked) below. + ### Logging Options Use logging flags to get more information: @@ -58,6 +60,52 @@ npm install express --safe-chain-logging=silent ## Common Issues +### Malware Not Being Blocked + +**Symptom:** Test malware packages (like `safe-chain-test`) install successfully when they should be blocked + +**Most Common Cause:** The package is cached in your package manager's local store + +Safe-chain blocks malicious packages by intercepting network requests to package registries using its proxy. + +When a package is already cached locally, the package manager skips downloading it from the registry, which bypasses the proxy. + +**Resolution Steps:** + +1. **Clear your package manager's cache:** + + ```bash + # For npm + npm cache clean --force + + # For pnpm + pnpm store prune + + # For yarn (classic) + yarn cache clean + + # For yarn (berry/v2+) + yarn cache clean --all + + # For bun + bun pm cache rm + ``` + + > **⚠️ Warning:** Cache clearing is safe but will remove all cached packages. Subsequent installations will need to re-download packages. In CI/CD environments or monorepos, this may affect build times. + +2. **Clean local installation artifacts (optional):** + + ```bash + # Remove node_modules if you want a completely fresh install + rm -rf node_modules + ``` + +3. **Re-test malware blocking:** + + ```bash + npm install safe-chain-test # Should be blocked + ``` + ### Shell Aliases Not Working After Installation **Symptom:** Running `npm` shows regular npm instead of safe-chain wrapped version @@ -246,4 +294,4 @@ If you encounter problems: - Shell type and version - `safe-chain --version` output - Output from verification commands - - Verbose logs of the failing command + - Verbose logs of the failing command (add the `--safe-chain-logging=verbose` argument) From 6a70898e7b0f52e2d19d65ab4ce373c3e1b114d3 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 8 Jan 2026 08:01:48 +0100 Subject: [PATCH 071/360] Remove "optional" from "Clean local installation artifacts" --- docs/troubleshooting.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 34b2099..8c32bee 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -93,7 +93,7 @@ When a package is already cached locally, the package manager skips downloading > **⚠️ Warning:** Cache clearing is safe but will remove all cached packages. Subsequent installations will need to re-download packages. In CI/CD environments or monorepos, this may affect build times. -2. **Clean local installation artifacts (optional):** +2. **Clean local installation artifacts:** ```bash # Remove node_modules if you want a completely fresh install From 4e894dd0fdcfdea1fb731c3562adbf6be7b86c04 Mon Sep 17 00:00:00 2001 From: Uriel Corfa Date: Thu, 8 Jan 2026 09:56:59 +0100 Subject: [PATCH 072/360] init-posix: preserve arguments when exec'ing the original_cmd --- .../src/shell-integration/startup-scripts/init-posix.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh index b9eebeb..ebaaf3c 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh @@ -80,7 +80,7 @@ function wrapSafeChainCommand() { # If the original command is not available, don't try to wrap it: invoke it # transparently, so the shell can report errors as if this wrapper didn't # exist. - command $@ + command "$@" return $? fi From 0ce0a875575a6d6ca9485e2f63cd54d44cea51e1 Mon Sep 17 00:00:00 2001 From: Uriel Corfa Date: Thu, 8 Jan 2026 10:01:13 +0100 Subject: [PATCH 073/360] Add the same handler for fish --- .../startup-scripts/init-fish.fish | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish index ec58c8b..13463f6 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish @@ -71,13 +71,13 @@ end function printSafeChainWarning set original_cmd $argv[1] - + # Fish equivalent of ANSI color codes: yellow background, black text for "Warning:" set_color -b yellow black printf "Warning:" set_color normal printf " safe-chain is not available to protect you from installing malware. %s will run without it.\n" $original_cmd - + # Cyan text for the install command printf "Install safe-chain by using " set_color cyan @@ -90,6 +90,20 @@ function wrapSafeChainCommand set original_cmd $argv[1] set cmd_args $argv[2..-1] + if not type -fq $original_cmd + # If the original command is not available, don't try to wrap it: invoke + # it transparently, so the shell can report errors as if this wrapper + # didn't exist. fish always adds extra debug information when executing + # missing commands from within a function, so after the "command not + # found" handler, there will be information about how the + # wrapSafeChainCommand function errored out. To avoid users assuming this + # is a safe-chain bug, display an explicit error message afterwards. + command $original_cmd $cmd_args + set oldstatus $status + echo "safe-chain tried to run $original_cmd but it doesn't seem to be installed in your \$PATH." >&2 + return $oldstatus + end + if type -q safe-chain # If the safe-chain command is available, just run it with the provided arguments safe-chain $original_cmd $cmd_args From 3573ef2bc5959839ef55b6f48925a7a1231218f7 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 12 Jan 2026 10:50:06 +0100 Subject: [PATCH 074/360] Allow to configure loglevel through an env variable --- .../src/config/environmentVariables.js | 9 ++ packages/safe-chain/src/config/settings.js | 18 ++- .../safe-chain/src/config/settings.spec.js | 131 ++++++++++++++++-- 3 files changed, 137 insertions(+), 21 deletions(-) diff --git a/packages/safe-chain/src/config/environmentVariables.js b/packages/safe-chain/src/config/environmentVariables.js index 64da107..1b85ed7 100644 --- a/packages/safe-chain/src/config/environmentVariables.js +++ b/packages/safe-chain/src/config/environmentVariables.js @@ -25,3 +25,12 @@ export function getNpmCustomRegistries() { export function getPipCustomRegistries() { return process.env.SAFE_CHAIN_PIP_CUSTOM_REGISTRIES; } + +/** + * Gets the logging level from environment variable + * Valid values: "silent", "normal", "verbose" + * @returns {string | undefined} + */ +export function getLoggingLevel() { + return process.env.SAFE_CHAIN_LOGGING; +} diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index 573c3ab..7a287ab 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -7,14 +7,20 @@ export const LOGGING_NORMAL = "normal"; export const LOGGING_VERBOSE = "verbose"; export function getLoggingLevel() { - const level = cliArguments.getLoggingLevel(); - - if (level === LOGGING_SILENT) { - return LOGGING_SILENT; + // Priority 1: CLI argument + const cliLevel = cliArguments.getLoggingLevel(); + if (cliLevel === LOGGING_SILENT || cliLevel === LOGGING_VERBOSE) { + return cliLevel; + } + if (cliLevel) { + // CLI arg was set but invalid, default to normal + return LOGGING_NORMAL; } - if (level === LOGGING_VERBOSE) { - return LOGGING_VERBOSE; + // Priority 2: Environment variable + const envLevel = environmentVariables.getLoggingLevel()?.toLowerCase(); + if (envLevel === LOGGING_SILENT || envLevel === LOGGING_VERBOSE) { + return envLevel; } return LOGGING_NORMAL; diff --git a/packages/safe-chain/src/config/settings.spec.js b/packages/safe-chain/src/config/settings.spec.js index db513f3..314fac0 100644 --- a/packages/safe-chain/src/config/settings.spec.js +++ b/packages/safe-chain/src/config/settings.spec.js @@ -11,9 +11,15 @@ mock.module("fs", { }, }); -const { getNpmCustomRegistries, getPipCustomRegistries } = await import( - "./settings.js" -); +const { + getNpmCustomRegistries, + getPipCustomRegistries, + getLoggingLevel, + LOGGING_SILENT, + LOGGING_NORMAL, + LOGGING_VERBOSE, +} = await import("./settings.js"); +const { initializeCliArguments } = await import("./cliArguments.js"); for (const { packageManager, getCustomRegistries, envVarName } of [ { @@ -26,8 +32,7 @@ for (const { packageManager, getCustomRegistries, envVarName } of [ getCustomRegistries: getPipCustomRegistries, envVarName: "SAFE_CHAIN_PIP_CUSTOM_REGISTRIES", }, -]) -{ +]) { describe(getCustomRegistries.name, async () => { let originalEnv; @@ -55,7 +60,10 @@ for (const { packageManager, getCustomRegistries, envVarName } of [ it("should return registries without protocol", () => { configFileContent = JSON.stringify({ [packageManager]: { - customRegistries: [`${packageManager}.company.com`, "registry.internal.net"], + customRegistries: [ + `${packageManager}.company.com`, + "registry.internal.net", + ], }, }); @@ -143,8 +151,7 @@ for (const { packageManager, getCustomRegistries, envVarName } of [ it("should parse comma-separated registries from environment variable", () => { delete process.env[envVarName]; - process.env[envVarName] = - "env1.registry.com,env2.registry.net"; + process.env[envVarName] = "env1.registry.com,env2.registry.net"; configFileContent = undefined; const registries = getCustomRegistries(); @@ -157,8 +164,7 @@ for (const { packageManager, getCustomRegistries, envVarName } of [ it("should trim whitespace from environment variable registries", () => { delete process.env[envVarName]; - process.env[envVarName] = - " env1.registry.com , env2.registry.net "; + process.env[envVarName] = " env1.registry.com , env2.registry.net "; configFileContent = undefined; const registries = getCustomRegistries(); @@ -188,11 +194,15 @@ for (const { packageManager, getCustomRegistries, envVarName } of [ it("should remove duplicate registries when merging env and config", () => { delete process.env[envVarName]; - process.env[envVarName] = - `${packageManager}.company.com,env.registry.com`; + process.env[ + envVarName + ] = `${packageManager}.company.com,env.registry.com`; configFileContent = JSON.stringify({ [packageManager]: { - customRegistries: [`${packageManager}.company.com`, "config.registry.net"], + customRegistries: [ + `${packageManager}.company.com`, + "config.registry.net", + ], }, }); @@ -221,8 +231,7 @@ for (const { packageManager, getCustomRegistries, envVarName } of [ it("should handle empty strings in comma-separated list", () => { delete process.env[envVarName]; - process.env[envVarName] = - "env1.registry.com,,env2.registry.net,"; + process.env[envVarName] = "env1.registry.com,,env2.registry.net,"; configFileContent = undefined; const registries = getCustomRegistries(); @@ -264,3 +273,95 @@ for (const { packageManager, getCustomRegistries, envVarName } of [ }); }); } + +describe("getLoggingLevel", () => { + let originalEnv; + + beforeEach(() => { + originalEnv = process.env.SAFE_CHAIN_LOGGING; + delete process.env.SAFE_CHAIN_LOGGING; + // Reset CLI arguments state + initializeCliArguments([]); + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.SAFE_CHAIN_LOGGING = originalEnv; + } else { + delete process.env.SAFE_CHAIN_LOGGING; + } + }); + + it("should return normal by default when nothing is configured", () => { + const level = getLoggingLevel(); + + assert.strictEqual(level, LOGGING_NORMAL); + }); + + it("should return silent from environment variable", () => { + process.env.SAFE_CHAIN_LOGGING = "silent"; + + const level = getLoggingLevel(); + + assert.strictEqual(level, LOGGING_SILENT); + }); + + it("should return verbose from environment variable", () => { + process.env.SAFE_CHAIN_LOGGING = "verbose"; + + const level = getLoggingLevel(); + + assert.strictEqual(level, LOGGING_VERBOSE); + }); + + it("should handle uppercase environment variable values", () => { + process.env.SAFE_CHAIN_LOGGING = "VERBOSE"; + + const level = getLoggingLevel(); + + assert.strictEqual(level, LOGGING_VERBOSE); + }); + + it("should handle mixed case environment variable values", () => { + process.env.SAFE_CHAIN_LOGGING = "Silent"; + + const level = getLoggingLevel(); + + assert.strictEqual(level, LOGGING_SILENT); + }); + + it("should return normal for invalid environment variable values", () => { + process.env.SAFE_CHAIN_LOGGING = "invalid"; + + const level = getLoggingLevel(); + + assert.strictEqual(level, LOGGING_NORMAL); + }); + + it("should prioritize CLI argument over environment variable", () => { + process.env.SAFE_CHAIN_LOGGING = "verbose"; + initializeCliArguments(["--safe-chain-logging=silent"]); + + const level = getLoggingLevel(); + + assert.strictEqual(level, LOGGING_SILENT); + }); + + it("should use environment variable when CLI argument is not set", () => { + process.env.SAFE_CHAIN_LOGGING = "silent"; + initializeCliArguments(["install", "express"]); + + const level = getLoggingLevel(); + + assert.strictEqual(level, LOGGING_SILENT); + }); + + it("should return normal when CLI argument is invalid (even if env var is valid)", () => { + process.env.SAFE_CHAIN_LOGGING = "verbose"; + initializeCliArguments(["--safe-chain-logging=invalid"]); + + const level = getLoggingLevel(); + + assert.strictEqual(level, LOGGING_NORMAL); + }); +}); From 20994c1834d3a272bae0eae6e7447834b88c8236 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 12 Jan 2026 11:01:54 +0100 Subject: [PATCH 075/360] Document to configure loglevel through env variables. --- README.md | 35 ++++++++++++++++++++++++----------- docs/troubleshooting.md | 13 +++++++++++-- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 14dc26c..57d1bf4 100644 --- a/README.md +++ b/README.md @@ -152,23 +152,36 @@ iex (iwr "https://github.com/AikidoSec/safe-chain/releases/latest/download/unins ## Logging -You can control the output from Aikido Safe Chain using the `--safe-chain-logging` flag: +You can control the output from Aikido Safe Chain using the `--safe-chain-logging` flag or the `SAFE_CHAIN_LOGGING` environment variable. -- `--safe-chain-logging=silent` - Suppresses all Aikido Safe Chain output except when malware is blocked. The package manager output is written to stdout as normal, and Safe Chain only writes a short message if it has blocked malware and causes the process to exit. +### Configuration Options - Example usage: +You can set the logging level through multiple sources (in order of priority): - ```shell - npm install express --safe-chain-logging=silent - ``` +1. **CLI Argument** (highest priority): -- `--safe-chain-logging=verbose` - Enables detailed diagnostic output from Aikido Safe Chain. Useful for troubleshooting issues or understanding what Safe Chain is doing behind the scenes. + - `--safe-chain-logging=silent` - Suppresses all Aikido Safe Chain output except when malware is blocked. The package manager output is written to stdout as normal, and Safe Chain only writes a short message if it has blocked malware and causes the process to exit. - Example usage: + ```shell + npm install express --safe-chain-logging=silent + ``` - ```shell - npm install express --safe-chain-logging=verbose - ``` + - `--safe-chain-logging=verbose` - Enables detailed diagnostic output from Aikido Safe Chain. Useful for troubleshooting issues or understanding what Safe Chain is doing behind the scenes. + + ```shell + npm install express --safe-chain-logging=verbose + ``` + +2. **Environment Variable**: + + ```shell + export SAFE_CHAIN_LOGGING=verbose + npm install express + ``` + + Valid values: `silent`, `normal`, `verbose` + + This is useful for setting a default logging level for all package manager commands in your terminal session or CI/CD environment. ## Minimum Package Age diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8c32bee..0cd6098 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -48,12 +48,16 @@ These test packages are flagged as malware and should be blocked by Safe Chain. ### Logging Options -Use logging flags to get more information: +Use logging flags or environment variables to get more information: ```bash # Verbose mode - detailed diagnostic output for troubleshooting npm install express --safe-chain-logging=verbose +# Or set it globally for all commands in your session +export SAFE_CHAIN_LOGGING=verbose +npm install express + # Silent mode - suppress all output except malware blocking npm install express --safe-chain-logging=silent ``` @@ -277,11 +281,16 @@ rm -rf ~/.safe-chain ### Enable Verbose Logging -Get detailed diagnostic output: +Get detailed diagnostic output using a CLI flag or environment variable: ```bash +# Using CLI flag npm install express --safe-chain-logging=verbose pip install requests --safe-chain-logging=verbose + +# Using environment variable (applies to all commands) +export SAFE_CHAIN_LOGGING=verbose +npm install express ``` ### Report Issues From 595f269f6268542ae7103d74777f9f05e0566e31 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 12 Jan 2026 11:20:25 +0100 Subject: [PATCH 076/360] Add comment about backwards compat. --- packages/safe-chain/src/config/settings.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index 7a287ab..6910fe3 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -13,7 +13,7 @@ export function getLoggingLevel() { return cliLevel; } if (cliLevel) { - // CLI arg was set but invalid, default to normal + // CLI arg was set but invalid, default to normal for backwards compatibility. return LOGGING_NORMAL; } From 19652c49c9e48ec96d84b40739b275831b8c8b90 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 12 Jan 2026 14:53:23 -0800 Subject: [PATCH 077/360] Attempted fix for powershell swallowing '--' --- .../startup-scripts/init-pwsh.ps1 | 66 ++++++++++++++----- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 index 7fabcad..f02b900 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 @@ -6,27 +6,27 @@ $safeChainBin = Join-Path (Join-Path $HOME '.safe-chain') 'bin' $env:PATH = "$env:PATH$pathSeparator$safeChainBin" function npx { - Invoke-WrappedCommand "npx" $args + Invoke-WrappedCommand "npx" $args $MyInvocation.Line $MyInvocation.OffsetInLine } function yarn { - Invoke-WrappedCommand "yarn" $args + Invoke-WrappedCommand "yarn" $args $MyInvocation.Line $MyInvocation.OffsetInLine } function pnpm { - Invoke-WrappedCommand "pnpm" $args + Invoke-WrappedCommand "pnpm" $args $MyInvocation.Line $MyInvocation.OffsetInLine } function pnpx { - Invoke-WrappedCommand "pnpx" $args + Invoke-WrappedCommand "pnpx" $args $MyInvocation.Line $MyInvocation.OffsetInLine } function bun { - Invoke-WrappedCommand "bun" $args + Invoke-WrappedCommand "bun" $args $MyInvocation.Line $MyInvocation.OffsetInLine } function bunx { - Invoke-WrappedCommand "bunx" $args + Invoke-WrappedCommand "bunx" $args $MyInvocation.Line $MyInvocation.OffsetInLine } function npm { @@ -37,37 +37,37 @@ function npm { return } - Invoke-WrappedCommand "npm" $args + Invoke-WrappedCommand "npm" $args $MyInvocation.Line $MyInvocation.OffsetInLine } function pip { - Invoke-WrappedCommand "pip" $args + Invoke-WrappedCommand "pip" $args $MyInvocation.Line $MyInvocation.OffsetInLine } function pip3 { - Invoke-WrappedCommand "pip3" $args + Invoke-WrappedCommand "pip3" $args $MyInvocation.Line $MyInvocation.OffsetInLine } function uv { - Invoke-WrappedCommand "uv" $args + Invoke-WrappedCommand "uv" $args $MyInvocation.Line $MyInvocation.OffsetInLine } function poetry { - Invoke-WrappedCommand "poetry" $args + Invoke-WrappedCommand "poetry" $args $MyInvocation.Line $MyInvocation.OffsetInLine } # `python -m pip`, `python -m pip3`. function python { - Invoke-WrappedCommand 'python' $args + Invoke-WrappedCommand 'python' $args $MyInvocation.Line $MyInvocation.OffsetInLine } # `python3 -m pip`, `python3 -m pip3'. function python3 { - Invoke-WrappedCommand 'python3' $args + Invoke-WrappedCommand 'python3' $args $MyInvocation.Line $MyInvocation.OffsetInLine } function pipx { - Invoke-WrappedCommand "pipx" $args + Invoke-WrappedCommand "pipx" $args $MyInvocation.Line $MyInvocation.OffsetInLine } function Write-SafeChainWarning { @@ -111,10 +111,44 @@ function Invoke-RealCommand { function Invoke-WrappedCommand { param( [string]$OriginalCmd, - [string[]]$Arguments + [string[]]$Arguments, + [string]$RawLine = $null, + [int]$RawOffset = 0 ) - if (Test-CommandAvailable "safe-chain") { + # Use raw line parsing to recover arguments like '--' that PowerShell consumes + if ($RawLine) { + $tokens = [System.Management.Automation.PSParser]::Tokenize($RawLine, [ref]$null) + $newArgs = @() + $foundCommand = $false + $canUseRaw = $true + + foreach ($t in $tokens) { + # Find the command token based on offset + if (-not $foundCommand) { + if ($t.Start -eq ($RawOffset - 1)) { $foundCommand = $true } + continue + } + # Stop at command separators + if ($t.Type -eq 'Operator' -and $t.Content -match '[|;&]') { break } + # Stop if complex variable expansion is used + if ($t.Type -eq 'Variable' -or $t.Type -eq 'Group' -or $t.Type -eq 'SubExpression') { + $canUseRaw = $false + break + } + $newArgs += $t.Content + } + + if ($foundCommand -and $canUseRaw) { + $Arguments = $newArgs + Write-Host "Safe-chain Powershell Wrapper: Reconstructed args: $($Arguments -join ' ')" + } + } + + if ($isWindowsPlatform -and (Test-CommandAvailable "safe-chain.cmd")) { + & safe-chain.cmd $OriginalCmd @Arguments + } + elseif (Test-CommandAvailable "safe-chain") { & safe-chain $OriginalCmd @Arguments } else { From 9a902af917fce9920a71a34235cd8fc5ec39977c Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 12 Jan 2026 15:12:19 -0800 Subject: [PATCH 078/360] Fix some logic --- .../startup-scripts/init-pwsh.ps1 | 60 ++++++++++++------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 index f02b900..e1ed660 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 @@ -108,6 +108,40 @@ function Invoke-RealCommand { } } +function Get-ReconstructedArguments { + param( + [string]$RawLine, + [int]$RawOffset + ) + + if (-not $RawLine) { return $null } + + $tokens = [System.Management.Automation.PSParser]::Tokenize($RawLine, [ref]$null) + $newArgs = @() + $foundCommand = $false + + foreach ($t in $tokens) { + if (-not $foundCommand) { + if ($t.Start -eq ($RawOffset - 1)) { $foundCommand = $true } + continue + } + + if ($t.Type -eq 'Operator' -and $t.Content -match '[|;&]') { break } + + # Stop if complex variable expansion is used + if ($t.Type -eq 'Variable' -or $t.Type -eq 'Group' -or $t.Type -eq 'SubExpression') { + return $null + } + + $newArgs += $t.Content + } + + if ($foundCommand) { + return ,$newArgs + } + return $null +} + function Invoke-WrappedCommand { param( [string]$OriginalCmd, @@ -118,29 +152,9 @@ function Invoke-WrappedCommand { # Use raw line parsing to recover arguments like '--' that PowerShell consumes if ($RawLine) { - $tokens = [System.Management.Automation.PSParser]::Tokenize($RawLine, [ref]$null) - $newArgs = @() - $foundCommand = $false - $canUseRaw = $true - - foreach ($t in $tokens) { - # Find the command token based on offset - if (-not $foundCommand) { - if ($t.Start -eq ($RawOffset - 1)) { $foundCommand = $true } - continue - } - # Stop at command separators - if ($t.Type -eq 'Operator' -and $t.Content -match '[|;&]') { break } - # Stop if complex variable expansion is used - if ($t.Type -eq 'Variable' -or $t.Type -eq 'Group' -or $t.Type -eq 'SubExpression') { - $canUseRaw = $false - break - } - $newArgs += $t.Content - } - - if ($foundCommand -and $canUseRaw) { - $Arguments = $newArgs + $reconstructedArgs = Get-ReconstructedArguments $RawLine $RawOffset + if ($null -ne $reconstructedArgs) { + $Arguments = $reconstructedArgs Write-Host "Safe-chain Powershell Wrapper: Reconstructed args: $($Arguments -join ' ')" } } From 340e9a90a5f47c6725852c4b61e057ccb017cf97 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 12 Jan 2026 15:13:34 -0800 Subject: [PATCH 079/360] Remove comment --- .../src/shell-integration/startup-scripts/init-pwsh.ps1 | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 index e1ed660..f82d0fc 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 @@ -155,7 +155,6 @@ function Invoke-WrappedCommand { $reconstructedArgs = Get-ReconstructedArguments $RawLine $RawOffset if ($null -ne $reconstructedArgs) { $Arguments = $reconstructedArgs - Write-Host "Safe-chain Powershell Wrapper: Reconstructed args: $($Arguments -join ' ')" } } From b25d405972d433cf30f8cf86dcb9cece4c74bf37 Mon Sep 17 00:00:00 2001 From: Robert Slootjes Date: Tue, 13 Jan 2026 08:19:10 +0100 Subject: [PATCH 080/360] Add Bitbucket Pipelines example --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 57d1bf4..17d2515 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,7 @@ iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download - ✅ **Azure Pipelines** - ✅ **CircleCI** - ✅ **Jenkins** +- ✅ **Bitbucket Pipelines** ## GitHub Actions Example @@ -360,6 +361,21 @@ pipeline { } ``` +## Bitbucket Pipelines Example + +```yaml +image: node:22 + +steps: + - step: + name: Install + script: + - npm install -g @aikidosec/safe-chain + - safe-chain setup-ci + - export PATH=~/.safe-chain/shims:$PATH + - npm ci +``` + After setup, all subsequent package manager commands in your CI pipeline will automatically be protected by Aikido Safe Chain's malware detection. # Troubleshooting From f678ff8dd1d62d8274ae093b23c010647903248c Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Tue, 13 Jan 2026 10:09:59 -0800 Subject: [PATCH 081/360] Include package name in logging when minimum package age is not met --- .../src/registryProxy/interceptors/npm/modifyNpmInfo.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js index 2ee4eb8..ae71cb3 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js @@ -116,8 +116,10 @@ export function modifyNpmInfoResponse(body, headers) { function deleteVersionFromJson(json, version) { state.hasSuppressedVersions = true; - ui.writeVerbose( - `Safe-chain: ${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).` + const packageName = typeof json?.name === "string" ? json.name : "(unknown)"; + + ui.writeInformation( + `Safe-chain: ${packageName}@${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).` ); delete json.time[version]; From c38f1bcb3e752985b040e6669bbc5e0932bad621 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Tue, 13 Jan 2026 19:33:00 +0100 Subject: [PATCH 082/360] Update packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js --- .../src/registryProxy/interceptors/npm/modifyNpmInfo.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js index ae71cb3..421666a 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js @@ -118,7 +118,7 @@ function deleteVersionFromJson(json, version) { const packageName = typeof json?.name === "string" ? json.name : "(unknown)"; - ui.writeInformation( + ui.writeVerbose( `Safe-chain: ${packageName}@${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).` ); From d83a381231a84af8d1766eb1e4e1f3de3de6107c Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 14 Jan 2026 14:02:27 +0100 Subject: [PATCH 083/360] Retry downloading the malware database 3 times --- packages/safe-chain/src/api/aikido.js | 97 +++++++++++++------ .../src/scanning/malwareDatabase.js | 14 +-- 2 files changed, 77 insertions(+), 34 deletions(-) diff --git a/packages/safe-chain/src/api/aikido.js b/packages/safe-chain/src/api/aikido.js index 5c04360..26c88ea 100644 --- a/packages/safe-chain/src/api/aikido.js +++ b/packages/safe-chain/src/api/aikido.js @@ -1,5 +1,9 @@ import fetch from "make-fetch-happen"; -import { getEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; +import { + getEcoSystem, + ECOSYSTEM_JS, + ECOSYSTEM_PY, +} from "../config/settings.js"; const malwareDatabaseUrls = { [ECOSYSTEM_JS]: "https://malware-list.aikido.dev/malware_predictions.json", @@ -17,38 +21,77 @@ const malwareDatabaseUrls = { * @returns {Promise<{malwareDatabase: MalwarePackage[], version: string | undefined}>} */ export async function fetchMalwareDatabase() { - const ecosystem = getEcoSystem(); - const malwareDatabaseUrl = malwareDatabaseUrls[/** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem)]; - const response = await fetch(malwareDatabaseUrl); - if (!response.ok) { - throw new Error(`Error fetching ${ecosystem} malware database: ${response.statusText}`); - } + return retry(async () => { + const ecosystem = getEcoSystem(); + const malwareDatabaseUrl = + malwareDatabaseUrls[ + /** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem) + ]; + const response = await fetch(malwareDatabaseUrl); + if (!response.ok) { + throw new Error( + `Error fetching ${ecosystem} malware database: ${response.statusText}` + ); + } - try { - let malwareDatabase = await response.json(); - return { - malwareDatabase: malwareDatabase, - version: response.headers.get("etag") || undefined, - }; - } catch (/** @type {any} */ error) { - throw new Error(`Error parsing malware database: ${error.message}`); - } + try { + let malwareDatabase = await response.json(); + return { + malwareDatabase: malwareDatabase, + version: response.headers.get("etag") || undefined, + }; + } catch (/** @type {any} */ error) { + throw new Error(`Error parsing malware database: ${error.message}`); + } + }, 3); } /** * @returns {Promise} */ export async function fetchMalwareDatabaseVersion() { - const ecosystem = getEcoSystem(); - const malwareDatabaseUrl = malwareDatabaseUrls[/** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem)]; - const response = await fetch(malwareDatabaseUrl, { - method: "HEAD", - }); + return retry(async () => { + const ecosystem = getEcoSystem(); + const malwareDatabaseUrl = + malwareDatabaseUrls[ + /** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem) + ]; + const response = await fetch(malwareDatabaseUrl, { + method: "HEAD", + }); - if (!response.ok) { - throw new Error( - `Error fetching ${ecosystem} malware database version: ${response.statusText}` - ); - } - return response.headers.get("etag") || undefined; + if (!response.ok) { + throw new Error( + `Error fetching ${ecosystem} malware database version: ${response.statusText}` + ); + } + return response.headers.get("etag") || undefined; + }, 3); +} + +/** + * Retries an asynchronous function multiple times until it succeeds or exhausts all attempts. + * + * @template T + * @param {() => Promise} func - The asynchronous function to retry + * @param {number} times - The number of retry attempts (will execute times + 1 total attempts) + * @returns {Promise} The return value of the function if successful + * @throws {Error} The last error encountered if all retry attempts fail + */ +async function retry(func, times) { + let lastError; + + for (let i = 0; i <= times; i++) { + try { + return await func(); + } catch (error) { + lastError = error; + } + + if (i < times) { + await new Promise((resolve) => setTimeout(resolve, Math.pow(2, i) * 500)); + } + } + + throw lastError; } diff --git a/packages/safe-chain/src/scanning/malwareDatabase.js b/packages/safe-chain/src/scanning/malwareDatabase.js index 4aba43c..120c438 100644 --- a/packages/safe-chain/src/scanning/malwareDatabase.js +++ b/packages/safe-chain/src/scanning/malwareDatabase.js @@ -48,13 +48,13 @@ export async function openMalwareDatabase() { */ 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 packageData = malwareDatabase.find((pkg) => { + const normalizedPkgName = normalizePackageName(pkg.package_name); + return ( + normalizedPkgName === normalizedName && + (pkg.version === version || pkg.version === "*") + ); + }); if (!packageData) { return MALWARE_STATUS_OK; From 8d2655a4bf1b59d61c34d06d28b0b4f1992a0f48 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 14 Jan 2026 14:41:06 +0100 Subject: [PATCH 084/360] Add tests for malware db retry --- packages/safe-chain/src/api/aikido.spec.js | 125 +++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 packages/safe-chain/src/api/aikido.spec.js diff --git a/packages/safe-chain/src/api/aikido.spec.js b/packages/safe-chain/src/api/aikido.spec.js new file mode 100644 index 0000000..2191d42 --- /dev/null +++ b/packages/safe-chain/src/api/aikido.spec.js @@ -0,0 +1,125 @@ +import { describe, it, mock, beforeEach } from "node:test"; +import assert from "node:assert"; + +describe("aikido API", async () => { + const mockFetch = mock.fn(); + + mock.module("make-fetch-happen", { + defaultExport: mockFetch, + }); + + mock.module("../config/settings.js", { + namedExports: { + getEcoSystem: () => "js", + ECOSYSTEM_JS: "js", + ECOSYSTEM_PY: "py", + }, + }); + + const { fetchMalwareDatabase, fetchMalwareDatabaseVersion } = + await import("./aikido.js"); + + beforeEach(() => { + mockFetch.mock.resetCalls(); + }); + + describe("fetchMalwareDatabase", () => { + it("should succeed immediately when fetch succeeds on first try", async () => { + const malwareData = [ + { package_name: "malicious-pkg", version: "1.0.0", reason: "test" }, + ]; + mockFetch.mock.mockImplementationOnce(() => ({ + ok: true, + json: async () => malwareData, + headers: { get: () => '"etag-123"' }, + })); + + const result = await fetchMalwareDatabase(); + + assert.strictEqual(mockFetch.mock.calls.length, 1); + assert.deepStrictEqual(result.malwareDatabase, malwareData); + assert.strictEqual(result.version, '"etag-123"'); + }); + + it("should throw error after exhausting all retries", async () => { + mockFetch.mock.mockImplementation(() => { + throw new Error("Network error"); + }); + + await assert.rejects(() => fetchMalwareDatabase(), { + message: "Network error", + }); + + assert.strictEqual(mockFetch.mock.calls.length, 4); + }); + + it("should succeed after failing 3 times and succeeding on 4th attempt", async () => { + const malwareData = [ + { package_name: "bad-pkg", version: "2.0.0", reason: "malware" }, + ]; + let callCount = 0; + mockFetch.mock.mockImplementation(() => { + callCount++; + if (callCount < 4) { + throw new Error("Network error"); + } + return { + ok: true, + json: async () => malwareData, + headers: { get: () => '"etag-456"' }, + }; + }); + + const result = await fetchMalwareDatabase(); + + assert.strictEqual(mockFetch.mock.calls.length, 4); + assert.deepStrictEqual(result.malwareDatabase, malwareData); + assert.strictEqual(result.version, '"etag-456"'); + }); + }); + + describe("fetchMalwareDatabaseVersion", () => { + it("should succeed immediately when fetch succeeds on first try", async () => { + mockFetch.mock.mockImplementationOnce(() => ({ + ok: true, + headers: { get: () => '"version-etag"' }, + })); + + const result = await fetchMalwareDatabaseVersion(); + + assert.strictEqual(mockFetch.mock.calls.length, 1); + assert.strictEqual(result, '"version-etag"'); + }); + + it("should throw error after exhausting all retries", async () => { + mockFetch.mock.mockImplementation(() => { + throw new Error("Connection refused"); + }); + + await assert.rejects(() => fetchMalwareDatabaseVersion(), { + message: "Connection refused", + }); + + assert.strictEqual(mockFetch.mock.calls.length, 4); + }); + + it("should succeed after failing 3 times and succeeding on 4th attempt", async () => { + let callCount = 0; + mockFetch.mock.mockImplementation(() => { + callCount++; + if (callCount < 4) { + throw new Error("Timeout"); + } + return { + ok: true, + headers: { get: () => '"final-etag"' }, + }; + }); + + const result = await fetchMalwareDatabaseVersion(); + + assert.strictEqual(mockFetch.mock.calls.length, 4); + assert.strictEqual(result, '"final-etag"'); + }); + }); +}); From a5d545f29b2ad95e47c0029b038b7bdfb6a01ac3 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 14 Jan 2026 14:55:11 +0100 Subject: [PATCH 085/360] Handle pr comments --- packages/safe-chain/src/api/aikido.js | 22 ++++++++++++++----- .../src/scanning/malwareDatabase.js | 14 ++++++------ 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/safe-chain/src/api/aikido.js b/packages/safe-chain/src/api/aikido.js index 26c88ea..88dffb2 100644 --- a/packages/safe-chain/src/api/aikido.js +++ b/packages/safe-chain/src/api/aikido.js @@ -21,6 +21,8 @@ const malwareDatabaseUrls = { * @returns {Promise<{malwareDatabase: MalwarePackage[], version: string | undefined}>} */ export async function fetchMalwareDatabase() { + const numberOfAttempts = 4; + return retry(async () => { const ecosystem = getEcoSystem(); const malwareDatabaseUrl = @@ -43,13 +45,15 @@ export async function fetchMalwareDatabase() { } catch (/** @type {any} */ error) { throw new Error(`Error parsing malware database: ${error.message}`); } - }, 3); + }, numberOfAttempts); } /** * @returns {Promise} */ export async function fetchMalwareDatabaseVersion() { + const numberOfAttempts = 4; + return retry(async () => { const ecosystem = getEcoSystem(); const malwareDatabaseUrl = @@ -66,7 +70,7 @@ export async function fetchMalwareDatabaseVersion() { ); } return response.headers.get("etag") || undefined; - }, 3); + }, numberOfAttempts); } /** @@ -74,21 +78,27 @@ export async function fetchMalwareDatabaseVersion() { * * @template T * @param {() => Promise} func - The asynchronous function to retry - * @param {number} times - The number of retry attempts (will execute times + 1 total attempts) + * @param {number} attempts - The number of attempts * @returns {Promise} The return value of the function if successful * @throws {Error} The last error encountered if all retry attempts fail */ -async function retry(func, times) { +async function retry(func, attempts) { let lastError; - for (let i = 0; i <= times; i++) { + for (let i = 0; i < attempts; i++) { try { return await func(); } catch (error) { lastError = error; } - if (i < times) { + if (i < attempts - 1) { + // When this is not the last try, back-off expenentially: + // 1st attempt - 500ms delay + // 2nd attempt - 1000ms delay + // 3rd attempt - 2000ms delay + // 4th attempt - 4000ms delay + // ... await new Promise((resolve) => setTimeout(resolve, Math.pow(2, i) * 500)); } } diff --git a/packages/safe-chain/src/scanning/malwareDatabase.js b/packages/safe-chain/src/scanning/malwareDatabase.js index 120c438..4aba43c 100644 --- a/packages/safe-chain/src/scanning/malwareDatabase.js +++ b/packages/safe-chain/src/scanning/malwareDatabase.js @@ -48,13 +48,13 @@ export async function openMalwareDatabase() { */ 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 packageData = malwareDatabase.find( + (pkg) => { + const normalizedPkgName = normalizePackageName(pkg.package_name); + return normalizedPkgName === normalizedName && + (pkg.version === version || pkg.version === "*"); + } + ); if (!packageData) { return MALWARE_STATUS_OK; From 6f4eaf5234447948397e6e021e009aa51ea7370b Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 14 Jan 2026 15:31:37 +0100 Subject: [PATCH 086/360] Don't swallow error on retry --- packages/safe-chain/src/api/aikido.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/safe-chain/src/api/aikido.js b/packages/safe-chain/src/api/aikido.js index 88dffb2..be01518 100644 --- a/packages/safe-chain/src/api/aikido.js +++ b/packages/safe-chain/src/api/aikido.js @@ -4,6 +4,7 @@ import { ECOSYSTEM_JS, ECOSYSTEM_PY, } from "../config/settings.js"; +import { ui } from "../environment/userInteraction.js"; const malwareDatabaseUrls = { [ECOSYSTEM_JS]: "https://malware-list.aikido.dev/malware_predictions.json", @@ -89,6 +90,10 @@ async function retry(func, attempts) { try { return await func(); } catch (error) { + ui.writeVerbose( + "An error occurred while trying to download the Aikido Malware database", + error + ); lastError = error; } From 9d55afbf857a29f402c7e4234cb651a361124b5e Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Wed, 14 Jan 2026 15:33:09 +0100 Subject: [PATCH 087/360] Update packages/safe-chain/src/api/aikido.js --- packages/safe-chain/src/api/aikido.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/api/aikido.js b/packages/safe-chain/src/api/aikido.js index be01518..abb2135 100644 --- a/packages/safe-chain/src/api/aikido.js +++ b/packages/safe-chain/src/api/aikido.js @@ -98,7 +98,7 @@ async function retry(func, attempts) { } if (i < attempts - 1) { - // When this is not the last try, back-off expenentially: + // When this is not the last try, back-off exponentially: // 1st attempt - 500ms delay // 2nd attempt - 1000ms delay // 3rd attempt - 2000ms delay From 6815b620199d4ef8ce48202bcb7a0ebfe5f66f55 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 14 Jan 2026 17:41:23 +0100 Subject: [PATCH 088/360] Allow to exclude packages from the minimum package age --- README.md | 16 ++ packages/safe-chain/src/api/aikido.spec.js | 8 + packages/safe-chain/src/config/configFile.js | 22 +++ .../src/config/environmentVariables.js | 10 ++ packages/safe-chain/src/config/settings.js | 31 ++++ .../safe-chain/src/config/settings.spec.js | 135 ++++++++++++++++ .../interceptors/npm/modifyNpmInfo.js | 12 +- .../npm/npmInterceptor.minPackageAge.spec.js | 153 ++++++++++++++++++ .../npmInterceptor.packageDownload.spec.js | 1 + 9 files changed, 387 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 17d2515..bc61787 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,22 @@ You can set the minimum package age through multiple sources (in order of priori } ``` +### Excluding Packages + +Exclude trusted packages from minimum age filtering via environment variable or config file (both are merged): + +```shell +export SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS="react,@aikidosec/safe-chain" +``` + +```json +{ + "npm": { + "minimumPackageAgeExclusions": ["react", "@aikidosec/safe-chain"] + } +} +``` + ## Custom Registries Configure Safe Chain to scan packages from custom or private registries. diff --git a/packages/safe-chain/src/api/aikido.spec.js b/packages/safe-chain/src/api/aikido.spec.js index 2191d42..2e7cecb 100644 --- a/packages/safe-chain/src/api/aikido.spec.js +++ b/packages/safe-chain/src/api/aikido.spec.js @@ -8,6 +8,14 @@ describe("aikido API", async () => { defaultExport: mockFetch, }); + mock.module("../environment/userInteraction.js", { + namedExports: { + ui: { + writeVerbose: () => {}, + }, + }, + }); + mock.module("../config/settings.js", { namedExports: { getEcoSystem: () => "js", diff --git a/packages/safe-chain/src/config/configFile.js b/packages/safe-chain/src/config/configFile.js index a98304e..fd6ac26 100644 --- a/packages/safe-chain/src/config/configFile.js +++ b/packages/safe-chain/src/config/configFile.js @@ -16,6 +16,7 @@ import { getEcoSystem } from "./settings.js"; * @typedef {Object} SafeChainRegistryConfiguration * We cannot trust the input and should add the necessary validations. * @property {unknown | string[]} customRegistries + * @property {unknown | string[]} minimumPackageAgeExclusions */ /** @@ -127,6 +128,27 @@ export function getPipCustomRegistries() { return customRegistries.filter((item) => typeof item === "string"); } +/** + * Gets the minimum package age exclusions from the config file + * @returns {string[]} + */ +export function getNpmMinimumPackageAgeExclusions() { + const config = readConfigFile(); + + if (!config || !config.npm) { + return []; + } + + const npmConfig = /** @type {SafeChainRegistryConfiguration} */ (config.npm); + const exclusions = npmConfig.minimumPackageAgeExclusions; + + if (!Array.isArray(exclusions)) { + return []; + } + + return exclusions.filter((item) => typeof item === "string"); +} + /** * @param {import("../api/aikido.js").MalwarePackage[]} data * @param {string | number} version diff --git a/packages/safe-chain/src/config/environmentVariables.js b/packages/safe-chain/src/config/environmentVariables.js index 1b85ed7..8a44841 100644 --- a/packages/safe-chain/src/config/environmentVariables.js +++ b/packages/safe-chain/src/config/environmentVariables.js @@ -34,3 +34,13 @@ export function getPipCustomRegistries() { export function getLoggingLevel() { return process.env.SAFE_CHAIN_LOGGING; } + +/** + * Gets the minimum package age exclusions from environment variable + * Expected format: comma-separated list of package names + * Example: "react,@aikidosec/safe-chain,lodash" + * @returns {string | undefined} + */ +export function getNpmMinimumPackageAgeExclusions() { + return process.env.SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS; +} diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index 6910fe3..b9243b0 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -167,3 +167,34 @@ export function getPipCustomRegistries() { // Normalize each registry (remove protocol if any) return uniqueRegistries.map(normalizeRegistry); } + +/** + * Parses comma-separated exclusions from environment variable + * @param {string | undefined} envValue + * @returns {string[]} + */ +function parseExclusionsFromEnv(envValue) { + if (!envValue || typeof envValue !== "string") { + return []; + } + + return envValue + .split(",") + .map((exclusion) => exclusion.trim()) + .filter((exclusion) => exclusion.length > 0); +} + +/** + * Gets the minimum package age exclusions from both environment variable and config file (merged) + * @returns {string[]} + */ +export function getNpmMinimumPackageAgeExclusions() { + const envExclusions = parseExclusionsFromEnv( + environmentVariables.getNpmMinimumPackageAgeExclusions() + ); + const configExclusions = configFile.getNpmMinimumPackageAgeExclusions(); + + // Merge both sources and remove duplicates + const allExclusions = [...envExclusions, ...configExclusions]; + return [...new Set(allExclusions)]; +} diff --git a/packages/safe-chain/src/config/settings.spec.js b/packages/safe-chain/src/config/settings.spec.js index 314fac0..8db5b83 100644 --- a/packages/safe-chain/src/config/settings.spec.js +++ b/packages/safe-chain/src/config/settings.spec.js @@ -14,6 +14,7 @@ mock.module("fs", { const { getNpmCustomRegistries, getPipCustomRegistries, + getNpmMinimumPackageAgeExclusions, getLoggingLevel, LOGGING_SILENT, LOGGING_NORMAL, @@ -365,3 +366,137 @@ describe("getLoggingLevel", () => { assert.strictEqual(level, LOGGING_NORMAL); }); }); + +describe("getNpmMinimumPackageAgeExclusions", () => { + let originalEnv; + const envVarName = "SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS"; + + beforeEach(() => { + originalEnv = process.env[envVarName]; + delete process.env[envVarName]; + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env[envVarName] = originalEnv; + } else { + delete process.env[envVarName]; + } + configFileContent = undefined; + }); + + it("should return empty array when no exclusions configured", () => { + configFileContent = undefined; + + const exclusions = getNpmMinimumPackageAgeExclusions(); + + assert.deepStrictEqual(exclusions, []); + }); + + it("should return exclusions from config file", () => { + configFileContent = JSON.stringify({ + npm: { + minimumPackageAgeExclusions: ["react", "@aikidosec/safe-chain"], + }, + }); + + const exclusions = getNpmMinimumPackageAgeExclusions(); + + assert.deepStrictEqual(exclusions, ["react", "@aikidosec/safe-chain"]); + }); + + it("should parse comma-separated exclusions from environment variable", () => { + process.env[envVarName] = "lodash,express,@types/node"; + configFileContent = undefined; + + const exclusions = getNpmMinimumPackageAgeExclusions(); + + assert.deepStrictEqual(exclusions, ["lodash", "express", "@types/node"]); + }); + + it("should merge environment variable and config file exclusions", () => { + process.env[envVarName] = "lodash"; + configFileContent = JSON.stringify({ + npm: { + minimumPackageAgeExclusions: ["react"], + }, + }); + + const exclusions = getNpmMinimumPackageAgeExclusions(); + + assert.deepStrictEqual(exclusions, ["lodash", "react"]); + }); + + it("should remove duplicate exclusions when merging", () => { + process.env[envVarName] = "lodash,react"; + configFileContent = JSON.stringify({ + npm: { + minimumPackageAgeExclusions: ["react", "express"], + }, + }); + + const exclusions = getNpmMinimumPackageAgeExclusions(); + + assert.deepStrictEqual(exclusions, ["lodash", "react", "express"]); + }); + + it("should trim whitespace from environment variable exclusions", () => { + process.env[envVarName] = " lodash , react "; + configFileContent = undefined; + + const exclusions = getNpmMinimumPackageAgeExclusions(); + + assert.deepStrictEqual(exclusions, ["lodash", "react"]); + }); + + it("should handle scoped packages", () => { + configFileContent = JSON.stringify({ + npm: { + minimumPackageAgeExclusions: ["@babel/core", "@types/react"], + }, + }); + + const exclusions = getNpmMinimumPackageAgeExclusions(); + + assert.deepStrictEqual(exclusions, ["@babel/core", "@types/react"]); + }); + + it("should handle empty strings in comma-separated list", () => { + process.env[envVarName] = "lodash,,react,"; + configFileContent = undefined; + + const exclusions = getNpmMinimumPackageAgeExclusions(); + + assert.deepStrictEqual(exclusions, ["lodash", "react"]); + }); + + it("should return empty array for empty environment variable", () => { + process.env[envVarName] = ""; + configFileContent = undefined; + + const exclusions = getNpmMinimumPackageAgeExclusions(); + + assert.deepStrictEqual(exclusions, []); + }); + + it("should return empty array for whitespace-only environment variable", () => { + process.env[envVarName] = " , , "; + configFileContent = undefined; + + const exclusions = getNpmMinimumPackageAgeExclusions(); + + assert.deepStrictEqual(exclusions, []); + }); + + it("should filter non-string values from config file", () => { + configFileContent = JSON.stringify({ + npm: { + minimumPackageAgeExclusions: ["react", 123, null, "lodash", undefined], + }, + }); + + const exclusions = getNpmMinimumPackageAgeExclusions(); + + assert.deepStrictEqual(exclusions, ["react", "lodash"]); + }); +}); diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js index 421666a..3407397 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js @@ -1,4 +1,4 @@ -import { getMinimumPackageAgeHours } from "../../../config/settings.js"; +import { getMinimumPackageAgeHours, getNpmMinimumPackageAgeExclusions } from "../../../config/settings.js"; import { ui } from "../../../environment/userInteraction.js"; import { getHeaderValueAsString } from "../../http-utils.js"; @@ -65,6 +65,16 @@ export function modifyNpmInfoResponse(body, headers) { return body; } + // Check if this package is excluded from minimum age filtering + const packageName = bodyJson.name; + const exclusions = getNpmMinimumPackageAgeExclusions(); + if (packageName && exclusions.includes(packageName)) { + ui.writeVerbose( + `Safe-chain: ${packageName} is excluded from minimum package age filtering (minimumPackageAgeExclusions setting).` + ); + return body; + } + const cutOff = new Date( new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000 ); diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js index fb7ae56..ed00909 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js @@ -4,12 +4,14 @@ import assert from "node:assert"; describe("npmInterceptor minimum package age", async () => { let minimumPackageAgeSettings = 48; let skipMinimumPackageAgeSetting = false; + let minimumPackageAgeExclusionsSetting = []; mock.module("../../../config/settings.js", { namedExports: { getMinimumPackageAgeHours: () => minimumPackageAgeSettings, skipMinimumPackageAge: () => skipMinimumPackageAgeSetting, getNpmCustomRegistries: () => [], + getNpmMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting, }, }); @@ -357,6 +359,157 @@ describe("npmInterceptor minimum package age", async () => { assert.equal(modifiedJson["dist-tags"]["latest"], "2.0.0"); }); + it("Should not filter packages when package is in exclusion list", async () => { + minimumPackageAgeSettings = 5; + skipMinimumPackageAgeSetting = false; + minimumPackageAgeExclusionsSetting = ["lodash"]; + + const packageUrl = "https://registry.npmjs.org/lodash"; + + const originalBody = JSON.stringify({ + name: "lodash", + ["dist-tags"]: { + latest: "3.0.0", + }, + versions: { + ["1.0.0"]: {}, + ["2.0.0"]: {}, + ["3.0.0"]: {}, + }, + time: { + created: getDate(-365 * 24), + modified: getDate(-3), + ["1.0.0"]: getDate(-7), + // cutoff-date here + ["2.0.0"]: getDate(-4), + ["3.0.0"]: getDate(-3), // Would normally be filtered + }, + }); + + const modifiedBody = await runModifyNpmInfoRequest(packageUrl, originalBody); + const modifiedJson = JSON.parse(modifiedBody); + + // All versions should remain unchanged since lodash is excluded + assert.equal(Object.keys(modifiedJson.versions).length, 3); + assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0")); + assert.ok(Object.keys(modifiedJson.versions).includes("2.0.0")); + assert.ok(Object.keys(modifiedJson.versions).includes("3.0.0")); + assert.equal(modifiedJson["dist-tags"]["latest"], "3.0.0"); + }); + + it("Should filter packages when package is NOT in exclusion list", async () => { + minimumPackageAgeSettings = 5; + skipMinimumPackageAgeSetting = false; + minimumPackageAgeExclusionsSetting = ["react"]; // Different package + + const packageUrl = "https://registry.npmjs.org/lodash"; + + const modifiedBody = await runModifyNpmInfoRequest( + packageUrl, + JSON.stringify({ + name: "lodash", + ["dist-tags"]: { latest: "3.0.0" }, + versions: { ["1.0.0"]: {}, ["3.0.0"]: {} }, + time: { + created: getDate(-365 * 24), + modified: getDate(-3), + ["1.0.0"]: getDate(-7), + ["3.0.0"]: getDate(-3), + }, + }) + ); + + const modifiedJson = JSON.parse(modifiedBody); + + // lodash should still be filtered since it's not in exclusions + assert.equal(Object.keys(modifiedJson.versions).length, 1); + assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0")); + assert.ok(!Object.keys(modifiedJson.versions).includes("3.0.0")); + }); + + it("Should handle scoped packages in exclusion list", async () => { + minimumPackageAgeSettings = 5; + skipMinimumPackageAgeSetting = false; + minimumPackageAgeExclusionsSetting = ["@babel/core"]; + + const packageUrl = "https://registry.npmjs.org/@babel/core"; + + const originalBody = JSON.stringify({ + name: "@babel/core", + ["dist-tags"]: { latest: "7.0.0" }, + versions: { ["6.0.0"]: {}, ["7.0.0"]: {} }, + time: { + created: getDate(-365 * 24), + modified: getDate(-1), + ["6.0.0"]: getDate(-100), + ["7.0.0"]: getDate(-1), // Would normally be filtered + }, + }); + + const modifiedBody = await runModifyNpmInfoRequest(packageUrl, originalBody); + const modifiedJson = JSON.parse(modifiedBody); + + // All versions should remain for excluded scoped package + assert.equal(Object.keys(modifiedJson.versions).length, 2); + assert.ok(Object.keys(modifiedJson.versions).includes("6.0.0")); + assert.ok(Object.keys(modifiedJson.versions).includes("7.0.0")); + }); + + it("Should handle multiple packages in exclusion list", async () => { + minimumPackageAgeSettings = 5; + skipMinimumPackageAgeSetting = false; + minimumPackageAgeExclusionsSetting = ["react", "lodash", "@types/node"]; + + const packageUrl = "https://registry.npmjs.org/lodash"; + + const originalBody = JSON.stringify({ + name: "lodash", + ["dist-tags"]: { latest: "2.0.0" }, + versions: { ["1.0.0"]: {}, ["2.0.0"]: {} }, + time: { + created: getDate(-365 * 24), + modified: getDate(-1), + ["1.0.0"]: getDate(-100), + ["2.0.0"]: getDate(-1), + }, + }); + + const modifiedBody = await runModifyNpmInfoRequest(packageUrl, originalBody); + const modifiedJson = JSON.parse(modifiedBody); + + // All versions should remain since lodash is in the exclusion list + assert.equal(Object.keys(modifiedJson.versions).length, 2); + }); + + it("Should reset exclusions between tests", async () => { + minimumPackageAgeSettings = 5; + skipMinimumPackageAgeSetting = false; + minimumPackageAgeExclusionsSetting = []; // Reset to empty + + const packageUrl = "https://registry.npmjs.org/lodash"; + + const modifiedBody = await runModifyNpmInfoRequest( + packageUrl, + JSON.stringify({ + name: "lodash", + ["dist-tags"]: { latest: "2.0.0" }, + versions: { ["1.0.0"]: {}, ["2.0.0"]: {} }, + time: { + created: getDate(-365 * 24), + modified: getDate(-1), + ["1.0.0"]: getDate(-100), + ["2.0.0"]: getDate(-1), + }, + }) + ); + + const modifiedJson = JSON.parse(modifiedBody); + + // Version 2.0.0 should be filtered since exclusions are empty + assert.equal(Object.keys(modifiedJson.versions).length, 1); + assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0")); + }); + function getDate(plusHours) { const date = new Date(); date.setHours(date.getHours() + plusHours); diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js index 88fcbd0..e1b7c79 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js @@ -26,6 +26,7 @@ mock.module("../../../config/settings.js", { setEcoSystem: () => {}, getMinimumPackageAgeHours: () => 24, getNpmCustomRegistries: () => customRegistries, + getNpmMinimumPackageAgeExclusions: () => [], skipMinimumPackageAge: () => false, }, }); From 884cb6e02622f3b3f747e4163ac809ec24eb1eca Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 14 Jan 2026 17:51:41 +0100 Subject: [PATCH 089/360] Allow trailing * for wildcard matching --- README.md | 6 +- .../interceptors/npm/modifyNpmInfo.js | 16 +++- .../npm/npmInterceptor.minPackageAge.spec.js | 81 +++++++++++++++++++ 3 files changed, 99 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index bc61787..290304f 100644 --- a/README.md +++ b/README.md @@ -214,16 +214,16 @@ You can set the minimum package age through multiple sources (in order of priori ### Excluding Packages -Exclude trusted packages from minimum age filtering via environment variable or config file (both are merged): +Exclude trusted packages from minimum age filtering via environment variable or config file (both are merged). Supports wildcard patterns with trailing `*`: ```shell -export SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS="react,@aikidosec/safe-chain" +export SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS="@aikidosec/*,react-*,lodash" ``` ```json { "npm": { - "minimumPackageAgeExclusions": ["react", "@aikidosec/safe-chain"] + "minimumPackageAgeExclusions": ["@aikidosec/*", "react-*", "lodash"] } } ``` diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js index 3407397..9a36207 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js @@ -68,7 +68,7 @@ export function modifyNpmInfoResponse(body, headers) { // Check if this package is excluded from minimum age filtering const packageName = bodyJson.name; const exclusions = getNpmMinimumPackageAgeExclusions(); - if (packageName && exclusions.includes(packageName)) { + if (packageName && exclusions.some((pattern) => matchesExclusionPattern(packageName, pattern))) { ui.writeVerbose( `Safe-chain: ${packageName} is excluded from minimum package age filtering (minimumPackageAgeExclusions setting).` ); @@ -187,3 +187,17 @@ function getMostRecentTag(tagList) { export function getHasSuppressedVersions() { return state.hasSuppressedVersions; } + +/** + * Checks if a package name matches an exclusion pattern. + * Supports trailing wildcard (*) for prefix matching. + * @param {string} packageName + * @param {string} pattern + * @returns {boolean} + */ +function matchesExclusionPattern(packageName, pattern) { + if (pattern.endsWith("*")) { + return packageName.startsWith(pattern.slice(0, -1)); + } + return packageName === pattern; +} diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js index ed00909..82fed71 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js @@ -481,6 +481,87 @@ describe("npmInterceptor minimum package age", async () => { assert.equal(Object.keys(modifiedJson.versions).length, 2); }); + it("Should exclude packages matching wildcard pattern @scope/*", async () => { + minimumPackageAgeSettings = 5; + skipMinimumPackageAgeSetting = false; + minimumPackageAgeExclusionsSetting = ["@aikidosec/*"]; + + const packageUrl = "https://registry.npmjs.org/@aikidosec/safe-chain"; + + const originalBody = JSON.stringify({ + name: "@aikidosec/safe-chain", + ["dist-tags"]: { latest: "2.0.0" }, + versions: { ["1.0.0"]: {}, ["2.0.0"]: {} }, + time: { + created: getDate(-365 * 24), + modified: getDate(-1), + ["1.0.0"]: getDate(-100), + ["2.0.0"]: getDate(-1), // Would normally be filtered + }, + }); + + const modifiedBody = await runModifyNpmInfoRequest(packageUrl, originalBody); + const modifiedJson = JSON.parse(modifiedBody); + + // All versions should remain since @aikidosec/* matches @aikidosec/safe-chain + assert.equal(Object.keys(modifiedJson.versions).length, 2); + assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0")); + assert.ok(Object.keys(modifiedJson.versions).includes("2.0.0")); + }); + + it("Should exclude packages matching wildcard pattern prefix-*", async () => { + minimumPackageAgeSettings = 5; + skipMinimumPackageAgeSetting = false; + minimumPackageAgeExclusionsSetting = ["react-*"]; + + const packageUrl = "https://registry.npmjs.org/react-dom"; + + const originalBody = JSON.stringify({ + name: "react-dom", + ["dist-tags"]: { latest: "18.0.0" }, + versions: { ["17.0.0"]: {}, ["18.0.0"]: {} }, + time: { + created: getDate(-365 * 24), + modified: getDate(-1), + ["17.0.0"]: getDate(-100), + ["18.0.0"]: getDate(-1), // Would normally be filtered + }, + }); + + const modifiedBody = await runModifyNpmInfoRequest(packageUrl, originalBody); + const modifiedJson = JSON.parse(modifiedBody); + + // All versions should remain since react-* matches react-dom + assert.equal(Object.keys(modifiedJson.versions).length, 2); + }); + + it("Should NOT exclude packages that don't match wildcard pattern", async () => { + minimumPackageAgeSettings = 5; + skipMinimumPackageAgeSetting = false; + minimumPackageAgeExclusionsSetting = ["@aikidosec/*"]; + + const packageUrl = "https://registry.npmjs.org/@other/package"; + + const originalBody = JSON.stringify({ + name: "@other/package", + ["dist-tags"]: { latest: "2.0.0" }, + versions: { ["1.0.0"]: {}, ["2.0.0"]: {} }, + time: { + created: getDate(-365 * 24), + modified: getDate(-1), + ["1.0.0"]: getDate(-100), + ["2.0.0"]: getDate(-1), + }, + }); + + const modifiedBody = await runModifyNpmInfoRequest(packageUrl, originalBody); + const modifiedJson = JSON.parse(modifiedBody); + + // Version 2.0.0 should be filtered since @other/package doesn't match @aikidosec/* + assert.equal(Object.keys(modifiedJson.versions).length, 1); + assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0")); + }); + it("Should reset exclusions between tests", async () => { minimumPackageAgeSettings = 5; skipMinimumPackageAgeSetting = false; From 6c814ff82fd7183a5c8a0d33645d6d492fc31151 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 15 Jan 2026 15:13:00 +0100 Subject: [PATCH 090/360] Only allow wildcards for scoped packages (@scope/*) --- README.md | 6 ++--- .../interceptors/npm/modifyNpmInfo.js | 2 +- .../npm/npmInterceptor.minPackageAge.spec.js | 26 ------------------- 3 files changed, 4 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 290304f..128d662 100644 --- a/README.md +++ b/README.md @@ -214,16 +214,16 @@ You can set the minimum package age through multiple sources (in order of priori ### Excluding Packages -Exclude trusted packages from minimum age filtering via environment variable or config file (both are merged). Supports wildcard patterns with trailing `*`: +Exclude trusted packages from minimum age filtering via environment variable or config file (both are merged). Use `@scope/*` to trust all packages from an organization: ```shell -export SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS="@aikidosec/*,react-*,lodash" +export SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS="@aikidosec/*" ``` ```json { "npm": { - "minimumPackageAgeExclusions": ["@aikidosec/*", "react-*", "lodash"] + "minimumPackageAgeExclusions": ["@aikidosec/*"] } } ``` diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js index 9a36207..14e3ba7 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js @@ -196,7 +196,7 @@ export function getHasSuppressedVersions() { * @returns {boolean} */ function matchesExclusionPattern(packageName, pattern) { - if (pattern.endsWith("*")) { + if (pattern.endsWith("/*")) { return packageName.startsWith(pattern.slice(0, -1)); } return packageName === pattern; diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js index 82fed71..834a2ad 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js @@ -509,32 +509,6 @@ describe("npmInterceptor minimum package age", async () => { assert.ok(Object.keys(modifiedJson.versions).includes("2.0.0")); }); - it("Should exclude packages matching wildcard pattern prefix-*", async () => { - minimumPackageAgeSettings = 5; - skipMinimumPackageAgeSetting = false; - minimumPackageAgeExclusionsSetting = ["react-*"]; - - const packageUrl = "https://registry.npmjs.org/react-dom"; - - const originalBody = JSON.stringify({ - name: "react-dom", - ["dist-tags"]: { latest: "18.0.0" }, - versions: { ["17.0.0"]: {}, ["18.0.0"]: {} }, - time: { - created: getDate(-365 * 24), - modified: getDate(-1), - ["17.0.0"]: getDate(-100), - ["18.0.0"]: getDate(-1), // Would normally be filtered - }, - }); - - const modifiedBody = await runModifyNpmInfoRequest(packageUrl, originalBody); - const modifiedJson = JSON.parse(modifiedBody); - - // All versions should remain since react-* matches react-dom - assert.equal(Object.keys(modifiedJson.versions).length, 2); - }); - it("Should NOT exclude packages that don't match wildcard pattern", async () => { minimumPackageAgeSettings = 5; skipMinimumPackageAgeSetting = false; From 879b37e164d0a264a9f129a57adad29232685684 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 19 Jan 2026 12:47:57 +0100 Subject: [PATCH 091/360] Add ultimate installer for Windows --- packages/safe-chain/bin/safe-chain.js | 32 +++--- .../src/installation/installUltimate.js | 101 ++++++++++++++++++ 2 files changed, 121 insertions(+), 12 deletions(-) create mode 100644 packages/safe-chain/src/installation/installUltimate.js diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index 841ccee..e33ad9f 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -16,6 +16,7 @@ import path from "path"; import { fileURLToPath } from "url"; import fs from "fs"; import { knownAikidoTools } from "../src/shell-integration/helpers.js"; +import { installUltimate } from "../src/installation/installUltimate.js"; /** @type {string} */ // This checks the current file's dirname in a way that's compatible with: @@ -62,6 +63,8 @@ if (tool) { process.exit(0); } else if (command === "setup") { setup(); +} else if (command === "--ultimate") { + installUltimate(); } else if (command === "teardown") { teardownDirectories(); teardown(); @@ -82,36 +85,41 @@ if (tool) { function writeHelp() { ui.writeInformation( - chalk.bold("Usage: ") + chalk.cyan("safe-chain ") + chalk.bold("Usage: ") + chalk.cyan("safe-chain "), ); ui.emptyLine(); ui.writeInformation( `Available commands: ${chalk.cyan("setup")}, ${chalk.cyan( - "teardown" + "teardown", )}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("help")}, ${chalk.cyan( - "--version" - )}` + "--version", + )}`, ); ui.emptyLine(); ui.writeInformation( `- ${chalk.cyan( - "safe-chain setup" - )}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, bun, bunx, pip and pip3.` + "safe-chain setup", + )}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, bun, bunx, pip and pip3.`, ); ui.writeInformation( `- ${chalk.cyan( - "safe-chain teardown" - )}: This will remove safe-chain aliases from your shell configuration.` + "safe-chain --ultimate", + )}: This installs the ultimate version of safe-chain, enabling protection for more eco-systems (vscode).`, ); ui.writeInformation( `- ${chalk.cyan( - "safe-chain setup-ci" - )}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.` + "safe-chain teardown", + )}: This will remove safe-chain aliases from your shell configuration.`, + ); + ui.writeInformation( + `- ${chalk.cyan( + "safe-chain setup-ci", + )}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.`, ); ui.writeInformation( `- ${chalk.cyan("safe-chain --version")} (or ${chalk.cyan( - "-v" - )}): Display the current version of safe-chain.` + "-v", + )}): Display the current version of safe-chain.`, ); ui.emptyLine(); } diff --git a/packages/safe-chain/src/installation/installUltimate.js b/packages/safe-chain/src/installation/installUltimate.js new file mode 100644 index 0000000..e1323db --- /dev/null +++ b/packages/safe-chain/src/installation/installUltimate.js @@ -0,0 +1,101 @@ +import { platform, arch, tmpdir } from "os"; +import { createWriteStream, unlinkSync } from "fs"; +import { join } from "path"; +import { execSync } from "child_process"; +import { pipeline } from "stream/promises"; +import fetch from "make-fetch-happen"; +import { ui } from "../environment/userInteraction.js"; + +const ULTIMATE_VERSION = "v0.2.0"; + +export function installUltimate() { + const operatingSystem = platform(); + + if (operatingSystem === "win32") { + installOnWindows(); + } else { + ui.writeInformation( + `${operatingSystem} is not supported yet by safe-chain's ultimate version.`, + ); + } +} + +async function installOnWindows() { + if (!isRunningAsAdmin()) { + ui.writeError("Administrator privileges required."); + ui.writeInformation( + "Please run this command in an elevated terminal (Run as Administrator)." + ); + return; + } + + const architecture = getWindowsArchitecture(); + const downloadUrl = buildDownloadUrl(architecture); + const msiPath = join(tmpdir(), `SafeChainAgent-${Date.now()}.msi`); + + ui.writeInformation(`Downloading SafeChain Agent ${ULTIMATE_VERSION} for ${architecture}...`); + ui.writeVerbose(`Download URL: ${downloadUrl}`); + ui.writeVerbose(`Destination: ${msiPath}`); + await downloadFile(downloadUrl, msiPath); + + ui.writeInformation("Installing SafeChain Agent..."); + ui.writeVerbose(`Running: msiexec /i "${msiPath}" /qn`); + runMsiInstaller(msiPath); + + ui.writeVerbose(`Cleaning up temporary file: ${msiPath}`); + cleanup(msiPath); + ui.writeInformation("SafeChain Agent installed successfully!"); +} + +function isRunningAsAdmin() { + try { + execSync("net session", { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +function getWindowsArchitecture() { + const nodeArch = arch(); + if (nodeArch === "x64") return "amd64"; + if (nodeArch === "arm64") return "arm64"; + throw new Error(`Unsupported architecture: ${nodeArch}`); +} + +/** + * @param {string} architecture + */ +function buildDownloadUrl(architecture) { + return `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainAgent-windows-${architecture}.msi`; +} + +/** + * @param {string} url + * @param {string} destPath + */ +async function downloadFile(url, destPath) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Download failed: ${response.statusText}`); + } + await pipeline(response.body, createWriteStream(destPath)); +} + +/** + * @param {string} msiPath + */ +function runMsiInstaller(msiPath) { + execSync(`msiexec /i "${msiPath}" /qn`, { stdio: "inherit" }); +} + +/** + * @param {string} msiPath + */ +function cleanup(msiPath) { + try { + unlinkSync(msiPath); + } catch { + // Ignore cleanup errors + } +} From 2c0245b020ecbed7ee4c28212d7653970458e288 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 19 Jan 2026 13:28:16 +0100 Subject: [PATCH 092/360] Start and stop safe-chain agent's Windows service. --- .../src/installation/installUltimate.js | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/safe-chain/src/installation/installUltimate.js b/packages/safe-chain/src/installation/installUltimate.js index e1323db..a638407 100644 --- a/packages/safe-chain/src/installation/installUltimate.js +++ b/packages/safe-chain/src/installation/installUltimate.js @@ -38,13 +38,18 @@ async function installOnWindows() { ui.writeVerbose(`Destination: ${msiPath}`); await downloadFile(downloadUrl, msiPath); + stopServiceIfRunning(); + ui.writeInformation("Installing SafeChain Agent..."); ui.writeVerbose(`Running: msiexec /i "${msiPath}" /qn`); runMsiInstaller(msiPath); + ui.writeInformation("Starting SafeChain Agent service..."); + startService(); + ui.writeVerbose(`Cleaning up temporary file: ${msiPath}`); cleanup(msiPath); - ui.writeInformation("SafeChain Agent installed successfully!"); + ui.writeInformation("SafeChain Agent installed and started successfully!"); } function isRunningAsAdmin() { @@ -89,6 +94,22 @@ function runMsiInstaller(msiPath) { execSync(`msiexec /i "${msiPath}" /qn`, { stdio: "inherit" }); } +function stopServiceIfRunning() { + try { + ui.writeInformation("Stopping existing SafeChain Agent service..."); + ui.writeVerbose('Running: net stop "SafeChainAgent"'); + execSync('net stop "SafeChainAgent"', { stdio: "inherit" }); + } catch { + // Service is not running or doesn't exist, which is fine + ui.writeVerbose("SafeChain Agent service not running or not installed."); + } +} + +function startService() { + ui.writeVerbose('Running: net start "SafeChainAgent"'); + execSync('net start "SafeChainAgent"', { stdio: "inherit" }); +} + /** * @param {string} msiPath */ From 6a3c7b938b9ac12d35924c30cf4c2159c901a7a8 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 19 Jan 2026 13:48:33 +0100 Subject: [PATCH 093/360] Overwrite the agent if it's already installed. --- packages/safe-chain/src/installation/installUltimate.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/safe-chain/src/installation/installUltimate.js b/packages/safe-chain/src/installation/installUltimate.js index a638407..5d38eb0 100644 --- a/packages/safe-chain/src/installation/installUltimate.js +++ b/packages/safe-chain/src/installation/installUltimate.js @@ -41,7 +41,7 @@ async function installOnWindows() { stopServiceIfRunning(); ui.writeInformation("Installing SafeChain Agent..."); - ui.writeVerbose(`Running: msiexec /i "${msiPath}" /qn`); + ui.writeVerbose(`Running: msiexec /i "${msiPath}" /qn REINSTALL=ALL REINSTALLMODE=vomus`); runMsiInstaller(msiPath); ui.writeInformation("Starting SafeChain Agent service..."); @@ -91,7 +91,10 @@ async function downloadFile(url, destPath) { * @param {string} msiPath */ function runMsiInstaller(msiPath) { - execSync(`msiexec /i "${msiPath}" /qn`, { stdio: "inherit" }); + // Use /i for install/upgrade with REINSTALL=ALL REINSTALLMODE=vomus + // This forces a reinstall of all features if the product is already installed + // /qn = quiet mode (no UI) + execSync(`msiexec /i "${msiPath}" /qn REINSTALL=ALL REINSTALLMODE=vomus`, { stdio: "inherit" }); } function stopServiceIfRunning() { From 4851e582f69a89ddc6ae24420002105cd5b7f467 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 19 Jan 2026 13:54:32 +0100 Subject: [PATCH 094/360] Improve updating existing agent install --- .../src/installation/installUltimate.js | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/safe-chain/src/installation/installUltimate.js b/packages/safe-chain/src/installation/installUltimate.js index 5d38eb0..1860b80 100644 --- a/packages/safe-chain/src/installation/installUltimate.js +++ b/packages/safe-chain/src/installation/installUltimate.js @@ -24,7 +24,7 @@ async function installOnWindows() { if (!isRunningAsAdmin()) { ui.writeError("Administrator privileges required."); ui.writeInformation( - "Please run this command in an elevated terminal (Run as Administrator)." + "Please run this command in an elevated terminal (Run as Administrator).", ); return; } @@ -33,15 +33,20 @@ async function installOnWindows() { const downloadUrl = buildDownloadUrl(architecture); const msiPath = join(tmpdir(), `SafeChainAgent-${Date.now()}.msi`); - ui.writeInformation(`Downloading SafeChain Agent ${ULTIMATE_VERSION} for ${architecture}...`); + ui.writeInformation( + `Downloading SafeChain Agent ${ULTIMATE_VERSION} for ${architecture}...`, + ); ui.writeVerbose(`Download URL: ${downloadUrl}`); ui.writeVerbose(`Destination: ${msiPath}`); await downloadFile(downloadUrl, msiPath); stopServiceIfRunning(); + // Wait a moment for the service to fully stop before installing + await new Promise((resolve) => setTimeout(resolve, 10000)); + ui.writeInformation("Installing SafeChain Agent..."); - ui.writeVerbose(`Running: msiexec /i "${msiPath}" /qn REINSTALL=ALL REINSTALLMODE=vomus`); + ui.writeVerbose(`Running: msiexec /i "${msiPath}" /qn /norestart`); runMsiInstaller(msiPath); ui.writeInformation("Starting SafeChain Agent service..."); @@ -91,10 +96,23 @@ async function downloadFile(url, destPath) { * @param {string} msiPath */ function runMsiInstaller(msiPath) { - // Use /i for install/upgrade with REINSTALL=ALL REINSTALLMODE=vomus - // This forces a reinstall of all features if the product is already installed + // Try to install/upgrade + // /i = install (will upgrade if product code matches) // /qn = quiet mode (no UI) - execSync(`msiexec /i "${msiPath}" /qn REINSTALL=ALL REINSTALLMODE=vomus`, { stdio: "inherit" }); + // /norestart = suppress restarts + try { + execSync(`msiexec /i "${msiPath}" /qn /norestart`, { stdio: "inherit" }); + } catch (error) { + // If installation fails, it might be because it's already installed + // Try to force a reinstall + ui.writeVerbose( + "Initial installation failed, attempting to force reinstall...", + ); + execSync( + `msiexec /i "${msiPath}" /qn /norestart REINSTALL=ALL REINSTALLMODE=vomus`, + { stdio: "inherit" }, + ); + } } function stopServiceIfRunning() { From c4941e25ed5ca304e684b921a8e20590c81f8486 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 19 Jan 2026 13:55:41 +0100 Subject: [PATCH 095/360] Fix linting --- packages/safe-chain/src/installation/installUltimate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/installation/installUltimate.js b/packages/safe-chain/src/installation/installUltimate.js index 1860b80..278aab4 100644 --- a/packages/safe-chain/src/installation/installUltimate.js +++ b/packages/safe-chain/src/installation/installUltimate.js @@ -102,7 +102,7 @@ function runMsiInstaller(msiPath) { // /norestart = suppress restarts try { execSync(`msiexec /i "${msiPath}" /qn /norestart`, { stdio: "inherit" }); - } catch (error) { + } catch { // If installation fails, it might be because it's already installed // Try to force a reinstall ui.writeVerbose( From 673783ceabffac02ec684e3f661b1e90672669f6 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 19 Jan 2026 14:00:09 +0100 Subject: [PATCH 096/360] Uninstall safe-chain agent if it's there, before re-installing --- .../src/installation/installUltimate.js | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/packages/safe-chain/src/installation/installUltimate.js b/packages/safe-chain/src/installation/installUltimate.js index 278aab4..6cb3f46 100644 --- a/packages/safe-chain/src/installation/installUltimate.js +++ b/packages/safe-chain/src/installation/installUltimate.js @@ -41,9 +41,10 @@ async function installOnWindows() { await downloadFile(downloadUrl, msiPath); stopServiceIfRunning(); + uninstallIfInstalled(); - // Wait a moment for the service to fully stop before installing - await new Promise((resolve) => setTimeout(resolve, 10000)); + // Wait a moment for uninstall to complete + await new Promise((resolve) => setTimeout(resolve, 2000)); ui.writeInformation("Installing SafeChain Agent..."); ui.writeVerbose(`Running: msiexec /i "${msiPath}" /qn /norestart`); @@ -92,27 +93,25 @@ async function downloadFile(url, destPath) { await pipeline(response.body, createWriteStream(destPath)); } +function uninstallIfInstalled() { + try { + ui.writeInformation("Uninstalling existing SafeChain Agent..."); + ui.writeVerbose('Running: wmic product where "name=\'SafeChain Agent\'" call uninstall /nointeractive'); + execSync('wmic product where "name=\'SafeChain Agent\'" call uninstall /nointeractive', { stdio: "inherit" }); + } catch { + // Not installed or uninstall failed, which is fine for a fresh install + ui.writeVerbose("No existing SafeChain Agent installation found."); + } +} + /** * @param {string} msiPath */ function runMsiInstaller(msiPath) { - // Try to install/upgrade - // /i = install (will upgrade if product code matches) + // /i = install // /qn = quiet mode (no UI) // /norestart = suppress restarts - try { - execSync(`msiexec /i "${msiPath}" /qn /norestart`, { stdio: "inherit" }); - } catch { - // If installation fails, it might be because it's already installed - // Try to force a reinstall - ui.writeVerbose( - "Initial installation failed, attempting to force reinstall...", - ); - execSync( - `msiexec /i "${msiPath}" /qn /norestart REINSTALL=ALL REINSTALLMODE=vomus`, - { stdio: "inherit" }, - ); - } + execSync(`msiexec /i "${msiPath}" /qn /norestart`, { stdio: "inherit" }); } function stopServiceIfRunning() { From 3958fcfcefeaca7d9e4a215b54b433da02d4178d Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 19 Jan 2026 14:06:43 +0100 Subject: [PATCH 097/360] Parse cli args in ultimate installation --- .../src/installation/installUltimate.js | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/safe-chain/src/installation/installUltimate.js b/packages/safe-chain/src/installation/installUltimate.js index 6cb3f46..fc2b93f 100644 --- a/packages/safe-chain/src/installation/installUltimate.js +++ b/packages/safe-chain/src/installation/installUltimate.js @@ -5,10 +5,13 @@ import { execSync } from "child_process"; import { pipeline } from "stream/promises"; import fetch from "make-fetch-happen"; import { ui } from "../environment/userInteraction.js"; +import { initializeCliArguments } from "../config/cliArguments.js"; const ULTIMATE_VERSION = "v0.2.0"; export function installUltimate() { + initializeCliArguments(process.argv); + const operatingSystem = platform(); if (operatingSystem === "win32") { @@ -96,8 +99,25 @@ async function downloadFile(url, destPath) { function uninstallIfInstalled() { try { ui.writeInformation("Uninstalling existing SafeChain Agent..."); - ui.writeVerbose('Running: wmic product where "name=\'SafeChain Agent\'" call uninstall /nointeractive'); - execSync('wmic product where "name=\'SafeChain Agent\'" call uninstall /nointeractive', { stdio: "inherit" }); + + // Use PowerShell to find the product code, then use msiexec to uninstall + // This is the modern alternative to wmic which is deprecated + const findProductCodeCmd = `powershell -Command "$app = Get-WmiObject -Class Win32_Product -Filter \\"Name='SafeChain Agent'\\"; if ($app) { Write-Output $app.IdentifyingNumber }"`; + ui.writeVerbose(`Finding product code: ${findProductCodeCmd}`); + + const productCode = execSync(findProductCodeCmd, { + encoding: "utf8", + }).trim(); + + if (productCode) { + ui.writeVerbose(`Found product code: ${productCode}`); + ui.writeVerbose(`Running: msiexec /x ${productCode} /qn /norestart`); + execSync(`msiexec /x ${productCode} /qn /norestart`, { + stdio: "inherit", + }); + } else { + ui.writeVerbose("No existing SafeChain Agent installation found."); + } } catch { // Not installed or uninstall failed, which is fine for a fresh install ui.writeVerbose("No existing SafeChain Agent installation found."); From 2784dfd34e89b87f92eb3ed683a821c299adbfb0 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 19 Jan 2026 14:23:15 +0100 Subject: [PATCH 098/360] Check if the agents service is running before starting it --- .../safe-chain/src/installation/installUltimate.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/safe-chain/src/installation/installUltimate.js b/packages/safe-chain/src/installation/installUltimate.js index fc2b93f..dd8de84 100644 --- a/packages/safe-chain/src/installation/installUltimate.js +++ b/packages/safe-chain/src/installation/installUltimate.js @@ -146,6 +146,19 @@ function stopServiceIfRunning() { } function startService() { + try { + // Check if service is already running + ui.writeVerbose('Checking service status: sc query "SafeChainAgent"'); + const status = execSync('sc query "SafeChainAgent"', { encoding: "utf8" }); + + if (status.includes("RUNNING")) { + ui.writeVerbose("SafeChain Agent service is already running."); + return; + } + } catch { + // Service might not exist yet or query failed, proceed with start + } + ui.writeVerbose('Running: net start "SafeChainAgent"'); execSync('net start "SafeChainAgent"', { stdio: "inherit" }); } From 0e7cce750d7ddfaaef20f00ea3bbc595573163d3 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 19 Jan 2026 14:30:09 +0100 Subject: [PATCH 099/360] Improve output --- .../src/installation/installUltimate.js | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/safe-chain/src/installation/installUltimate.js b/packages/safe-chain/src/installation/installUltimate.js index dd8de84..d1ccf28 100644 --- a/packages/safe-chain/src/installation/installUltimate.js +++ b/packages/safe-chain/src/installation/installUltimate.js @@ -36,29 +36,35 @@ async function installOnWindows() { const downloadUrl = buildDownloadUrl(architecture); const msiPath = join(tmpdir(), `SafeChainAgent-${Date.now()}.msi`); + ui.emptyLine(); ui.writeInformation( - `Downloading SafeChain Agent ${ULTIMATE_VERSION} for ${architecture}...`, + `📥 Downloading SafeChain Agent ${ULTIMATE_VERSION} (${architecture})...`, ); ui.writeVerbose(`Download URL: ${downloadUrl}`); ui.writeVerbose(`Destination: ${msiPath}`); await downloadFile(downloadUrl, msiPath); + ui.emptyLine(); stopServiceIfRunning(); uninstallIfInstalled(); // Wait a moment for uninstall to complete await new Promise((resolve) => setTimeout(resolve, 2000)); - ui.writeInformation("Installing SafeChain Agent..."); + ui.writeInformation("⚙️ Installing SafeChain Agent..."); ui.writeVerbose(`Running: msiexec /i "${msiPath}" /qn /norestart`); runMsiInstaller(msiPath); - ui.writeInformation("Starting SafeChain Agent service..."); + ui.emptyLine(); + ui.writeInformation("🚀 Starting SafeChain Agent service..."); startService(); ui.writeVerbose(`Cleaning up temporary file: ${msiPath}`); cleanup(msiPath); - ui.writeInformation("SafeChain Agent installed and started successfully!"); + + ui.emptyLine(); + ui.writeInformation("✅ SafeChain Agent installed and started successfully!"); + ui.emptyLine(); } function isRunningAsAdmin() { @@ -98,8 +104,6 @@ async function downloadFile(url, destPath) { function uninstallIfInstalled() { try { - ui.writeInformation("Uninstalling existing SafeChain Agent..."); - // Use PowerShell to find the product code, then use msiexec to uninstall // This is the modern alternative to wmic which is deprecated const findProductCodeCmd = `powershell -Command "$app = Get-WmiObject -Class Win32_Product -Filter \\"Name='SafeChain Agent'\\"; if ($app) { Write-Output $app.IdentifyingNumber }"`; @@ -110,17 +114,18 @@ function uninstallIfInstalled() { }).trim(); if (productCode) { + ui.writeInformation("🗑️ Removing previous installation..."); ui.writeVerbose(`Found product code: ${productCode}`); ui.writeVerbose(`Running: msiexec /x ${productCode} /qn /norestart`); execSync(`msiexec /x ${productCode} /qn /norestart`, { stdio: "inherit", }); } else { - ui.writeVerbose("No existing SafeChain Agent installation found."); + ui.writeVerbose("No existing installation found (fresh install)."); } } catch { // Not installed or uninstall failed, which is fine for a fresh install - ui.writeVerbose("No existing SafeChain Agent installation found."); + ui.writeVerbose("No existing installation found (fresh install)."); } } @@ -136,12 +141,12 @@ function runMsiInstaller(msiPath) { function stopServiceIfRunning() { try { - ui.writeInformation("Stopping existing SafeChain Agent service..."); + ui.writeInformation("⏹️ Stopping running service..."); ui.writeVerbose('Running: net stop "SafeChainAgent"'); execSync('net stop "SafeChainAgent"', { stdio: "inherit" }); } catch { // Service is not running or doesn't exist, which is fine - ui.writeVerbose("SafeChain Agent service not running or not installed."); + ui.writeVerbose("Service not running (will start after installation)."); } } From fd559cfc63779037152dc892fdcb34d70d42f4a3 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 19 Jan 2026 14:46:04 +0100 Subject: [PATCH 100/360] Restructure code into separate files --- .../src/installation/downloadAgent.js | 40 +++++ .../src/installation/installOnWindows.js | 146 +++++++++++++++ .../src/installation/installUltimate.js | 166 +----------------- packages/safe-chain/src/main.js | 10 +- 4 files changed, 193 insertions(+), 169 deletions(-) create mode 100644 packages/safe-chain/src/installation/downloadAgent.js create mode 100644 packages/safe-chain/src/installation/installOnWindows.js diff --git a/packages/safe-chain/src/installation/downloadAgent.js b/packages/safe-chain/src/installation/downloadAgent.js new file mode 100644 index 0000000..2e45b79 --- /dev/null +++ b/packages/safe-chain/src/installation/downloadAgent.js @@ -0,0 +1,40 @@ +import { createWriteStream } from "fs"; +import { pipeline } from "stream/promises"; +import fetch from "make-fetch-happen"; + +const ULTIMATE_VERSION = "v0.2.0"; + +/** + * @typedef {"windows"} Platform + * @typedef {"amd64" | "arm64"} Architecture + */ + +/** + * Builds the download URL for the SafeChain Agent installer. + * @param {Platform} platform + * @param {Architecture} architecture + */ +export function getAgentDownloadUrl(platform, architecture) { + const extension = platform === "windows" ? "msi" : "pkg"; + return `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainAgent-${platform}-${architecture}.${extension}`; +} + +/** + * Downloads a file from a URL to a local path. + * @param {string} url + * @param {string} destPath + */ +export async function downloadFile(url, destPath) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Download failed: ${response.statusText}`); + } + await pipeline(response.body, createWriteStream(destPath)); +} + +/** + * Returns the current agent version. + */ +export function getAgentVersion() { + return ULTIMATE_VERSION; +} diff --git a/packages/safe-chain/src/installation/installOnWindows.js b/packages/safe-chain/src/installation/installOnWindows.js new file mode 100644 index 0000000..27db104 --- /dev/null +++ b/packages/safe-chain/src/installation/installOnWindows.js @@ -0,0 +1,146 @@ +import { arch, tmpdir } from "os"; +import { unlinkSync } from "fs"; +import { join } from "path"; +import { execSync } from "child_process"; +import { ui } from "../environment/userInteraction.js"; +import { + getAgentDownloadUrl, + getAgentVersion, + downloadFile, +} from "./downloadAgent.js"; + +export async function installOnWindows() { + if (!isRunningAsAdmin()) { + ui.writeError("Administrator privileges required."); + ui.writeInformation( + "Please run this command in an elevated terminal (Run as Administrator).", + ); + return; + } + + const architecture = getWindowsArchitecture(); + const downloadUrl = getAgentDownloadUrl("windows", architecture); + const msiPath = join(tmpdir(), `SafeChainAgent-${Date.now()}.msi`); + + ui.emptyLine(); + ui.writeInformation( + `📥 Downloading SafeChain Agent ${getAgentVersion()} (${architecture})...`, + ); + ui.writeVerbose(`Download URL: ${downloadUrl}`); + ui.writeVerbose(`Destination: ${msiPath}`); + await downloadFile(downloadUrl, msiPath); + + ui.emptyLine(); + stopServiceIfRunning(); + uninstallIfInstalled(); + + // Wait a moment for uninstall to complete + await new Promise((resolve) => setTimeout(resolve, 2000)); + + ui.writeInformation("⚙️ Installing SafeChain Agent..."); + runMsiInstaller(msiPath); + + ui.emptyLine(); + ui.writeInformation("🚀 Starting SafeChain Agent service..."); + startService(); + + ui.writeVerbose(`Cleaning up temporary file: ${msiPath}`); + cleanup(msiPath); + + ui.emptyLine(); + ui.writeInformation("✅ SafeChain Agent installed and started successfully!"); + ui.emptyLine(); +} + +function isRunningAsAdmin() { + try { + execSync("net session", { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +function getWindowsArchitecture() { + const nodeArch = arch(); + if (nodeArch === "x64") return "amd64"; + if (nodeArch === "arm64") return "arm64"; + throw new Error(`Unsupported architecture: ${nodeArch}`); +} + +function uninstallIfInstalled() { + try { + // Use PowerShell to find the product code, then use msiexec to uninstall + // This is the modern alternative to wmic which is deprecated + const findProductCodeCmd = `powershell -Command "$app = Get-WmiObject -Class Win32_Product -Filter \\"Name='SafeChain Agent'\\"; if ($app) { Write-Output $app.IdentifyingNumber }"`; + ui.writeVerbose(`Finding product code: ${findProductCodeCmd}`); + + const productCode = execSync(findProductCodeCmd, { + encoding: "utf8", + }).trim(); + + if (productCode) { + ui.writeInformation("🗑️ Removing previous installation..."); + ui.writeVerbose(`Found product code: ${productCode}`); + ui.writeVerbose(`Running: msiexec /x ${productCode} /qn /norestart`); + execSync(`msiexec /x ${productCode} /qn /norestart`, { + stdio: "inherit", + }); + } else { + ui.writeVerbose("No existing installation found (fresh install)."); + } + } catch { + // Not installed or uninstall failed, which is fine for a fresh install + ui.writeVerbose("No existing installation found (fresh install)."); + } +} + +/** + * @param {string} msiPath + */ +function runMsiInstaller(msiPath) { + // /i = install + // /qn = quiet mode (no UI) + ui.writeVerbose(`Running: msiexec /i "${msiPath}" /qn`); + execSync(`msiexec /i "${msiPath}" /qn`, { stdio: "inherit" }); +} + +function stopServiceIfRunning() { + try { + ui.writeInformation("⏹️ Stopping running service..."); + ui.writeVerbose('Running: net stop "SafeChainAgent"'); + execSync('net stop "SafeChainAgent"', { stdio: "inherit" }); + } catch { + // Service is not running or doesn't exist, which is fine + ui.writeVerbose("Service not running (will start after installation)."); + } +} + +function startService() { + try { + // Check if service is already running + ui.writeVerbose('Checking service status: sc query "SafeChainAgent"'); + const status = execSync('sc query "SafeChainAgent"', { encoding: "utf8" }); + + if (status.includes("RUNNING")) { + ui.writeVerbose("SafeChain Agent service is already running."); + return; + } + } catch { + // Service might not exist yet or query failed, proceed with start + } + + ui.writeVerbose('Running: net start "SafeChainAgent"'); + execSync('net start "SafeChainAgent"', { stdio: "inherit" }); +} + +/** + * @param {string} msiPath + */ +function cleanup(msiPath) { + try { + unlinkSync(msiPath); + } catch { + // Ignore cleanup errors + } +} diff --git a/packages/safe-chain/src/installation/installUltimate.js b/packages/safe-chain/src/installation/installUltimate.js index d1ccf28..7383d2c 100644 --- a/packages/safe-chain/src/installation/installUltimate.js +++ b/packages/safe-chain/src/installation/installUltimate.js @@ -1,13 +1,7 @@ -import { platform, arch, tmpdir } from "os"; -import { createWriteStream, unlinkSync } from "fs"; -import { join } from "path"; -import { execSync } from "child_process"; -import { pipeline } from "stream/promises"; -import fetch from "make-fetch-happen"; +import { platform } from "os"; import { ui } from "../environment/userInteraction.js"; import { initializeCliArguments } from "../config/cliArguments.js"; - -const ULTIMATE_VERSION = "v0.2.0"; +import { installOnWindows } from "./installOnWindows.js"; export function installUltimate() { initializeCliArguments(process.argv); @@ -22,159 +16,3 @@ export function installUltimate() { ); } } - -async function installOnWindows() { - if (!isRunningAsAdmin()) { - ui.writeError("Administrator privileges required."); - ui.writeInformation( - "Please run this command in an elevated terminal (Run as Administrator).", - ); - return; - } - - const architecture = getWindowsArchitecture(); - const downloadUrl = buildDownloadUrl(architecture); - const msiPath = join(tmpdir(), `SafeChainAgent-${Date.now()}.msi`); - - ui.emptyLine(); - ui.writeInformation( - `📥 Downloading SafeChain Agent ${ULTIMATE_VERSION} (${architecture})...`, - ); - ui.writeVerbose(`Download URL: ${downloadUrl}`); - ui.writeVerbose(`Destination: ${msiPath}`); - await downloadFile(downloadUrl, msiPath); - - ui.emptyLine(); - stopServiceIfRunning(); - uninstallIfInstalled(); - - // Wait a moment for uninstall to complete - await new Promise((resolve) => setTimeout(resolve, 2000)); - - ui.writeInformation("⚙️ Installing SafeChain Agent..."); - ui.writeVerbose(`Running: msiexec /i "${msiPath}" /qn /norestart`); - runMsiInstaller(msiPath); - - ui.emptyLine(); - ui.writeInformation("🚀 Starting SafeChain Agent service..."); - startService(); - - ui.writeVerbose(`Cleaning up temporary file: ${msiPath}`); - cleanup(msiPath); - - ui.emptyLine(); - ui.writeInformation("✅ SafeChain Agent installed and started successfully!"); - ui.emptyLine(); -} - -function isRunningAsAdmin() { - try { - execSync("net session", { stdio: "ignore" }); - return true; - } catch { - return false; - } -} - -function getWindowsArchitecture() { - const nodeArch = arch(); - if (nodeArch === "x64") return "amd64"; - if (nodeArch === "arm64") return "arm64"; - throw new Error(`Unsupported architecture: ${nodeArch}`); -} - -/** - * @param {string} architecture - */ -function buildDownloadUrl(architecture) { - return `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainAgent-windows-${architecture}.msi`; -} - -/** - * @param {string} url - * @param {string} destPath - */ -async function downloadFile(url, destPath) { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Download failed: ${response.statusText}`); - } - await pipeline(response.body, createWriteStream(destPath)); -} - -function uninstallIfInstalled() { - try { - // Use PowerShell to find the product code, then use msiexec to uninstall - // This is the modern alternative to wmic which is deprecated - const findProductCodeCmd = `powershell -Command "$app = Get-WmiObject -Class Win32_Product -Filter \\"Name='SafeChain Agent'\\"; if ($app) { Write-Output $app.IdentifyingNumber }"`; - ui.writeVerbose(`Finding product code: ${findProductCodeCmd}`); - - const productCode = execSync(findProductCodeCmd, { - encoding: "utf8", - }).trim(); - - if (productCode) { - ui.writeInformation("🗑️ Removing previous installation..."); - ui.writeVerbose(`Found product code: ${productCode}`); - ui.writeVerbose(`Running: msiexec /x ${productCode} /qn /norestart`); - execSync(`msiexec /x ${productCode} /qn /norestart`, { - stdio: "inherit", - }); - } else { - ui.writeVerbose("No existing installation found (fresh install)."); - } - } catch { - // Not installed or uninstall failed, which is fine for a fresh install - ui.writeVerbose("No existing installation found (fresh install)."); - } -} - -/** - * @param {string} msiPath - */ -function runMsiInstaller(msiPath) { - // /i = install - // /qn = quiet mode (no UI) - // /norestart = suppress restarts - execSync(`msiexec /i "${msiPath}" /qn /norestart`, { stdio: "inherit" }); -} - -function stopServiceIfRunning() { - try { - ui.writeInformation("⏹️ Stopping running service..."); - ui.writeVerbose('Running: net stop "SafeChainAgent"'); - execSync('net stop "SafeChainAgent"', { stdio: "inherit" }); - } catch { - // Service is not running or doesn't exist, which is fine - ui.writeVerbose("Service not running (will start after installation)."); - } -} - -function startService() { - try { - // Check if service is already running - ui.writeVerbose('Checking service status: sc query "SafeChainAgent"'); - const status = execSync('sc query "SafeChainAgent"', { encoding: "utf8" }); - - if (status.includes("RUNNING")) { - ui.writeVerbose("SafeChain Agent service is already running."); - return; - } - } catch { - // Service might not exist yet or query failed, proceed with start - } - - ui.writeVerbose('Running: net start "SafeChainAgent"'); - execSync('net start "SafeChainAgent"', { stdio: "inherit" }); -} - -/** - * @param {string} msiPath - */ -function cleanup(msiPath) { - try { - unlinkSync(msiPath); - } catch { - // Ignore cleanup errors - } -} diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index 9b7ba53..0b37eba 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -73,20 +73,20 @@ export async function main(args) { ui.writeVerbose( `${chalk.green("✔")} Safe-chain: Scanned ${ auditStats.totalPackages - } packages, no malware found.` + } packages, no malware found.`, ); } if (proxy.hasSuppressedVersions()) { ui.writeInformation( `${chalk.yellow( - "ℹ" - )} Safe-chain: Some package versions were suppressed due to minimum age requirement.` + "ℹ", + )} Safe-chain: Some package versions were suppressed due to minimum age requirement.`, ); ui.writeInformation( ` To disable this check, use: ${chalk.cyan( - "--safe-chain-skip-minimum-package-age" - )}` + "--safe-chain-skip-minimum-package-age", + )}`, ); } From 079e4893b1e55d43f8c772d9246588bb5184f7a2 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 19 Jan 2026 14:53:33 +0100 Subject: [PATCH 101/360] Move download name construction to os installer function --- .../safe-chain/src/installation/downloadAgent.js | 13 +++---------- .../safe-chain/src/installation/installOnWindows.js | 3 ++- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/safe-chain/src/installation/downloadAgent.js b/packages/safe-chain/src/installation/downloadAgent.js index 2e45b79..d74cbf7 100644 --- a/packages/safe-chain/src/installation/downloadAgent.js +++ b/packages/safe-chain/src/installation/downloadAgent.js @@ -4,19 +4,12 @@ import fetch from "make-fetch-happen"; const ULTIMATE_VERSION = "v0.2.0"; -/** - * @typedef {"windows"} Platform - * @typedef {"amd64" | "arm64"} Architecture - */ - /** * Builds the download URL for the SafeChain Agent installer. - * @param {Platform} platform - * @param {Architecture} architecture + * @param {string} fileName */ -export function getAgentDownloadUrl(platform, architecture) { - const extension = platform === "windows" ? "msi" : "pkg"; - return `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainAgent-${platform}-${architecture}.${extension}`; +export function getAgentDownloadUrl(fileName) { + return `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/${fileName}`; } /** diff --git a/packages/safe-chain/src/installation/installOnWindows.js b/packages/safe-chain/src/installation/installOnWindows.js index 27db104..9f92893 100644 --- a/packages/safe-chain/src/installation/installOnWindows.js +++ b/packages/safe-chain/src/installation/installOnWindows.js @@ -19,7 +19,8 @@ export async function installOnWindows() { } const architecture = getWindowsArchitecture(); - const downloadUrl = getAgentDownloadUrl("windows", architecture); + const fileName = `SafeChainAgent-windows-${architecture}.msi`; + const downloadUrl = getAgentDownloadUrl(fileName); const msiPath = join(tmpdir(), `SafeChainAgent-${Date.now()}.msi`); ui.emptyLine(); From 471ef2821015309ecdd82c8400f27a33a7d52793 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 19 Jan 2026 15:22:24 +0100 Subject: [PATCH 102/360] Handle code quality comments --- packages/safe-chain/bin/safe-chain.js | 4 +++- packages/safe-chain/src/installation/installOnWindows.js | 4 ++-- packages/safe-chain/src/installation/installUltimate.js | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index e33ad9f..d048ce1 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -64,7 +64,9 @@ if (tool) { } else if (command === "setup") { setup(); } else if (command === "--ultimate") { - installUltimate(); + (async () => { + await installUltimate(); + })(); } else if (command === "teardown") { teardownDirectories(); teardown(); diff --git a/packages/safe-chain/src/installation/installOnWindows.js b/packages/safe-chain/src/installation/installOnWindows.js index 9f92893..094ec58 100644 --- a/packages/safe-chain/src/installation/installOnWindows.js +++ b/packages/safe-chain/src/installation/installOnWindows.js @@ -103,7 +103,7 @@ function runMsiInstaller(msiPath) { // /i = install // /qn = quiet mode (no UI) ui.writeVerbose(`Running: msiexec /i "${msiPath}" /qn`); - execSync(`msiexec /i "${msiPath}" /qn`, { stdio: "inherit" }); + execSync(`msiexec /i "${msiPath}" /qn`, { stdio: "inherit" }); // noopengrep this is ok, we control the msiPath } function stopServiceIfRunning() { @@ -128,7 +128,7 @@ function startService() { return; } } catch { - // Service might not exist yet or query failed, proceed with start + ui.writeVerbose("Service not found or query failed, attempting to start."); } ui.writeVerbose('Running: net start "SafeChainAgent"'); diff --git a/packages/safe-chain/src/installation/installUltimate.js b/packages/safe-chain/src/installation/installUltimate.js index 7383d2c..3b6846a 100644 --- a/packages/safe-chain/src/installation/installUltimate.js +++ b/packages/safe-chain/src/installation/installUltimate.js @@ -3,13 +3,13 @@ import { ui } from "../environment/userInteraction.js"; import { initializeCliArguments } from "../config/cliArguments.js"; import { installOnWindows } from "./installOnWindows.js"; -export function installUltimate() { +export async function installUltimate() { initializeCliArguments(process.argv); const operatingSystem = platform(); if (operatingSystem === "win32") { - installOnWindows(); + await installOnWindows(); } else { ui.writeInformation( `${operatingSystem} is not supported yet by safe-chain's ultimate version.`, From 9b61a325fa1eccb98becd11088ba9763da4c2d1c Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 19 Jan 2026 15:24:49 +0100 Subject: [PATCH 103/360] Log when installer file cleanup failed --- packages/safe-chain/src/installation/installOnWindows.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/installation/installOnWindows.js b/packages/safe-chain/src/installation/installOnWindows.js index 094ec58..f3c9ee8 100644 --- a/packages/safe-chain/src/installation/installOnWindows.js +++ b/packages/safe-chain/src/installation/installOnWindows.js @@ -142,6 +142,6 @@ function cleanup(msiPath) { try { unlinkSync(msiPath); } catch { - // Ignore cleanup errors + ui.writeVerbose("Failed to clean up temporary installer file."); } } From 8b189443b7e293ad7f4bc88944da577ce2ace311 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 19 Jan 2026 15:31:41 +0100 Subject: [PATCH 104/360] Use safeSpawn instead of execSync --- .../src/installation/installOnWindows.js | 109 +++++++++--------- 1 file changed, 55 insertions(+), 54 deletions(-) diff --git a/packages/safe-chain/src/installation/installOnWindows.js b/packages/safe-chain/src/installation/installOnWindows.js index f3c9ee8..54893bf 100644 --- a/packages/safe-chain/src/installation/installOnWindows.js +++ b/packages/safe-chain/src/installation/installOnWindows.js @@ -1,8 +1,8 @@ import { arch, tmpdir } from "os"; import { unlinkSync } from "fs"; import { join } from "path"; -import { execSync } from "child_process"; import { ui } from "../environment/userInteraction.js"; +import { safeSpawn } from "../utils/safeSpawn.js"; import { getAgentDownloadUrl, getAgentVersion, @@ -10,7 +10,7 @@ import { } from "./downloadAgent.js"; export async function installOnWindows() { - if (!isRunningAsAdmin()) { + if (!(await isRunningAsAdmin())) { ui.writeError("Administrator privileges required."); ui.writeInformation( "Please run this command in an elevated terminal (Run as Administrator).", @@ -32,18 +32,18 @@ export async function installOnWindows() { await downloadFile(downloadUrl, msiPath); ui.emptyLine(); - stopServiceIfRunning(); - uninstallIfInstalled(); + await stopServiceIfRunning(); + await uninstallIfInstalled(); // Wait a moment for uninstall to complete await new Promise((resolve) => setTimeout(resolve, 2000)); ui.writeInformation("⚙️ Installing SafeChain Agent..."); - runMsiInstaller(msiPath); + await runMsiInstaller(msiPath); ui.emptyLine(); ui.writeInformation("🚀 Starting SafeChain Agent service..."); - startService(); + await startService(); ui.writeVerbose(`Cleaning up temporary file: ${msiPath}`); cleanup(msiPath); @@ -53,13 +53,9 @@ export async function installOnWindows() { ui.emptyLine(); } -function isRunningAsAdmin() { - try { - execSync("net session", { stdio: "ignore" }); - return true; - } catch { - return false; - } +async function isRunningAsAdmin() { + const result = await safeSpawn("net", ["session"], { stdio: "ignore" }); + return result.status === 0; } function getWindowsArchitecture() { @@ -69,29 +65,31 @@ function getWindowsArchitecture() { throw new Error(`Unsupported architecture: ${nodeArch}`); } -function uninstallIfInstalled() { - try { - // Use PowerShell to find the product code, then use msiexec to uninstall - // This is the modern alternative to wmic which is deprecated - const findProductCodeCmd = `powershell -Command "$app = Get-WmiObject -Class Win32_Product -Filter \\"Name='SafeChain Agent'\\"; if ($app) { Write-Output $app.IdentifyingNumber }"`; - ui.writeVerbose(`Finding product code: ${findProductCodeCmd}`); +async function uninstallIfInstalled() { + // Use PowerShell to find the product code, then use msiexec to uninstall + // This is the modern alternative to wmic which is deprecated + const powershellScript = `$app = Get-WmiObject -Class Win32_Product -Filter "Name='SafeChain Agent'"; if ($app) { Write-Output $app.IdentifyingNumber }`; + ui.writeVerbose(`Finding product code with PowerShell`); - const productCode = execSync(findProductCodeCmd, { - encoding: "utf8", - }).trim(); + const result = await safeSpawn("powershell", ["-Command", powershellScript], { + stdio: "pipe", + }); - if (productCode) { - ui.writeInformation("🗑️ Removing previous installation..."); - ui.writeVerbose(`Found product code: ${productCode}`); - ui.writeVerbose(`Running: msiexec /x ${productCode} /qn /norestart`); - execSync(`msiexec /x ${productCode} /qn /norestart`, { - stdio: "inherit", - }); - } else { - ui.writeVerbose("No existing installation found (fresh install)."); - } - } catch { - // Not installed or uninstall failed, which is fine for a fresh install + if (result.status !== 0) { + ui.writeVerbose("No existing installation found (fresh install)."); + return; + } + + const productCode = result.stdout.trim(); + + if (productCode) { + ui.writeInformation("🗑️ Removing previous installation..."); + ui.writeVerbose(`Found product code: ${productCode}`); + ui.writeVerbose(`Running: msiexec /x ${productCode} /qn /norestart`); + await safeSpawn("msiexec", ["/x", productCode, "/qn", "/norestart"], { + stdio: "inherit", + }); + } else { ui.writeVerbose("No existing installation found (fresh install)."); } } @@ -99,40 +97,43 @@ function uninstallIfInstalled() { /** * @param {string} msiPath */ -function runMsiInstaller(msiPath) { +async function runMsiInstaller(msiPath) { // /i = install // /qn = quiet mode (no UI) ui.writeVerbose(`Running: msiexec /i "${msiPath}" /qn`); - execSync(`msiexec /i "${msiPath}" /qn`, { stdio: "inherit" }); // noopengrep this is ok, we control the msiPath + await safeSpawn("msiexec", ["/i", msiPath, "/qn"], { stdio: "inherit" }); } -function stopServiceIfRunning() { - try { - ui.writeInformation("⏹️ Stopping running service..."); - ui.writeVerbose('Running: net stop "SafeChainAgent"'); - execSync('net stop "SafeChainAgent"', { stdio: "inherit" }); - } catch { - // Service is not running or doesn't exist, which is fine +async function stopServiceIfRunning() { + ui.writeInformation("⏹️ Stopping running service..."); + ui.writeVerbose('Running: net stop "SafeChainAgent"'); + const result = await safeSpawn("net", ["stop", "SafeChainAgent"], { + stdio: "inherit", + }); + + if (result.status !== 0) { ui.writeVerbose("Service not running (will start after installation)."); } } -function startService() { - try { - // Check if service is already running - ui.writeVerbose('Checking service status: sc query "SafeChainAgent"'); - const status = execSync('sc query "SafeChainAgent"', { encoding: "utf8" }); +async function startService() { + // Check if service is already running + ui.writeVerbose('Checking service status: sc query "SafeChainAgent"'); + const queryResult = await safeSpawn("sc", ["query", "SafeChainAgent"], { + stdio: "pipe", + }); - if (status.includes("RUNNING")) { - ui.writeVerbose("SafeChain Agent service is already running."); - return; - } - } catch { + if (queryResult.status === 0 && queryResult.stdout.includes("RUNNING")) { + ui.writeVerbose("SafeChain Agent service is already running."); + return; + } + + if (queryResult.status !== 0) { ui.writeVerbose("Service not found or query failed, attempting to start."); } ui.writeVerbose('Running: net start "SafeChainAgent"'); - execSync('net start "SafeChainAgent"', { stdio: "inherit" }); + await safeSpawn("net", ["start", "SafeChainAgent"], { stdio: "inherit" }); } /** From 4a90bd262109d307e0767eed654258a3e90801bd Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 19 Jan 2026 15:34:16 +0100 Subject: [PATCH 105/360] Code quality: use early return --- .../src/installation/installOnWindows.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/safe-chain/src/installation/installOnWindows.js b/packages/safe-chain/src/installation/installOnWindows.js index 54893bf..a6e0938 100644 --- a/packages/safe-chain/src/installation/installOnWindows.js +++ b/packages/safe-chain/src/installation/installOnWindows.js @@ -82,16 +82,17 @@ async function uninstallIfInstalled() { const productCode = result.stdout.trim(); - if (productCode) { - ui.writeInformation("🗑️ Removing previous installation..."); - ui.writeVerbose(`Found product code: ${productCode}`); - ui.writeVerbose(`Running: msiexec /x ${productCode} /qn /norestart`); - await safeSpawn("msiexec", ["/x", productCode, "/qn", "/norestart"], { - stdio: "inherit", - }); - } else { + if (!productCode) { ui.writeVerbose("No existing installation found (fresh install)."); + return; } + + ui.writeInformation("🗑️ Removing previous installation..."); + ui.writeVerbose(`Found product code: ${productCode}`); + ui.writeVerbose(`Running: msiexec /x ${productCode} /qn /norestart`); + await safeSpawn("msiexec", ["/x", productCode, "/qn", "/norestart"], { + stdio: "inherit", + }); } /** From 86e600773370e0b5da5f743ba1840bd008899201 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 19 Jan 2026 15:45:32 +0100 Subject: [PATCH 106/360] Improve error handling --- .../src/installation/installOnWindows.js | 93 ++++++++++++------- 1 file changed, 57 insertions(+), 36 deletions(-) diff --git a/packages/safe-chain/src/installation/installOnWindows.js b/packages/safe-chain/src/installation/installOnWindows.js index a6e0938..6db1cb0 100644 --- a/packages/safe-chain/src/installation/installOnWindows.js +++ b/packages/safe-chain/src/installation/installOnWindows.js @@ -31,31 +31,43 @@ export async function installOnWindows() { ui.writeVerbose(`Destination: ${msiPath}`); await downloadFile(downloadUrl, msiPath); - ui.emptyLine(); - await stopServiceIfRunning(); - await uninstallIfInstalled(); + try { + ui.emptyLine(); + await stopServiceIfRunning(); + await uninstallIfInstalled(); - // Wait a moment for uninstall to complete - await new Promise((resolve) => setTimeout(resolve, 2000)); + // Wait a moment for uninstall to complete + await new Promise((resolve) => setTimeout(resolve, 2000)); - ui.writeInformation("⚙️ Installing SafeChain Agent..."); - await runMsiInstaller(msiPath); + ui.writeInformation("⚙️ Installing SafeChain Agent..."); + await runMsiInstaller(msiPath); - ui.emptyLine(); - ui.writeInformation("🚀 Starting SafeChain Agent service..."); - await startService(); + ui.emptyLine(); + ui.writeInformation("🚀 Starting SafeChain Agent service..."); + await startService(); - ui.writeVerbose(`Cleaning up temporary file: ${msiPath}`); - cleanup(msiPath); - - ui.emptyLine(); - ui.writeInformation("✅ SafeChain Agent installed and started successfully!"); - ui.emptyLine(); + ui.emptyLine(); + ui.writeInformation( + "✅ SafeChain Agent installed and started successfully!", + ); + ui.emptyLine(); + } finally { + ui.writeVerbose(`Cleaning up temporary file: ${msiPath}`); + cleanup(msiPath); + } } async function isRunningAsAdmin() { - const result = await safeSpawn("net", ["session"], { stdio: "ignore" }); - return result.status === 0; + const result = await safeSpawn( + "powershell", + [ + "-Command", + "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)", + ], + { stdio: "pipe" }, + ); + + return result.status === 0 && result.stdout.trim() === "True"; } function getWindowsArchitecture() { @@ -66,8 +78,6 @@ function getWindowsArchitecture() { } async function uninstallIfInstalled() { - // Use PowerShell to find the product code, then use msiexec to uninstall - // This is the modern alternative to wmic which is deprecated const powershellScript = `$app = Get-WmiObject -Class Win32_Product -Filter "Name='SafeChain Agent'"; if ($app) { Write-Output $app.IdentifyingNumber }`; ui.writeVerbose(`Finding product code with PowerShell`); @@ -81,7 +91,6 @@ async function uninstallIfInstalled() { } const productCode = result.stdout.trim(); - if (!productCode) { ui.writeVerbose("No existing installation found (fresh install)."); return; @@ -89,27 +98,38 @@ async function uninstallIfInstalled() { ui.writeInformation("🗑️ Removing previous installation..."); ui.writeVerbose(`Found product code: ${productCode}`); - ui.writeVerbose(`Running: msiexec /x ${productCode} /qn /norestart`); - await safeSpawn("msiexec", ["/x", productCode, "/qn", "/norestart"], { - stdio: "inherit", - }); + + const uninstallResult = await safeSpawn( + "msiexec", + ["/x", productCode, "/qn", "/norestart"], + { stdio: "inherit" }, + ); + + if (uninstallResult.status !== 0) { + throw new Error(`Uninstall failed (exit code: ${uninstallResult.status})`); + } } /** * @param {string} msiPath */ async function runMsiInstaller(msiPath) { - // /i = install - // /qn = quiet mode (no UI) ui.writeVerbose(`Running: msiexec /i "${msiPath}" /qn`); - await safeSpawn("msiexec", ["/i", msiPath, "/qn"], { stdio: "inherit" }); + + const result = await safeSpawn("msiexec", ["/i", msiPath, "/qn"], { + stdio: "inherit", + }); + + if (result.status !== 0) { + throw new Error(`MSI installer failed (exit code: ${result.status})`); + } } async function stopServiceIfRunning() { ui.writeInformation("⏹️ Stopping running service..."); - ui.writeVerbose('Running: net stop "SafeChainAgent"'); + const result = await safeSpawn("net", ["stop", "SafeChainAgent"], { - stdio: "inherit", + stdio: "pipe", }); if (result.status !== 0) { @@ -118,7 +138,6 @@ async function stopServiceIfRunning() { } async function startService() { - // Check if service is already running ui.writeVerbose('Checking service status: sc query "SafeChainAgent"'); const queryResult = await safeSpawn("sc", ["query", "SafeChainAgent"], { stdio: "pipe", @@ -129,12 +148,14 @@ async function startService() { return; } - if (queryResult.status !== 0) { - ui.writeVerbose("Service not found or query failed, attempting to start."); - } - ui.writeVerbose('Running: net start "SafeChainAgent"'); - await safeSpawn("net", ["start", "SafeChainAgent"], { stdio: "inherit" }); + const startResult = await safeSpawn("net", ["start", "SafeChainAgent"], { + stdio: "pipe", + }); + + if (startResult.status !== 0) { + throw new Error(`Failed to start service (exit code: ${startResult.status})`); + } } /** From eb00fe6f3d8a17ec9e8ecf522db15f05072adc06 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 19 Jan 2026 15:54:02 +0100 Subject: [PATCH 107/360] Write error output --- packages/safe-chain/src/installation/installOnWindows.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/safe-chain/src/installation/installOnWindows.js b/packages/safe-chain/src/installation/installOnWindows.js index 6db1cb0..8610394 100644 --- a/packages/safe-chain/src/installation/installOnWindows.js +++ b/packages/safe-chain/src/installation/installOnWindows.js @@ -106,6 +106,8 @@ async function uninstallIfInstalled() { ); if (uninstallResult.status !== 0) { + ui.writeInformation(uninstallResult.stdout); + ui.writeInformation(uninstallResult.stderr); throw new Error(`Uninstall failed (exit code: ${uninstallResult.status})`); } } @@ -154,7 +156,9 @@ async function startService() { }); if (startResult.status !== 0) { - throw new Error(`Failed to start service (exit code: ${startResult.status})`); + throw new Error( + `Failed to start service (exit code: ${startResult.status})`, + ); } } From 4ebbbca4326295f1d144e0fd22c08cc03690ffbb Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 19 Jan 2026 15:58:11 +0100 Subject: [PATCH 108/360] Temporarily disable cleanup --- .../src/installation/installOnWindows.js | 39 +++++++++---------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/packages/safe-chain/src/installation/installOnWindows.js b/packages/safe-chain/src/installation/installOnWindows.js index 8610394..0257fd8 100644 --- a/packages/safe-chain/src/installation/installOnWindows.js +++ b/packages/safe-chain/src/installation/installOnWindows.js @@ -31,30 +31,29 @@ export async function installOnWindows() { ui.writeVerbose(`Destination: ${msiPath}`); await downloadFile(downloadUrl, msiPath); - try { - ui.emptyLine(); - await stopServiceIfRunning(); - await uninstallIfInstalled(); + // try { + ui.emptyLine(); + await stopServiceIfRunning(); + await uninstallIfInstalled(); - // Wait a moment for uninstall to complete - await new Promise((resolve) => setTimeout(resolve, 2000)); + // Wait a moment for uninstall to complete + await new Promise((resolve) => setTimeout(resolve, 2000)); - ui.writeInformation("⚙️ Installing SafeChain Agent..."); - await runMsiInstaller(msiPath); + ui.writeInformation("⚙️ Installing SafeChain Agent..."); + await runMsiInstaller(msiPath); - ui.emptyLine(); - ui.writeInformation("🚀 Starting SafeChain Agent service..."); - await startService(); + ui.emptyLine(); + ui.writeInformation("🚀 Starting SafeChain Agent service..."); + await startService(); - ui.emptyLine(); - ui.writeInformation( - "✅ SafeChain Agent installed and started successfully!", - ); - ui.emptyLine(); - } finally { - ui.writeVerbose(`Cleaning up temporary file: ${msiPath}`); - cleanup(msiPath); - } + ui.emptyLine(); + ui.writeInformation("✅ SafeChain Agent installed and started successfully!"); + ui.emptyLine(); + // } + // finally { + // ui.writeVerbose(`Cleaning up temporary file: ${msiPath}`); + // cleanup(msiPath); + // } } async function isRunningAsAdmin() { From 211f877384950e40a4c0e814c291b49517e7a066 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 19 Jan 2026 16:03:51 +0100 Subject: [PATCH 109/360] Write stdout stderr --- .../src/installation/installOnWindows.js | 43 ++++++++++--------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/packages/safe-chain/src/installation/installOnWindows.js b/packages/safe-chain/src/installation/installOnWindows.js index 0257fd8..7511eef 100644 --- a/packages/safe-chain/src/installation/installOnWindows.js +++ b/packages/safe-chain/src/installation/installOnWindows.js @@ -31,29 +31,30 @@ export async function installOnWindows() { ui.writeVerbose(`Destination: ${msiPath}`); await downloadFile(downloadUrl, msiPath); - // try { - ui.emptyLine(); - await stopServiceIfRunning(); - await uninstallIfInstalled(); + try { + ui.emptyLine(); + await stopServiceIfRunning(); + await uninstallIfInstalled(); - // Wait a moment for uninstall to complete - await new Promise((resolve) => setTimeout(resolve, 2000)); + // Wait a moment for uninstall to complete + await new Promise((resolve) => setTimeout(resolve, 2000)); - ui.writeInformation("⚙️ Installing SafeChain Agent..."); - await runMsiInstaller(msiPath); + ui.writeInformation("⚙️ Installing SafeChain Agent..."); + await runMsiInstaller(msiPath); - ui.emptyLine(); - ui.writeInformation("🚀 Starting SafeChain Agent service..."); - await startService(); + ui.emptyLine(); + ui.writeInformation("🚀 Starting SafeChain Agent service..."); + await startService(); - ui.emptyLine(); - ui.writeInformation("✅ SafeChain Agent installed and started successfully!"); - ui.emptyLine(); - // } - // finally { - // ui.writeVerbose(`Cleaning up temporary file: ${msiPath}`); - // cleanup(msiPath); - // } + ui.emptyLine(); + ui.writeInformation( + "✅ SafeChain Agent installed and started successfully!", + ); + ui.emptyLine(); + } finally { + ui.writeVerbose(`Cleaning up temporary file: ${msiPath}`); + cleanup(msiPath); + } } async function isRunningAsAdmin() { @@ -85,7 +86,9 @@ async function uninstallIfInstalled() { }); if (result.status !== 0) { - ui.writeVerbose("No existing installation found (fresh install)."); + ui.writeVerbose( + `No existing installation found (fresh install). Output: ${result.stdout} ${result.stderr}`, + ); return; } From 4a7629a17487217500f2b8054e970eb7ca83e186 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 19 Jan 2026 16:11:51 +0100 Subject: [PATCH 110/360] Use execSync to execute powershell command --- .../src/installation/installOnWindows.js | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/safe-chain/src/installation/installOnWindows.js b/packages/safe-chain/src/installation/installOnWindows.js index 7511eef..84dcd9c 100644 --- a/packages/safe-chain/src/installation/installOnWindows.js +++ b/packages/safe-chain/src/installation/installOnWindows.js @@ -1,6 +1,7 @@ import { arch, tmpdir } from "os"; import { unlinkSync } from "fs"; import { join } from "path"; +import { execSync } from "child_process"; import { ui } from "../environment/userInteraction.js"; import { safeSpawn } from "../utils/safeSpawn.js"; import { @@ -81,18 +82,15 @@ async function uninstallIfInstalled() { const powershellScript = `$app = Get-WmiObject -Class Win32_Product -Filter "Name='SafeChain Agent'"; if ($app) { Write-Output $app.IdentifyingNumber }`; ui.writeVerbose(`Finding product code with PowerShell`); - const result = await safeSpawn("powershell", ["-Command", powershellScript], { - stdio: "pipe", - }); - - if (result.status !== 0) { - ui.writeVerbose( - `No existing installation found (fresh install). Output: ${result.stdout} ${result.stderr}`, - ); + let productCode; + try { + productCode = execSync(`powershell -Command "${powershellScript}"`, { + encoding: "utf8", + }).trim(); + } catch { + ui.writeVerbose("No existing installation found (fresh install)."); return; } - - const productCode = result.stdout.trim(); if (!productCode) { ui.writeVerbose("No existing installation found (fresh install)."); return; From 20fb949a2351d6dc1940b0294f74036b9cb10a4f Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 19 Jan 2026 16:17:34 +0100 Subject: [PATCH 111/360] Fix uninstall --- packages/safe-chain/src/installation/installOnWindows.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/safe-chain/src/installation/installOnWindows.js b/packages/safe-chain/src/installation/installOnWindows.js index 84dcd9c..d6e7207 100644 --- a/packages/safe-chain/src/installation/installOnWindows.js +++ b/packages/safe-chain/src/installation/installOnWindows.js @@ -79,14 +79,14 @@ function getWindowsArchitecture() { } async function uninstallIfInstalled() { - const powershellScript = `$app = Get-WmiObject -Class Win32_Product -Filter "Name='SafeChain Agent'"; if ($app) { Write-Output $app.IdentifyingNumber }`; ui.writeVerbose(`Finding product code with PowerShell`); let productCode; try { - productCode = execSync(`powershell -Command "${powershellScript}"`, { - encoding: "utf8", - }).trim(); + productCode = execSync( + `powershell -Command "$app = Get-WmiObject -Class Win32_Product -Filter \\"Name='SafeChain Agent'\\"; if ($app) { Write-Output $app.IdentifyingNumber }"`, + { encoding: "utf8" }, + ).trim(); } catch { ui.writeVerbose("No existing installation found (fresh install)."); return; From c200ea56cf4aef90290654b2d1b40e1787515ac1 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 19 Jan 2026 16:23:59 +0100 Subject: [PATCH 112/360] Cleanup debug logging --- packages/safe-chain/src/installation/installOnWindows.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/safe-chain/src/installation/installOnWindows.js b/packages/safe-chain/src/installation/installOnWindows.js index d6e7207..60a339b 100644 --- a/packages/safe-chain/src/installation/installOnWindows.js +++ b/packages/safe-chain/src/installation/installOnWindows.js @@ -106,8 +106,6 @@ async function uninstallIfInstalled() { ); if (uninstallResult.status !== 0) { - ui.writeInformation(uninstallResult.stdout); - ui.writeInformation(uninstallResult.stderr); throw new Error(`Uninstall failed (exit code: ${uninstallResult.status})`); } } From da6c022ef49c5fcb6faba43a1c6bb9d685a8cc37 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 19 Jan 2026 16:25:50 +0100 Subject: [PATCH 113/360] Add explaining comments for powershell scritps --- packages/safe-chain/src/installation/installOnWindows.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/safe-chain/src/installation/installOnWindows.js b/packages/safe-chain/src/installation/installOnWindows.js index 60a339b..41bb1ca 100644 --- a/packages/safe-chain/src/installation/installOnWindows.js +++ b/packages/safe-chain/src/installation/installOnWindows.js @@ -59,6 +59,8 @@ export async function installOnWindows() { } async function isRunningAsAdmin() { + // Uses Windows Security API to check if current process has admin privileges. + // Returns "True" or "False" as a string. const result = await safeSpawn( "powershell", [ @@ -79,6 +81,8 @@ function getWindowsArchitecture() { } async function uninstallIfInstalled() { + // Query Win32_Product via WMI to find the installed SafeChain Agent. + // If found, outputs the product GUID (e.g., "{12345678-1234-...}") needed for msiexec uninstall. ui.writeVerbose(`Finding product code with PowerShell`); let productCode; From 9651e05f4b04773dcd9029a93ce7aa97b2c49859 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Mon, 19 Jan 2026 18:59:37 +0100 Subject: [PATCH 114/360] Fix naming of SafeChain Agent --- packages/safe-chain/src/installation/installOnWindows.js | 8 ++++---- packages/safe-chain/src/installation/installUltimate.js | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/safe-chain/src/installation/installOnWindows.js b/packages/safe-chain/src/installation/installOnWindows.js index 41bb1ca..9837d18 100644 --- a/packages/safe-chain/src/installation/installOnWindows.js +++ b/packages/safe-chain/src/installation/installOnWindows.js @@ -40,16 +40,16 @@ export async function installOnWindows() { // Wait a moment for uninstall to complete await new Promise((resolve) => setTimeout(resolve, 2000)); - ui.writeInformation("⚙️ Installing SafeChain Agent..."); + ui.writeInformation("⚙️ Installing SafeChain Ultimate..."); await runMsiInstaller(msiPath); ui.emptyLine(); - ui.writeInformation("🚀 Starting SafeChain Agent service..."); + ui.writeInformation("🚀 Starting SafeChain Ultimate service..."); await startService(); ui.emptyLine(); ui.writeInformation( - "✅ SafeChain Agent installed and started successfully!", + "✅ SafeChain Ultimate installed and started successfully!", ); ui.emptyLine(); } finally { @@ -148,7 +148,7 @@ async function startService() { }); if (queryResult.status === 0 && queryResult.stdout.includes("RUNNING")) { - ui.writeVerbose("SafeChain Agent service is already running."); + ui.writeVerbose("SafeChain Ultimate service is already running."); return; } diff --git a/packages/safe-chain/src/installation/installUltimate.js b/packages/safe-chain/src/installation/installUltimate.js index 3b6846a..086b6a4 100644 --- a/packages/safe-chain/src/installation/installUltimate.js +++ b/packages/safe-chain/src/installation/installUltimate.js @@ -12,7 +12,7 @@ export async function installUltimate() { await installOnWindows(); } else { ui.writeInformation( - `${operatingSystem} is not supported yet by safe-chain's ultimate version.`, + `${operatingSystem} is not supported yet by SafeChain's ultimate version.`, ); } } From 3dad1c2516bdc26141a57eeb0eae24eb37f42ecc Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Mon, 19 Jan 2026 19:01:28 +0100 Subject: [PATCH 115/360] Update packages/safe-chain/src/installation/installOnWindows.js --- packages/safe-chain/src/installation/installOnWindows.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/installation/installOnWindows.js b/packages/safe-chain/src/installation/installOnWindows.js index 9837d18..3cd3428 100644 --- a/packages/safe-chain/src/installation/installOnWindows.js +++ b/packages/safe-chain/src/installation/installOnWindows.js @@ -26,7 +26,7 @@ export async function installOnWindows() { ui.emptyLine(); ui.writeInformation( - `📥 Downloading SafeChain Agent ${getAgentVersion()} (${architecture})...`, + `📥 Downloading SafeChain Ultimate ${getAgentVersion()} (${architecture})...`, ); ui.writeVerbose(`Download URL: ${downloadUrl}`); ui.writeVerbose(`Destination: ${msiPath}`); From 7d55c5453bc19ac8ab203d6f37922c369627659a Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 20 Jan 2026 09:12:00 +0100 Subject: [PATCH 116/360] Move os and arch detection to downloader, add checksum verification. --- .../src/installation/downloadAgent.js | 82 ++++++++++++++++++- .../src/installation/installOnWindows.js | 49 +++++------ 2 files changed, 102 insertions(+), 29 deletions(-) diff --git a/packages/safe-chain/src/installation/downloadAgent.js b/packages/safe-chain/src/installation/downloadAgent.js index d74cbf7..2441f7d 100644 --- a/packages/safe-chain/src/installation/downloadAgent.js +++ b/packages/safe-chain/src/installation/downloadAgent.js @@ -1,9 +1,25 @@ -import { createWriteStream } from "fs"; +import { createWriteStream, createReadStream } from "fs"; +import { createHash } from "crypto"; import { pipeline } from "stream/promises"; import fetch from "make-fetch-happen"; const ULTIMATE_VERSION = "v0.2.0"; +const DOWNLOAD_URLS = { + win32: { + x64: { + url: "https://github.com/AikidoSec/safechain-internals/releases/download/v0.2.0/SafeChainAgent-windows-amd64.msi", + checksum: + "sha256:c699f74a3666d85b70b8ede076a2192a6a023f1b395e8e6c7556927ee698a020", + }, + arm64: { + url: "https://github.com/AikidoSec/safechain-internals/releases/download/v0.2.0/SafeChainAgent-windows-arm64.msi", + checksum: + "sha256:5b08dd4749c8befe5379bc01f7a8a5ac1d6a35b6bee37c6c72a4ba8744c3b052", + }, + }, +}; + /** * Builds the download URL for the SafeChain Agent installer. * @param {string} fileName @@ -31,3 +47,67 @@ export async function downloadFile(url, destPath) { export function getAgentVersion() { return ULTIMATE_VERSION; } + +/** + * Returns download info (url, checksum) for the current OS and architecture. + * @returns {{ url: string, checksum: string } | null} + */ +export function getDownloadInfoForCurrentPlatform() { + const platform = process.platform; + const arch = process.arch; + + if (!Object.hasOwn(DOWNLOAD_URLS, platform)) { + return null; + } + const platformUrls = + DOWNLOAD_URLS[/** @type {keyof typeof DOWNLOAD_URLS} */ (platform)]; + + if (!Object.hasOwn(platformUrls, arch)) { + return null; + } + + return platformUrls[/** @type {keyof typeof platformUrls} */ (arch)]; +} + +/** + * Verifies the checksum of a file. + * @param {string} filePath + * @param {string} expectedChecksum - Format: "algorithm:hash" (e.g., "sha256:abc123...") + * @returns {Promise} + */ +async function verifyChecksum(filePath, expectedChecksum) { + const [algorithm, expected] = expectedChecksum.split(":"); + + const hash = createHash(algorithm); + + if (filePath.includes("..")) throw new Error("Invalid file path"); + const stream = createReadStream(filePath); + + for await (const chunk of stream) { + hash.update(chunk); + } + + const actual = hash.digest("hex"); + return actual === expected; +} + +/** + * Downloads the SafeChain agent for the current OS/arch and verifies its checksum. + * @param {string} fileName - Destination file path + * @returns {Promise} The file path if successful, null if no download URL for current platform + */ +export async function downloadAgentToFile(fileName) { + const info = getDownloadInfoForCurrentPlatform(); + if (!info) { + return null; + } + + await downloadFile(info.url, fileName); + + const isValid = await verifyChecksum(fileName, info.checksum); + if (!isValid) { + throw new Error("Checksum verification failed"); + } + + return fileName; +} diff --git a/packages/safe-chain/src/installation/installOnWindows.js b/packages/safe-chain/src/installation/installOnWindows.js index 3cd3428..33ae293 100644 --- a/packages/safe-chain/src/installation/installOnWindows.js +++ b/packages/safe-chain/src/installation/installOnWindows.js @@ -1,14 +1,13 @@ -import { arch, tmpdir } from "os"; +import { tmpdir } from "os"; import { unlinkSync } from "fs"; import { join } from "path"; import { execSync } from "child_process"; import { ui } from "../environment/userInteraction.js"; import { safeSpawn } from "../utils/safeSpawn.js"; -import { - getAgentDownloadUrl, - getAgentVersion, - downloadFile, -} from "./downloadAgent.js"; +import { downloadAgentToFile, getAgentVersion } from "./downloadAgent.js"; + +const WINDOWS_SERVICE_NAME = "SafeChainAgent"; +const WINDOWS_APP_NAME = "SafeChain Agent"; export async function installOnWindows() { if (!(await isRunningAsAdmin())) { @@ -19,18 +18,17 @@ export async function installOnWindows() { return; } - const architecture = getWindowsArchitecture(); - const fileName = `SafeChainAgent-windows-${architecture}.msi`; - const downloadUrl = getAgentDownloadUrl(fileName); - const msiPath = join(tmpdir(), `SafeChainAgent-${Date.now()}.msi`); + const msiPath = join(tmpdir(), `SafeChainUltimate-${Date.now()}.msi`); ui.emptyLine(); - ui.writeInformation( - `📥 Downloading SafeChain Ultimate ${getAgentVersion()} (${architecture})...`, - ); - ui.writeVerbose(`Download URL: ${downloadUrl}`); + ui.writeInformation(`📥 Downloading SafeChain Ultimate ${getAgentVersion()}`); ui.writeVerbose(`Destination: ${msiPath}`); - await downloadFile(downloadUrl, msiPath); + + const result = await downloadAgentToFile(msiPath); + if (!result) { + ui.writeError("No download available for this platform/architecture."); + return; + } try { ui.emptyLine(); @@ -73,13 +71,6 @@ async function isRunningAsAdmin() { return result.status === 0 && result.stdout.trim() === "True"; } -function getWindowsArchitecture() { - const nodeArch = arch(); - if (nodeArch === "x64") return "amd64"; - if (nodeArch === "arm64") return "arm64"; - throw new Error(`Unsupported architecture: ${nodeArch}`); -} - async function uninstallIfInstalled() { // Query Win32_Product via WMI to find the installed SafeChain Agent. // If found, outputs the product GUID (e.g., "{12345678-1234-...}") needed for msiexec uninstall. @@ -88,7 +79,7 @@ async function uninstallIfInstalled() { let productCode; try { productCode = execSync( - `powershell -Command "$app = Get-WmiObject -Class Win32_Product -Filter \\"Name='SafeChain Agent'\\"; if ($app) { Write-Output $app.IdentifyingNumber }"`, + `powershell -Command "$app = Get-WmiObject -Class Win32_Product -Filter \\"Name='${WINDOWS_APP_NAME}'\\"; if ($app) { Write-Output $app.IdentifyingNumber }"`, { encoding: "utf8" }, ).trim(); } catch { @@ -132,7 +123,7 @@ async function runMsiInstaller(msiPath) { async function stopServiceIfRunning() { ui.writeInformation("⏹️ Stopping running service..."); - const result = await safeSpawn("net", ["stop", "SafeChainAgent"], { + const result = await safeSpawn("net", ["stop", WINDOWS_SERVICE_NAME], { stdio: "pipe", }); @@ -142,8 +133,10 @@ async function stopServiceIfRunning() { } async function startService() { - ui.writeVerbose('Checking service status: sc query "SafeChainAgent"'); - const queryResult = await safeSpawn("sc", ["query", "SafeChainAgent"], { + ui.writeVerbose( + `Checking service status: sc query "${WINDOWS_SERVICE_NAME}"`, + ); + const queryResult = await safeSpawn("sc", ["query", WINDOWS_SERVICE_NAME], { stdio: "pipe", }); @@ -152,8 +145,8 @@ async function startService() { return; } - ui.writeVerbose('Running: net start "SafeChainAgent"'); - const startResult = await safeSpawn("net", ["start", "SafeChainAgent"], { + ui.writeVerbose(`Running: net start "${WINDOWS_SERVICE_NAME}"`); + const startResult = await safeSpawn("net", ["start", WINDOWS_SERVICE_NAME], { stdio: "pipe", }); From 626bb0d2b9c4262a92472beda086cbb25fe5f715 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 20 Jan 2026 12:21:45 +0100 Subject: [PATCH 117/360] Don't start the windows service - the msi already does this --- .../src/installation/installOnWindows.js | 32 ------------------- 1 file changed, 32 deletions(-) diff --git a/packages/safe-chain/src/installation/installOnWindows.js b/packages/safe-chain/src/installation/installOnWindows.js index 33ae293..2380a7f 100644 --- a/packages/safe-chain/src/installation/installOnWindows.js +++ b/packages/safe-chain/src/installation/installOnWindows.js @@ -35,16 +35,9 @@ export async function installOnWindows() { await stopServiceIfRunning(); await uninstallIfInstalled(); - // Wait a moment for uninstall to complete - await new Promise((resolve) => setTimeout(resolve, 2000)); - ui.writeInformation("⚙️ Installing SafeChain Ultimate..."); await runMsiInstaller(msiPath); - ui.emptyLine(); - ui.writeInformation("🚀 Starting SafeChain Ultimate service..."); - await startService(); - ui.emptyLine(); ui.writeInformation( "✅ SafeChain Ultimate installed and started successfully!", @@ -132,31 +125,6 @@ async function stopServiceIfRunning() { } } -async function startService() { - ui.writeVerbose( - `Checking service status: sc query "${WINDOWS_SERVICE_NAME}"`, - ); - const queryResult = await safeSpawn("sc", ["query", WINDOWS_SERVICE_NAME], { - stdio: "pipe", - }); - - if (queryResult.status === 0 && queryResult.stdout.includes("RUNNING")) { - ui.writeVerbose("SafeChain Ultimate service is already running."); - return; - } - - ui.writeVerbose(`Running: net start "${WINDOWS_SERVICE_NAME}"`); - const startResult = await safeSpawn("net", ["start", WINDOWS_SERVICE_NAME], { - stdio: "pipe", - }); - - if (startResult.status !== 0) { - throw new Error( - `Failed to start service (exit code: ${startResult.status})`, - ); - } -} - /** * @param {string} msiPath */ From 99cd41662846d7182369dbae82dbfa2b712b5644 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 20 Jan 2026 12:53:18 +0100 Subject: [PATCH 118/360] Support Windows in install-safe-shain.sh (git bash, cygwin, ...) --- install-scripts/install-safe-chain.sh | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 7ee07c2..182cdad 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -42,8 +42,9 @@ detect_os() { echo "linuxstatic" fi ;; - Darwin*) echo "macos" ;; - *) error "Unsupported operating system: $(uname -s)" ;; + Darwin*) echo "macos" ;; + MINGW*|MSYS*|CYGWIN*) echo "win" ;; + *) error "Unsupported operating system: $(uname -s)" ;; esac } @@ -293,7 +294,11 @@ main() { # Detect platform OS=$(detect_os) ARCH=$(detect_arch) - BINARY_NAME="safe-chain-${OS}-${ARCH}" + if [ "$OS" = "win" ]; then + BINARY_NAME="safe-chain-${OS}-${ARCH}.exe" + else + BINARY_NAME="safe-chain-${OS}-${ARCH}" + fi info "Detected platform: ${OS}-${ARCH}" @@ -311,9 +316,15 @@ main() { download "$DOWNLOAD_URL" "$TEMP_FILE" # Rename and make executable - FINAL_FILE="${INSTALL_DIR}/safe-chain" + if [ "$OS" = "win" ]; then + FINAL_FILE="${INSTALL_DIR}/safe-chain.exe" + else + FINAL_FILE="${INSTALL_DIR}/safe-chain" + fi mv "$TEMP_FILE" "$FINAL_FILE" || error "Failed to move binary to $FINAL_FILE" - chmod +x "$FINAL_FILE" || error "Failed to make binary executable" + if [ "$OS" != "win" ]; then + chmod +x "$FINAL_FILE" || error "Failed to make binary executable" + fi info "Binary installed to: $FINAL_FILE" From 0d8b919831f50a17c7cc97dc02a6130d6d4405a7 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 20 Jan 2026 13:34:22 +0100 Subject: [PATCH 119/360] Use bash for setting up safe-chain in CI --- .github/workflows/create-artifact.yml | 11 +++-------- .github/workflows/test-on-pr.yml | 11 +++-------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/.github/workflows/create-artifact.yml b/.github/workflows/create-artifact.yml index 00fc58a..9a1702d 100644 --- a/.github/workflows/create-artifact.yml +++ b/.github/workflows/create-artifact.yml @@ -69,14 +69,9 @@ jobs: with: node-version: "20.x" - - name: Setup safe-chain (Mac/Linux) - if: runner.os != 'Windows' - run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci - - - name: Setup safe-chain (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -ci" + - name: Setup safe-chain + run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/0.0.1-windows-install-script-in-git-bash-beta/install-safe-chain.sh | sh -s -- --ci + shell: bash - name: Install dependencies run: npm ci --ignore-scripts diff --git a/.github/workflows/test-on-pr.yml b/.github/workflows/test-on-pr.yml index 9e4a5ec..5d5564e 100644 --- a/.github/workflows/test-on-pr.yml +++ b/.github/workflows/test-on-pr.yml @@ -22,14 +22,9 @@ jobs: with: node-version: "lts/*" - - name: Setup safe-chain (Mac/Linux) - if: runner.os != 'Windows' - run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci - - - name: Setup safe-chain (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -ci" + - name: Setup safe-chain + run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/0.0.1-windows-install-script-in-git-bash-beta/install-safe-chain.sh | sh -s -- --ci + shell: bash - name: Install dependencies run: npm ci --ignore-scripts From a7e21bbfe272e31b11c7aae7c7b1e825090c2aa2 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 21 Jan 2026 07:58:06 +0100 Subject: [PATCH 120/360] Update download urls --- .../src/installation/downloadAgent.js | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/safe-chain/src/installation/downloadAgent.js b/packages/safe-chain/src/installation/downloadAgent.js index 2441f7d..0ee994e 100644 --- a/packages/safe-chain/src/installation/downloadAgent.js +++ b/packages/safe-chain/src/installation/downloadAgent.js @@ -8,14 +8,26 @@ const ULTIMATE_VERSION = "v0.2.0"; const DOWNLOAD_URLS = { win32: { x64: { - url: "https://github.com/AikidoSec/safechain-internals/releases/download/v0.2.0/SafeChainAgent-windows-amd64.msi", + url: "https://github.com/AikidoSec/safechain-internals/releases/download/v0.0.2-macos-release-artifact/SafeChainAgent-windows-amd64.msi", checksum: - "sha256:c699f74a3666d85b70b8ede076a2192a6a023f1b395e8e6c7556927ee698a020", + "sha256:bba5deb250ebc6008f1cb33fa4209d2455a2f47fa99f0a40e3babef64939ac77", }, arm64: { - url: "https://github.com/AikidoSec/safechain-internals/releases/download/v0.2.0/SafeChainAgent-windows-arm64.msi", + url: "https://github.com/AikidoSec/safechain-internals/releases/download/v0.0.2-macos-release-artifact/SafeChainAgent-windows-arm64.msi", checksum: - "sha256:5b08dd4749c8befe5379bc01f7a8a5ac1d6a35b6bee37c6c72a4ba8744c3b052", + "sha256:9553ed15d5efed4185b990a1b86af0b11c23f11d96f8ce04e16b6b98aaf0506e", + }, + }, + darwin: { + x64: { + url: "https://github.com/AikidoSec/safechain-internals/releases/download/v0.0.2-macos-release-artifact/SafeChainAgent-darwin-amd64.pkg", + checksum: + "sha256:cbccf32e987a45bc8cc20b620f7b597ff7f9c2f966c2bc21132349612ddb619f", + }, + arm64: { + url: "https://github.com/AikidoSec/safechain-internals/releases/download/v0.0.2-macos-release-artifact/SafeChainAgent-darwin-arm64.pkg", + checksum: + "sha256:4d53a43a47bf7e8133eb61d306a1fb16348b9ec89c1c825e5f746f4fe847796e", }, }, }; From d4c496d60d625b1ba6886cd8fd312af79bd2afc7 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 21 Jan 2026 09:14:44 +0100 Subject: [PATCH 121/360] Add mac os installation --- .../src/installation/installOnMacOS.js | 78 +++++++++++++++++++ .../src/installation/installUltimate.js | 3 + 2 files changed, 81 insertions(+) create mode 100644 packages/safe-chain/src/installation/installOnMacOS.js diff --git a/packages/safe-chain/src/installation/installOnMacOS.js b/packages/safe-chain/src/installation/installOnMacOS.js new file mode 100644 index 0000000..6963291 --- /dev/null +++ b/packages/safe-chain/src/installation/installOnMacOS.js @@ -0,0 +1,78 @@ +import { tmpdir } from "os"; +import { unlinkSync } from "fs"; +import { join } from "path"; +import { ui } from "../environment/userInteraction.js"; +import { safeSpawn } from "../utils/safeSpawn.js"; +import { downloadAgentToFile, getAgentVersion } from "./downloadAgent.js"; + +const MACOS_SERVICE_LABEL = "com.aikido.SafeChainAgent"; + +export async function installOnMacOS() { + if (!isRunningAsRoot()) { + ui.writeError("Root privileges required."); + ui.writeInformation("Please run this command with sudo:"); + ui.writeInformation(" sudo safe-chain --ultimate"); + return; + } + + const pkgPath = join(tmpdir(), `SafeChainUltimate-${Date.now()}.pkg`); + + ui.emptyLine(); + ui.writeInformation(`📥 Downloading SafeChain Ultimate ${getAgentVersion()}`); + ui.writeVerbose(`Destination: ${pkgPath}`); + + const result = await downloadAgentToFile(pkgPath); + if (!result) { + ui.writeError("No download available for this platform/architecture."); + return; + } + + try { + ui.writeInformation("⚙️ Installing SafeChain Ultimate..."); + await runPkgInstaller(pkgPath); + + ui.emptyLine(); + ui.writeInformation( + "✅ SafeChain Ultimate installed and started successfully!", + ); + ui.emptyLine(); + } finally { + ui.writeVerbose(`Cleaning up temporary file: ${pkgPath}`); + cleanup(pkgPath); + } +} + +function isRunningAsRoot() { + const rootUserUid = 0; + return process.getuid?.() === rootUserUid; +} + +/** + * @param {string} pkgPath + */ +async function runPkgInstaller(pkgPath) { + ui.writeVerbose(`Running: installer -pkg "${pkgPath}" -target /`); + + const result = await safeSpawn( + "installer", + ["-pkg", pkgPath, "-target", "/"], + { + stdio: "inherit", + }, + ); + + if (result.status !== 0) { + throw new Error(`PKG installer failed (exit code: ${result.status})`); + } +} + +/** + * @param {string} pkgPath + */ +function cleanup(pkgPath) { + try { + unlinkSync(pkgPath); + } catch { + ui.writeVerbose("Failed to clean up temporary installer file."); + } +} diff --git a/packages/safe-chain/src/installation/installUltimate.js b/packages/safe-chain/src/installation/installUltimate.js index 086b6a4..a79a2b1 100644 --- a/packages/safe-chain/src/installation/installUltimate.js +++ b/packages/safe-chain/src/installation/installUltimate.js @@ -2,6 +2,7 @@ import { platform } from "os"; import { ui } from "../environment/userInteraction.js"; import { initializeCliArguments } from "../config/cliArguments.js"; import { installOnWindows } from "./installOnWindows.js"; +import { installOnMacOS } from "./installOnMacOS.js"; export async function installUltimate() { initializeCliArguments(process.argv); @@ -10,6 +11,8 @@ export async function installUltimate() { if (operatingSystem === "win32") { await installOnWindows(); + } else if (operatingSystem === "darwin") { + await installOnMacOS(); } else { ui.writeInformation( `${operatingSystem} is not supported yet by SafeChain's ultimate version.`, From b9aade2da40082c95c992f02427fc161be95307a Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 21 Jan 2026 09:18:26 +0100 Subject: [PATCH 122/360] Remove unused variable --- packages/safe-chain/src/installation/installOnMacOS.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/safe-chain/src/installation/installOnMacOS.js b/packages/safe-chain/src/installation/installOnMacOS.js index 6963291..b2a953a 100644 --- a/packages/safe-chain/src/installation/installOnMacOS.js +++ b/packages/safe-chain/src/installation/installOnMacOS.js @@ -5,8 +5,6 @@ import { ui } from "../environment/userInteraction.js"; import { safeSpawn } from "../utils/safeSpawn.js"; import { downloadAgentToFile, getAgentVersion } from "./downloadAgent.js"; -const MACOS_SERVICE_LABEL = "com.aikido.SafeChainAgent"; - export async function installOnMacOS() { if (!isRunningAsRoot()) { ui.writeError("Root privileges required."); From 9cde77a408bcfc97f474ec1c213321ec03a4d612 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 22 Jan 2026 08:20:45 +0100 Subject: [PATCH 123/360] PR comments --- .../src/installation/downloadAgent.js | 18 +++++----- .../src/installation/installOnMacOS.js | 10 ++++-- .../src/installation/installOnWindows.js | 34 ++++++++++++++----- packages/safe-chain/src/utils/safeSpawn.js | 16 +++++++++ 4 files changed, 57 insertions(+), 21 deletions(-) diff --git a/packages/safe-chain/src/installation/downloadAgent.js b/packages/safe-chain/src/installation/downloadAgent.js index 0ee994e..2f2baac 100644 --- a/packages/safe-chain/src/installation/downloadAgent.js +++ b/packages/safe-chain/src/installation/downloadAgent.js @@ -3,31 +3,31 @@ import { createHash } from "crypto"; import { pipeline } from "stream/promises"; import fetch from "make-fetch-happen"; -const ULTIMATE_VERSION = "v0.2.0"; +const ULTIMATE_VERSION = "v0.2.1"; const DOWNLOAD_URLS = { win32: { x64: { - url: "https://github.com/AikidoSec/safechain-internals/releases/download/v0.0.2-macos-release-artifact/SafeChainAgent-windows-amd64.msi", + url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainAgent-windows-amd64.msi`, checksum: - "sha256:bba5deb250ebc6008f1cb33fa4209d2455a2f47fa99f0a40e3babef64939ac77", + "sha256:8d86a44d314746099ba50cfae0cc1eae6232522deb348b226da92aae12754eec", }, arm64: { - url: "https://github.com/AikidoSec/safechain-internals/releases/download/v0.0.2-macos-release-artifact/SafeChainAgent-windows-arm64.msi", + url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainAgent-windows-arm64.msi`, checksum: - "sha256:9553ed15d5efed4185b990a1b86af0b11c23f11d96f8ce04e16b6b98aaf0506e", + "sha256:ab5b8335cc257d53424f73d6681920875083cd9b3f53e52d944bf867a415e027", }, }, darwin: { x64: { - url: "https://github.com/AikidoSec/safechain-internals/releases/download/v0.0.2-macos-release-artifact/SafeChainAgent-darwin-amd64.pkg", + url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainAgent-darwin-amd64.pkg`, checksum: - "sha256:cbccf32e987a45bc8cc20b620f7b597ff7f9c2f966c2bc21132349612ddb619f", + "sha256:73f83d9352c4fd25f7693d9e53bbbb2b7ac70d16217d745495c9efb50dc4a3a6", }, arm64: { - url: "https://github.com/AikidoSec/safechain-internals/releases/download/v0.0.2-macos-release-artifact/SafeChainAgent-darwin-arm64.pkg", + url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainAgent-darwin-arm64.pkg`, checksum: - "sha256:4d53a43a47bf7e8133eb61d306a1fb16348b9ec89c1c825e5f746f4fe847796e", + "sha256:bd419e9c82488539b629b04c97aa1d2dc90e54ff045bd7277a6b40d26f8ebc73", }, }, }; diff --git a/packages/safe-chain/src/installation/installOnMacOS.js b/packages/safe-chain/src/installation/installOnMacOS.js index b2a953a..0d7081c 100644 --- a/packages/safe-chain/src/installation/installOnMacOS.js +++ b/packages/safe-chain/src/installation/installOnMacOS.js @@ -2,7 +2,7 @@ import { tmpdir } from "os"; import { unlinkSync } from "fs"; import { join } from "path"; import { ui } from "../environment/userInteraction.js"; -import { safeSpawn } from "../utils/safeSpawn.js"; +import { printVerboseAndSafeSpawn, safeSpawn } from "../utils/safeSpawn.js"; import { downloadAgentToFile, getAgentVersion } from "./downloadAgent.js"; export async function installOnMacOS() { @@ -49,9 +49,13 @@ function isRunningAsRoot() { * @param {string} pkgPath */ async function runPkgInstaller(pkgPath) { - ui.writeVerbose(`Running: installer -pkg "${pkgPath}" -target /`); + // Uses installer to install the package (https://ss64.com/mac/installer.html) + // Options: + // -pkg (required): The package to be installed. + // -target (required): The target volume is specified with the -target parameter. + // --> "-target /" installs to the current boot volume. - const result = await safeSpawn( + const result = await printVerboseAndSafeSpawn( "installer", ["-pkg", pkgPath, "-target", "/"], { diff --git a/packages/safe-chain/src/installation/installOnWindows.js b/packages/safe-chain/src/installation/installOnWindows.js index 2380a7f..c6bc744 100644 --- a/packages/safe-chain/src/installation/installOnWindows.js +++ b/packages/safe-chain/src/installation/installOnWindows.js @@ -3,7 +3,7 @@ import { unlinkSync } from "fs"; import { join } from "path"; import { execSync } from "child_process"; import { ui } from "../environment/userInteraction.js"; -import { safeSpawn } from "../utils/safeSpawn.js"; +import { printVerboseAndSafeSpawn, safeSpawn } from "../utils/safeSpawn.js"; import { downloadAgentToFile, getAgentVersion } from "./downloadAgent.js"; const WINDOWS_SERVICE_NAME = "SafeChainAgent"; @@ -87,7 +87,12 @@ async function uninstallIfInstalled() { ui.writeInformation("🗑️ Removing previous installation..."); ui.writeVerbose(`Found product code: ${productCode}`); - const uninstallResult = await safeSpawn( + // Use msiexec to run the msi installer quitely (https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/msiexec) + // Options: + // - /x: Uninstalls the package. + // - /qn: Specifies there's no UI during the installation process. + // - /norestart: Stops the device from restarting after the installation completes. + const uninstallResult = await printVerboseAndSafeSpawn( "msiexec", ["/x", productCode, "/qn", "/norestart"], { stdio: "inherit" }, @@ -102,11 +107,18 @@ async function uninstallIfInstalled() { * @param {string} msiPath */ async function runMsiInstaller(msiPath) { - ui.writeVerbose(`Running: msiexec /i "${msiPath}" /qn`); + // Use msiexec to run the msi installer quitely (https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/msiexec) + // Options: + // - /i: Specifies normal installation + // - /qn: Specifies there's no UI during the installation process. - const result = await safeSpawn("msiexec", ["/i", msiPath, "/qn"], { - stdio: "inherit", - }); + const result = await printVerboseAndSafeSpawn( + "msiexec", + ["/i", msiPath, "/qn"], + { + stdio: "inherit", + }, + ); if (result.status !== 0) { throw new Error(`MSI installer failed (exit code: ${result.status})`); @@ -116,9 +128,13 @@ async function runMsiInstaller(msiPath) { async function stopServiceIfRunning() { ui.writeInformation("⏹️ Stopping running service..."); - const result = await safeSpawn("net", ["stop", WINDOWS_SERVICE_NAME], { - stdio: "pipe", - }); + const result = await printVerboseAndSafeSpawn( + "net", + ["stop", WINDOWS_SERVICE_NAME], + { + stdio: "pipe", + }, + ); if (result.status !== 0) { ui.writeVerbose("Service not running (will start after installation)."); diff --git a/packages/safe-chain/src/utils/safeSpawn.js b/packages/safe-chain/src/utils/safeSpawn.js index e17bdb5..69c827a 100644 --- a/packages/safe-chain/src/utils/safeSpawn.js +++ b/packages/safe-chain/src/utils/safeSpawn.js @@ -1,5 +1,6 @@ import { spawn, execSync } from "child_process"; import os from "os"; +import { ui } from "../environment/userInteraction.js"; /** * @param {string} arg @@ -135,3 +136,18 @@ export async function safeSpawn(command, args, options = {}) { }); }); } + +/** + * @param {string} command + * @param {string[]} args + * @param {import("child_process").SpawnOptions} options + * + * @returns {Promise<{status: number, stdout: string, stderr: string}>} + */ +export async function printVerboseAndSafeSpawn(command, args, options = {}) { + ui.writeVerbose(`Running: ${command} ${args.join(" ")}`); + + const result = await safeSpawn(command, args, options); + + return result; +} From b7a5adf67017b455582eba8688b3fc6aaa8154c7 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 22 Jan 2026 09:13:43 +0100 Subject: [PATCH 124/360] Fix linting --- packages/safe-chain/src/installation/installOnMacOS.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/installation/installOnMacOS.js b/packages/safe-chain/src/installation/installOnMacOS.js index 0d7081c..074bbe6 100644 --- a/packages/safe-chain/src/installation/installOnMacOS.js +++ b/packages/safe-chain/src/installation/installOnMacOS.js @@ -2,7 +2,7 @@ import { tmpdir } from "os"; import { unlinkSync } from "fs"; import { join } from "path"; import { ui } from "../environment/userInteraction.js"; -import { printVerboseAndSafeSpawn, safeSpawn } from "../utils/safeSpawn.js"; +import { printVerboseAndSafeSpawn } from "../utils/safeSpawn.js"; import { downloadAgentToFile, getAgentVersion } from "./downloadAgent.js"; export async function installOnMacOS() { From b2d94aaa167b720c78759221e5ebae7a7d3beb10 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 22 Jan 2026 09:18:23 +0100 Subject: [PATCH 125/360] Fix download links --- packages/safe-chain/src/installation/downloadAgent.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/safe-chain/src/installation/downloadAgent.js b/packages/safe-chain/src/installation/downloadAgent.js index 2f2baac..df4a933 100644 --- a/packages/safe-chain/src/installation/downloadAgent.js +++ b/packages/safe-chain/src/installation/downloadAgent.js @@ -8,24 +8,24 @@ const ULTIMATE_VERSION = "v0.2.1"; const DOWNLOAD_URLS = { win32: { x64: { - url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainAgent-windows-amd64.msi`, + url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-windows-amd64.msi`, checksum: "sha256:8d86a44d314746099ba50cfae0cc1eae6232522deb348b226da92aae12754eec", }, arm64: { - url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainAgent-windows-arm64.msi`, + url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-windows-arm64.msi`, checksum: "sha256:ab5b8335cc257d53424f73d6681920875083cd9b3f53e52d944bf867a415e027", }, }, darwin: { x64: { - url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainAgent-darwin-amd64.pkg`, + url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-darwin-amd64.pkg`, checksum: "sha256:73f83d9352c4fd25f7693d9e53bbbb2b7ac70d16217d745495c9efb50dc4a3a6", }, arm64: { - url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainAgent-darwin-arm64.pkg`, + url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-darwin-arm64.pkg`, checksum: "sha256:bd419e9c82488539b629b04c97aa1d2dc90e54ff045bd7277a6b40d26f8ebc73", }, From 09730a07752173ec5d10296b59ee0641f417bb56 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 22 Jan 2026 09:23:17 +0100 Subject: [PATCH 126/360] Update application names on Windows --- packages/safe-chain/src/installation/installOnWindows.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/safe-chain/src/installation/installOnWindows.js b/packages/safe-chain/src/installation/installOnWindows.js index c6bc744..0741fb7 100644 --- a/packages/safe-chain/src/installation/installOnWindows.js +++ b/packages/safe-chain/src/installation/installOnWindows.js @@ -6,8 +6,8 @@ import { ui } from "../environment/userInteraction.js"; import { printVerboseAndSafeSpawn, safeSpawn } from "../utils/safeSpawn.js"; import { downloadAgentToFile, getAgentVersion } from "./downloadAgent.js"; -const WINDOWS_SERVICE_NAME = "SafeChainAgent"; -const WINDOWS_APP_NAME = "SafeChain Agent"; +const WINDOWS_SERVICE_NAME = "SafeChainUltimate"; +const WINDOWS_APP_NAME = "SafeChain Ultimate"; export async function installOnWindows() { if (!(await isRunningAsAdmin())) { From c02d0785fac707816d10f81efb9dbd5fe06a9cf9 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 22 Jan 2026 11:58:52 +0100 Subject: [PATCH 127/360] Fix tests for mitm registryproxy --- .../registryProxy/registryProxy.mitm.spec.js | 132 ++++++++++++++---- 1 file changed, 105 insertions(+), 27 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js b/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js index df4332e..407aa3c 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js @@ -2,12 +2,17 @@ import { before, after, describe, it } from "node:test"; import assert from "node:assert"; import net from "net"; import tls from "tls"; +import { gunzipSync } from "zlib"; import { createSafeChainProxy, mergeSafeChainProxyEnvironmentVariables, } from "./registryProxy.js"; import { getCaCertPath } from "./certUtils.js"; -import { setEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; +import { + setEcoSystem, + ECOSYSTEM_JS, + ECOSYSTEM_PY, +} from "../config/settings.js"; import fs from "fs"; describe("registryProxy.mitm", () => { @@ -33,7 +38,7 @@ describe("registryProxy.mitm", () => { proxyHost, proxyPort, "registry.npmjs.org", - "/lodash" + "/lodash", ); assert.strictEqual(response.statusCode, 200); @@ -45,7 +50,7 @@ describe("registryProxy.mitm", () => { proxyHost, proxyPort, "registry.npmjs.org", - "/lodash/-/lodash-4.17.21.tgz" + "/lodash/-/lodash-4.17.21.tgz", ); // Should get a response (200 or redirect, but not 403 blocked) @@ -57,7 +62,7 @@ describe("registryProxy.mitm", () => { proxyHost, proxyPort, "registry.npmjs.org", - "/this-package-definitely-does-not-exist-12345" + "/this-package-definitely-does-not-exist-12345", ); assert.strictEqual(response.statusCode, 404); @@ -68,7 +73,7 @@ describe("registryProxy.mitm", () => { proxyHost, proxyPort, "registry.npmjs.org", - "/lodash?write=true" + "/lodash?write=true", ); assert.strictEqual(response.statusCode, 200); @@ -79,7 +84,7 @@ describe("registryProxy.mitm", () => { proxyHost, proxyPort, "registry.yarnpkg.com", - "/lodash" + "/lodash", ); assert.strictEqual(response.statusCode, 200); @@ -90,7 +95,7 @@ describe("registryProxy.mitm", () => { proxyHost, proxyPort, "registry.npmjs.org", - "/lodash" + "/lodash", ); // Check certificate common name matches the target hostname @@ -109,14 +114,14 @@ describe("registryProxy.mitm", () => { proxyHost, proxyPort, "registry.npmjs.org", - "/lodash" + "/lodash", ); const { cert: cert2 } = await makeRegistryRequestAndGetCert( proxyHost, proxyPort, "registry.yarnpkg.com", - "/lodash" + "/lodash", ); // Different hostnames should have different certificates @@ -130,14 +135,14 @@ describe("registryProxy.mitm", () => { proxyHost, proxyPort, "registry.npmjs.org", - "/lodash" + "/lodash", ); const { cert: cert2 } = await makeRegistryRequestAndGetCert( proxyHost, proxyPort, "registry.npmjs.org", - "/package/lodash" + "/package/lodash", ); // Same hostname should get the same certificate (fingerprint) @@ -159,7 +164,7 @@ describe("registryProxy.mitm", () => { proxyHost, proxyPort, "pypi.org", - "/packages/source/f/foo_bar/foo_bar-2.0.0.tar.gz" + "/packages/source/f/foo_bar/foo_bar-2.0.0.tar.gz", ); assert.notStrictEqual(response.statusCode, 403); assert.ok(typeof response.body === "string"); @@ -172,7 +177,7 @@ describe("registryProxy.mitm", () => { proxyHost, proxyPort, "files.pythonhosted.org", - "/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl" + "/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl", ); assert.notStrictEqual(response.statusCode, 403); assert.ok(typeof response.body === "string"); @@ -185,7 +190,7 @@ describe("registryProxy.mitm", () => { proxyHost, proxyPort, "pypi.org", - "/packages/source/f/foo_bar/foo_bar-2.0.0a1.tar.gz" + "/packages/source/f/foo_bar/foo_bar-2.0.0a1.tar.gz", ); assert.notStrictEqual(response.statusCode, 403); assert.ok(typeof response.body === "string"); @@ -198,7 +203,7 @@ describe("registryProxy.mitm", () => { proxyHost, proxyPort, "pypi.org", - "/packages/source/f/foo_bar/foo_bar-latest.tar.gz" + "/packages/source/f/foo_bar/foo_bar-latest.tar.gz", ); assert.notStrictEqual(response.statusCode, 403); assert.ok(typeof response.body === "string"); @@ -234,34 +239,73 @@ async function makeRegistryRequest(proxyHost, proxyPort, targetHost, path) { }); // Step 4: Send HTTP request over TLS - const httpRequest = `GET ${path} HTTP/1.1\r\nHost: ${targetHost}\r\nConnection: close\r\n\r\n`; + const httpRequest = `GET ${path} HTTP/1.1\r\nHost: ${targetHost}\r\nConnection: close\r\nAccept-encoding: gzip\r\n\r\n`; tlsSocket.write(httpRequest); - // Step 5: Read response + // Step 5: Read response as binary chunks return new Promise((resolve, reject) => { - let data = ""; + const chunks = []; tlsSocket.on("data", (chunk) => { - data += chunk.toString(); + chunks.push(chunk); }); tlsSocket.on("end", () => { - const lines = data.split("\r\n"); - const statusLine = lines[0]; + const buffer = Buffer.concat(chunks); + + // Find the header/body separator (\r\n\r\n) in binary + const separator = Buffer.from("\r\n\r\n"); + let separatorIndex = buffer.indexOf(separator); + if (separatorIndex === -1) { + return reject( + new Error("Invalid HTTP response: no header/body separator"), + ); + } + + // Extract headers as text + const headersText = buffer.subarray(0, separatorIndex).toString("utf8"); + const headerLines = headersText.split("\r\n"); + const statusLine = headerLines[0]; const statusCode = parseInt(statusLine.split(" ")[1]); - // Find body after empty line - const emptyLineIndex = lines.findIndex(line => line === ""); - const body = lines.slice(emptyLineIndex + 1).join("\r\n"); + // Parse headers into object + const headers = {}; + for (let i = 1; i < headerLines.length; i++) { + const colonIndex = headerLines[i].indexOf(":"); + if (colonIndex > 0) { + const key = headerLines[i].substring(0, colonIndex).toLowerCase(); + const value = headerLines[i].substring(colonIndex + 1).trim(); + headers[key] = value; + } + } - resolve({ statusCode, body }); + // Extract body as binary + let bodyBuffer = buffer.subarray(separatorIndex + separator.length); + + // Decode chunked transfer encoding if present + if (headers["transfer-encoding"] === "chunked") { + bodyBuffer = decodeChunked(bodyBuffer); + } + + // Decompress if gzip encoded + if (headers["content-encoding"] === "gzip" && bodyBuffer.length > 0) { + bodyBuffer = gunzipSync(bodyBuffer); + } + + const body = bodyBuffer.toString("utf8"); + resolve({ statusCode, body, headers }); }); tlsSocket.on("error", reject); }); } -async function makeRegistryRequestAndGetCert(proxyHost, proxyPort, targetHost, path) { +async function makeRegistryRequestAndGetCert( + proxyHost, + proxyPort, + targetHost, + path, +) { // Step 1: Connect to proxy const socket = await new Promise((resolve, reject) => { const sock = net.connect({ host: proxyHost, port: proxyPort }, () => { @@ -311,7 +355,7 @@ async function makeRegistryRequestAndGetCert(proxyHost, proxyPort, targetHost, p const statusCode = parseInt(statusLine.split(" ")[1]); // Find body after empty line - const emptyLineIndex = lines.findIndex(line => line === ""); + const emptyLineIndex = lines.findIndex((line) => line === ""); const body = lines.slice(emptyLineIndex + 1).join("\r\n"); resolve({ statusCode, body }); @@ -322,3 +366,37 @@ async function makeRegistryRequestAndGetCert(proxyHost, proxyPort, targetHost, p return { cert: peerCert, response }; } + +/** + * Decode HTTP chunked transfer encoding + * Format: \r\n\r\n ... 0\r\n\r\n + * @param {Buffer} buffer + * @returns {Buffer} + */ +function decodeChunked(buffer) { + const chunks = []; + let offset = 0; + + while (offset < buffer.length) { + // Find the end of the chunk size line + const lineEnd = buffer.indexOf(Buffer.from("\r\n"), offset); + if (lineEnd === -1) break; + + // Parse chunk size (hex) + const sizeHex = buffer.subarray(offset, lineEnd).toString("utf8"); + const chunkSize = parseInt(sizeHex, 16); + + // End of chunks + if (chunkSize === 0) break; + + // Extract chunk data + const dataStart = lineEnd + 2; + const dataEnd = dataStart + chunkSize; + chunks.push(buffer.subarray(dataStart, dataEnd)); + + // Move past chunk data and trailing \r\n + offset = dataEnd + 2; + } + + return Buffer.concat(chunks); +} From f825f84faa88f6e578663674368fc06fe0ccac6e Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 22 Jan 2026 12:51:25 +0100 Subject: [PATCH 128/360] Add message about the certificate popup --- .../safe-chain/src/installation/installOnMacOS.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/safe-chain/src/installation/installOnMacOS.js b/packages/safe-chain/src/installation/installOnMacOS.js index 074bbe6..0d475b1 100644 --- a/packages/safe-chain/src/installation/installOnMacOS.js +++ b/packages/safe-chain/src/installation/installOnMacOS.js @@ -4,6 +4,7 @@ import { join } from "path"; import { ui } from "../environment/userInteraction.js"; import { printVerboseAndSafeSpawn } from "../utils/safeSpawn.js"; import { downloadAgentToFile, getAgentVersion } from "./downloadAgent.js"; +import chalk from "chalk"; export async function installOnMacOS() { if (!isRunningAsRoot()) { @@ -34,6 +35,17 @@ export async function installOnMacOS() { "✅ SafeChain Ultimate installed and started successfully!", ); ui.emptyLine(); + ui.writeInformation( + chalk.cyan("🔐 ") + + chalk.bold("ACTION REQUIRED: ") + + "macOS will show a popup to install our certificate.", + ); + ui.writeInformation( + " " + + chalk.bold("Please accept the certificate") + + " to complete the installation.", + ); + ui.emptyLine(); } finally { ui.writeVerbose(`Cleaning up temporary file: ${pkgPath}`); cleanup(pkgPath); From 309d7df050c636da03ef04757e1f056635710c12 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 27 Jan 2026 07:42:36 +0100 Subject: [PATCH 129/360] Don't insert empty line in rc file when it already ends with an empty line --- packages/safe-chain/src/shell-integration/helpers.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index 064aca1..3e71d71 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -99,7 +99,7 @@ export const knownAikidoTools = [ aikidoCommand: "aikido-pipx", ecoSystem: ECOSYSTEM_PY, internalPackageManagerName: "pipx", - } + }, // When adding a new tool here, also update the documentation for the new tool in the README.md ]; @@ -216,7 +216,13 @@ export function addLineToFile(filePath, line, eol) { eol = eol || os.EOL; const fileContent = fs.readFileSync(filePath, "utf-8"); - const updatedContent = fileContent + eol + line + eol; + let updatedContent = fileContent; + + if (!fileContent.endsWith(eol)) { + updatedContent += eol; + } + + updatedContent += line + eol; fs.writeFileSync(filePath, updatedContent, "utf-8"); } From 1058630dd1773b2e2281d5e1e2370d6061d7b0cd Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 27 Jan 2026 11:29:19 +0100 Subject: [PATCH 130/360] Add uninstallation process for ultimate --- packages/safe-chain/bin/safe-chain.js | 14 ++- .../src/installation/installOnMacOS.js | 86 ++++++++++++++++++- .../src/installation/installOnWindows.js | 59 +++++++++++-- .../src/installation/installUltimate.js | 20 ++++- 4 files changed, 167 insertions(+), 12 deletions(-) diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index d048ce1..06add7e 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -16,7 +16,10 @@ import path from "path"; import { fileURLToPath } from "url"; import fs from "fs"; import { knownAikidoTools } from "../src/shell-integration/helpers.js"; -import { installUltimate } from "../src/installation/installUltimate.js"; +import { + installUltimate, + uninstallUltimate, +} from "../src/installation/installUltimate.js"; /** @type {string} */ // This checks the current file's dirname in a way that's compatible with: @@ -67,6 +70,10 @@ if (tool) { (async () => { await installUltimate(); })(); +} else if (command === "--uninstall-ultimate") { + (async () => { + await uninstallUltimate(); + })(); } else if (command === "teardown") { teardownDirectories(); teardown(); @@ -108,6 +115,11 @@ function writeHelp() { "safe-chain --ultimate", )}: This installs the ultimate version of safe-chain, enabling protection for more eco-systems (vscode).`, ); + ui.writeInformation( + `- ${chalk.cyan( + "safe-chain --uninstall-ultimate", + )}: This uninstalls the ultimate version of safe-chain.`, + ); ui.writeInformation( `- ${chalk.cyan( "safe-chain teardown", diff --git a/packages/safe-chain/src/installation/installOnMacOS.js b/packages/safe-chain/src/installation/installOnMacOS.js index 0d475b1..b2d39ce 100644 --- a/packages/safe-chain/src/installation/installOnMacOS.js +++ b/packages/safe-chain/src/installation/installOnMacOS.js @@ -1,11 +1,14 @@ import { tmpdir } from "os"; -import { unlinkSync } from "fs"; +import { unlinkSync, rmSync } from "fs"; import { join } from "path"; +import { execSync } from "child_process"; import { ui } from "../environment/userInteraction.js"; import { printVerboseAndSafeSpawn } from "../utils/safeSpawn.js"; import { downloadAgentToFile, getAgentVersion } from "./downloadAgent.js"; import chalk from "chalk"; +const MACOS_PKG_IDENTIFIER = "com.aikidosecurity.safechainultimate"; + export async function installOnMacOS() { if (!isRunningAsRoot()) { ui.writeError("Root privileges required."); @@ -52,6 +55,87 @@ export async function installOnMacOS() { } } +export async function uninstallOnMacOS() { + if (!isRunningAsRoot()) { + ui.writeError("Root privileges required."); + ui.writeInformation("Please run this command with sudo:"); + ui.writeInformation(" sudo safe-chain --uninstall-ultimate"); + return; + } + + ui.emptyLine(); + + if (!isPackageInstalled()) { + ui.writeInformation("SafeChain Ultimate is not installed."); + return; + } + + ui.writeInformation("⏹️ Stopping service..."); + await stopService(); + + ui.writeInformation("🗑️ Removing installed files..."); + removeKnownFiles(); + + ui.writeInformation("🧹 Forgetting package receipt..."); + forgetPackage(); + + ui.emptyLine(); + ui.writeInformation("✅ SafeChain Ultimate has been uninstalled."); + ui.emptyLine(); +} + +function isPackageInstalled() { + try { + const output = execSync(`pkgutil --pkg-info ${MACOS_PKG_IDENTIFIER}`, { + encoding: "utf8", + stdio: "pipe", + }); + return output.includes(MACOS_PKG_IDENTIFIER); + } catch { + return false; + } +} + +async function stopService() { + const result = await printVerboseAndSafeSpawn( + "launchctl", + ["bootout", `system/${MACOS_PKG_IDENTIFIER}`], + { stdio: "pipe" }, + ); + + if (result.status !== 0) { + ui.writeVerbose("Service not running (will continue with uninstall)."); + } +} + +const MACOS_KNOWN_PATHS = [ + "/Library/Application Support/AikidoSecurity/SafeChainUltimate", + "/Library/Logs/AikidoSecurity/SafeChainUltimate", + `/Library/LaunchDaemons/${MACOS_PKG_IDENTIFIER}.plist`, +]; + +function removeKnownFiles() { + for (const filePath of MACOS_KNOWN_PATHS) { + try { + rmSync(filePath, { recursive: true, force: true }); + ui.writeVerbose(`Removed: ${filePath}`); + } catch { + ui.writeVerbose(`Failed to remove: ${filePath}`); + } + } +} + +function forgetPackage() { + try { + execSync(`pkgutil --forget ${MACOS_PKG_IDENTIFIER}`, { + encoding: "utf8", + stdio: "pipe", + }); + } catch { + ui.writeVerbose("Failed to forget package receipt."); + } +} + function isRunningAsRoot() { const rootUserUid = 0; return process.getuid?.() === rootUserUid; diff --git a/packages/safe-chain/src/installation/installOnWindows.js b/packages/safe-chain/src/installation/installOnWindows.js index 0741fb7..16bf2b7 100644 --- a/packages/safe-chain/src/installation/installOnWindows.js +++ b/packages/safe-chain/src/installation/installOnWindows.js @@ -9,6 +9,34 @@ import { downloadAgentToFile, getAgentVersion } from "./downloadAgent.js"; const WINDOWS_SERVICE_NAME = "SafeChainUltimate"; const WINDOWS_APP_NAME = "SafeChain Ultimate"; +export async function uninstallOnWindows() { + if (!(await isRunningAsAdmin())) { + ui.writeError("Administrator privileges required."); + ui.writeInformation( + "Please run this command in an elevated terminal (Run as Administrator).", + ); + return; + } + + ui.emptyLine(); + + const productCode = getInstalledProductCode(); + if (!productCode) { + ui.writeInformation("SafeChain Ultimate is not installed."); + return; + } + + ui.writeInformation("⏹️ Stopping running service..."); + await stopServiceIfRunning(); + + ui.writeInformation("🗑️ Uninstalling SafeChain Ultimate..."); + await uninstallByProductCode(productCode); + + ui.emptyLine(); + ui.writeInformation("✅ SafeChain Ultimate has been uninstalled."); + ui.emptyLine(); +} + export async function installOnWindows() { if (!(await isRunningAsAdmin())) { ui.writeError("Administrator privileges required."); @@ -64,7 +92,11 @@ async function isRunningAsAdmin() { return result.status === 0 && result.stdout.trim() === "True"; } -async function uninstallIfInstalled() { +/** + * Returns the MSI product code for SafeChain Ultimate, or null if not installed. + * @returns {string | null} + */ +function getInstalledProductCode() { // Query Win32_Product via WMI to find the installed SafeChain Agent. // If found, outputs the product GUID (e.g., "{12345678-1234-...}") needed for msiexec uninstall. ui.writeVerbose(`Finding product code with PowerShell`); @@ -76,15 +108,15 @@ async function uninstallIfInstalled() { { encoding: "utf8" }, ).trim(); } catch { - ui.writeVerbose("No existing installation found (fresh install)."); - return; - } - if (!productCode) { - ui.writeVerbose("No existing installation found (fresh install)."); - return; + return null; } + return productCode || null; +} - ui.writeInformation("🗑️ Removing previous installation..."); +/** + * @param {string} productCode + */ +async function uninstallByProductCode(productCode) { ui.writeVerbose(`Found product code: ${productCode}`); // Use msiexec to run the msi installer quitely (https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/msiexec) @@ -103,6 +135,17 @@ async function uninstallIfInstalled() { } } +async function uninstallIfInstalled() { + const productCode = getInstalledProductCode(); + if (!productCode) { + ui.writeVerbose("No existing installation found (fresh install)."); + return; + } + + ui.writeInformation("🗑️ Removing previous installation..."); + await uninstallByProductCode(productCode); +} + /** * @param {string} msiPath */ diff --git a/packages/safe-chain/src/installation/installUltimate.js b/packages/safe-chain/src/installation/installUltimate.js index a79a2b1..cfcdcca 100644 --- a/packages/safe-chain/src/installation/installUltimate.js +++ b/packages/safe-chain/src/installation/installUltimate.js @@ -1,8 +1,24 @@ import { platform } from "os"; import { ui } from "../environment/userInteraction.js"; import { initializeCliArguments } from "../config/cliArguments.js"; -import { installOnWindows } from "./installOnWindows.js"; -import { installOnMacOS } from "./installOnMacOS.js"; +import { installOnWindows, uninstallOnWindows } from "./installOnWindows.js"; +import { installOnMacOS, uninstallOnMacOS } from "./installOnMacOS.js"; + +export async function uninstallUltimate() { + initializeCliArguments(process.argv); + + const operatingSystem = platform(); + + if (operatingSystem === "win32") { + await uninstallOnWindows(); + } else if (operatingSystem === "darwin") { + await uninstallOnMacOS(); + } else { + ui.writeInformation( + `Uninstall is not yet supported on ${operatingSystem}.`, + ); + } +} export async function installUltimate() { initializeCliArguments(process.argv); From af4bbb10fcd0b24d168a90680bb8522a88235235 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 27 Jan 2026 12:41:11 +0100 Subject: [PATCH 131/360] Bump safe-chain-internals version --- packages/safe-chain/src/installation/downloadAgent.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/safe-chain/src/installation/downloadAgent.js b/packages/safe-chain/src/installation/downloadAgent.js index df4a933..4d076ee 100644 --- a/packages/safe-chain/src/installation/downloadAgent.js +++ b/packages/safe-chain/src/installation/downloadAgent.js @@ -3,31 +3,31 @@ import { createHash } from "crypto"; import { pipeline } from "stream/promises"; import fetch from "make-fetch-happen"; -const ULTIMATE_VERSION = "v0.2.1"; +const ULTIMATE_VERSION = "v0.2.2"; const DOWNLOAD_URLS = { win32: { x64: { url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-windows-amd64.msi`, checksum: - "sha256:8d86a44d314746099ba50cfae0cc1eae6232522deb348b226da92aae12754eec", + "sha256:82d6939579c23c357d0f6d368001a5ac8dc66ce13d32ee1700467555ee97e10a", }, arm64: { url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-windows-arm64.msi`, checksum: - "sha256:ab5b8335cc257d53424f73d6681920875083cd9b3f53e52d944bf867a415e027", + "sha256:d626da40e3d0c4e02a36e6c7e309f18f0ffde64e97a4f2fefd4b25722842ac19", }, }, darwin: { x64: { url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-darwin-amd64.pkg`, checksum: - "sha256:73f83d9352c4fd25f7693d9e53bbbb2b7ac70d16217d745495c9efb50dc4a3a6", + "sha256:d7c31914deff8b332bf3d0e18ed00660e47ace87f06f22606c7866f7e0809507", }, arm64: { url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-darwin-arm64.pkg`, checksum: - "sha256:bd419e9c82488539b629b04c97aa1d2dc90e54ff045bd7277a6b40d26f8ebc73", + "sha256:73b092689e00c98e3c376afa50fc3477cedfd01445a113d42b36c5fcd956a6f4", }, }, }; From 12caa6d1d43c9d684bee1bcb467300861e12da85 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 27 Jan 2026 12:44:47 +0100 Subject: [PATCH 132/360] Verify download links in a test --- .../src/installation/downloadAgent.js | 4 +- .../src/installation/downloadAgent.spec.js | 45 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 packages/safe-chain/src/installation/downloadAgent.spec.js diff --git a/packages/safe-chain/src/installation/downloadAgent.js b/packages/safe-chain/src/installation/downloadAgent.js index 4d076ee..cb2f84b 100644 --- a/packages/safe-chain/src/installation/downloadAgent.js +++ b/packages/safe-chain/src/installation/downloadAgent.js @@ -5,7 +5,7 @@ import fetch from "make-fetch-happen"; const ULTIMATE_VERSION = "v0.2.2"; -const DOWNLOAD_URLS = { +export const DOWNLOAD_URLS = { win32: { x64: { url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-windows-amd64.msi`, @@ -87,7 +87,7 @@ export function getDownloadInfoForCurrentPlatform() { * @param {string} expectedChecksum - Format: "algorithm:hash" (e.g., "sha256:abc123...") * @returns {Promise} */ -async function verifyChecksum(filePath, expectedChecksum) { +export async function verifyChecksum(filePath, expectedChecksum) { const [algorithm, expected] = expectedChecksum.split(":"); const hash = createHash(algorithm); diff --git a/packages/safe-chain/src/installation/downloadAgent.spec.js b/packages/safe-chain/src/installation/downloadAgent.spec.js new file mode 100644 index 0000000..17aecb9 --- /dev/null +++ b/packages/safe-chain/src/installation/downloadAgent.spec.js @@ -0,0 +1,45 @@ +import { describe, it, after } from "node:test"; +import assert from "node:assert"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { unlinkSync } from "node:fs"; +import { + DOWNLOAD_URLS, + downloadFile, + verifyChecksum, +} from "./downloadAgent.js"; + +describe("downloadAgent checksums", { timeout: 120_000 }, () => { + const downloadedFiles = []; + + after(() => { + for (const file of downloadedFiles) { + try { + unlinkSync(file); + } catch { + // ignore cleanup errors + } + } + }); + + for (const [platform, architectures] of Object.entries(DOWNLOAD_URLS)) { + for (const [arch, { url, checksum }] of Object.entries(architectures)) { + it(`${platform}/${arch} checksum matches`, async () => { + const destPath = join( + tmpdir(), + `safe-chain-test-${platform}-${arch}-${Date.now()}` + ); + downloadedFiles.push(destPath); + + await downloadFile(url, destPath); + + const isValid = await verifyChecksum(destPath, checksum); + assert.strictEqual( + isValid, + true, + `Checksum mismatch for ${platform}/${arch} (${url})` + ); + }); + } + } +}); From a016483057a4605e094c94ace3f0f9c1d1c1ec2d Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 27 Jan 2026 12:57:40 +0100 Subject: [PATCH 133/360] Remove duplicate "Stopping running service" log --- packages/safe-chain/src/installation/installOnWindows.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/safe-chain/src/installation/installOnWindows.js b/packages/safe-chain/src/installation/installOnWindows.js index 16bf2b7..f20bd9b 100644 --- a/packages/safe-chain/src/installation/installOnWindows.js +++ b/packages/safe-chain/src/installation/installOnWindows.js @@ -26,7 +26,6 @@ export async function uninstallOnWindows() { return; } - ui.writeInformation("⏹️ Stopping running service..."); await stopServiceIfRunning(); ui.writeInformation("🗑️ Uninstalling SafeChain Ultimate..."); From 7218d778cfabb98ad2698c6f50fce4c3541d1da5 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 27 Jan 2026 13:06:17 +0100 Subject: [PATCH 134/360] Update commands for ultimate --- packages/safe-chain/bin/safe-chain.js | 44 +++++++++++-------- .../src/installation/installOnMacOS.js | 4 +- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index 06add7e..9a07657 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -66,14 +66,17 @@ if (tool) { process.exit(0); } else if (command === "setup") { setup(); -} else if (command === "--ultimate") { - (async () => { - await installUltimate(); - })(); -} else if (command === "--uninstall-ultimate") { - (async () => { - await uninstallUltimate(); - })(); +} else if (command === "ultimate") { + const subCommand = process.argv[3]; + if (subCommand === "uninstall") { + (async () => { + await uninstallUltimate(); + })(); + } else { + (async () => { + await installUltimate(); + })(); + } } else if (command === "teardown") { teardownDirectories(); teardown(); @@ -100,7 +103,7 @@ function writeHelp() { ui.writeInformation( `Available commands: ${chalk.cyan("setup")}, ${chalk.cyan( "teardown", - )}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("help")}, ${chalk.cyan( + )}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("ultimate")}, ${chalk.cyan("help")}, ${chalk.cyan( "--version", )}`, ); @@ -110,16 +113,6 @@ function writeHelp() { "safe-chain setup", )}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, bun, bunx, pip and pip3.`, ); - ui.writeInformation( - `- ${chalk.cyan( - "safe-chain --ultimate", - )}: This installs the ultimate version of safe-chain, enabling protection for more eco-systems (vscode).`, - ); - ui.writeInformation( - `- ${chalk.cyan( - "safe-chain --uninstall-ultimate", - )}: This uninstalls the ultimate version of safe-chain.`, - ); ui.writeInformation( `- ${chalk.cyan( "safe-chain teardown", @@ -136,6 +129,19 @@ function writeHelp() { )}): Display the current version of safe-chain.`, ); ui.emptyLine(); + ui.writeInformation(chalk.bold("Ultimate commands:")); + ui.emptyLine(); + ui.writeInformation( + `- ${chalk.cyan( + "safe-chain ultimate", + )}: Install the ultimate version of safe-chain, enabling protection for more eco-systems.`, + ); + ui.writeInformation( + `- ${chalk.cyan( + "safe-chain ultimate uninstall", + )}: Uninstall the ultimate version of safe-chain.`, + ); + ui.emptyLine(); } async function getVersion() { diff --git a/packages/safe-chain/src/installation/installOnMacOS.js b/packages/safe-chain/src/installation/installOnMacOS.js index b2d39ce..018b911 100644 --- a/packages/safe-chain/src/installation/installOnMacOS.js +++ b/packages/safe-chain/src/installation/installOnMacOS.js @@ -13,7 +13,7 @@ export async function installOnMacOS() { if (!isRunningAsRoot()) { ui.writeError("Root privileges required."); ui.writeInformation("Please run this command with sudo:"); - ui.writeInformation(" sudo safe-chain --ultimate"); + ui.writeInformation(" sudo safe-chain ultimate"); return; } @@ -59,7 +59,7 @@ export async function uninstallOnMacOS() { if (!isRunningAsRoot()) { ui.writeError("Root privileges required."); ui.writeInformation("Please run this command with sudo:"); - ui.writeInformation(" sudo safe-chain --uninstall-ultimate"); + ui.writeInformation(" sudo safe-chain ultimate uninstall"); return; } From a3ab80b8b44cd232bbbeb36c849561a38ce56d59 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 28 Jan 2026 07:53:39 +0100 Subject: [PATCH 135/360] PR comment: extract requireRootPrivileges / requireAdminPrivileges into separate function --- .../src/installation/installOnMacOS.js | 36 ++++++++++++------- .../src/installation/installOnWindows.js | 28 +++++++++------ 2 files changed, 41 insertions(+), 23 deletions(-) diff --git a/packages/safe-chain/src/installation/installOnMacOS.js b/packages/safe-chain/src/installation/installOnMacOS.js index 018b911..21f8f1d 100644 --- a/packages/safe-chain/src/installation/installOnMacOS.js +++ b/packages/safe-chain/src/installation/installOnMacOS.js @@ -9,11 +9,29 @@ import chalk from "chalk"; const MACOS_PKG_IDENTIFIER = "com.aikidosecurity.safechainultimate"; +/** + * Checks if root privileges are available and displays error message if not. + * @param {string} command - The sudo command to show in the error message + * @returns {boolean} True if running as root, false otherwise. + */ +function requireRootPrivileges(command) { + if (isRunningAsRoot()) { + return true; + } + + ui.writeError("Root privileges required."); + ui.writeInformation("Please run this command with sudo:"); + ui.writeInformation(` ${command}`); + return false; +} + +function isRunningAsRoot() { + const rootUserUid = 0; + return process.getuid?.() === rootUserUid; +} + export async function installOnMacOS() { - if (!isRunningAsRoot()) { - ui.writeError("Root privileges required."); - ui.writeInformation("Please run this command with sudo:"); - ui.writeInformation(" sudo safe-chain ultimate"); + if (!requireRootPrivileges("sudo safe-chain ultimate")) { return; } @@ -56,10 +74,7 @@ export async function installOnMacOS() { } export async function uninstallOnMacOS() { - if (!isRunningAsRoot()) { - ui.writeError("Root privileges required."); - ui.writeInformation("Please run this command with sudo:"); - ui.writeInformation(" sudo safe-chain ultimate uninstall"); + if (!requireRootPrivileges("sudo safe-chain ultimate uninstall")) { return; } @@ -136,11 +151,6 @@ function forgetPackage() { } } -function isRunningAsRoot() { - const rootUserUid = 0; - return process.getuid?.() === rootUserUid; -} - /** * @param {string} pkgPath */ diff --git a/packages/safe-chain/src/installation/installOnWindows.js b/packages/safe-chain/src/installation/installOnWindows.js index f20bd9b..4cee911 100644 --- a/packages/safe-chain/src/installation/installOnWindows.js +++ b/packages/safe-chain/src/installation/installOnWindows.js @@ -10,11 +10,7 @@ const WINDOWS_SERVICE_NAME = "SafeChainUltimate"; const WINDOWS_APP_NAME = "SafeChain Ultimate"; export async function uninstallOnWindows() { - if (!(await isRunningAsAdmin())) { - ui.writeError("Administrator privileges required."); - ui.writeInformation( - "Please run this command in an elevated terminal (Run as Administrator).", - ); + if (!(await requireAdminPrivileges())) { return; } @@ -37,11 +33,7 @@ export async function uninstallOnWindows() { } export async function installOnWindows() { - if (!(await isRunningAsAdmin())) { - ui.writeError("Administrator privileges required."); - ui.writeInformation( - "Please run this command in an elevated terminal (Run as Administrator).", - ); + if (!(await requireAdminPrivileges())) { return; } @@ -76,6 +68,22 @@ export async function installOnWindows() { } } +/** + * Checks if admin privileges are available and displays error message if not. + * @returns {Promise} True if running as admin, false otherwise. + */ +async function requireAdminPrivileges() { + if (await isRunningAsAdmin()) { + return true; + } + + ui.writeError("Administrator privileges required."); + ui.writeInformation( + "Please run this command in an elevated terminal (Run as Administrator).", + ); + return false; +} + async function isRunningAsAdmin() { // Uses Windows Security API to check if current process has admin privileges. // Returns "True" or "False" as a string. From 57c090c3a773857480d997b4995116e2b3324981 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 28 Jan 2026 07:54:35 +0100 Subject: [PATCH 136/360] Rename output --- packages/safe-chain/src/installation/installOnMacOS.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/installation/installOnMacOS.js b/packages/safe-chain/src/installation/installOnMacOS.js index 21f8f1d..ab20a8a 100644 --- a/packages/safe-chain/src/installation/installOnMacOS.js +++ b/packages/safe-chain/src/installation/installOnMacOS.js @@ -88,7 +88,7 @@ export async function uninstallOnMacOS() { ui.writeInformation("⏹️ Stopping service..."); await stopService(); - ui.writeInformation("🗑️ Removing installed files..."); + ui.writeInformation("🗑️ Removing files..."); removeKnownFiles(); ui.writeInformation("🧹 Forgetting package receipt..."); From aa6553716d056fe58a1a2d55fbbb33c98bc1f39f Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 28 Jan 2026 15:33:45 +0100 Subject: [PATCH 137/360] Mac: use uninstaller script --- .../src/installation/downloadAgent.js | 10 +-- .../src/installation/installOnMacOS.js | 65 +++++-------------- 2 files changed, 22 insertions(+), 53 deletions(-) diff --git a/packages/safe-chain/src/installation/downloadAgent.js b/packages/safe-chain/src/installation/downloadAgent.js index cb2f84b..a5dcb0d 100644 --- a/packages/safe-chain/src/installation/downloadAgent.js +++ b/packages/safe-chain/src/installation/downloadAgent.js @@ -3,31 +3,31 @@ import { createHash } from "crypto"; import { pipeline } from "stream/promises"; import fetch from "make-fetch-happen"; -const ULTIMATE_VERSION = "v0.2.2"; +const ULTIMATE_VERSION = "v0.2.3"; export const DOWNLOAD_URLS = { win32: { x64: { url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-windows-amd64.msi`, checksum: - "sha256:82d6939579c23c357d0f6d368001a5ac8dc66ce13d32ee1700467555ee97e10a", + "sha256:bd196ae05b876588f828a57c4d19b3e7ad96ba40007cf2b36693dc6e792d28cc", }, arm64: { url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-windows-arm64.msi`, checksum: - "sha256:d626da40e3d0c4e02a36e6c7e309f18f0ffde64e97a4f2fefd4b25722842ac19", + "sha256:79e046f24405e869494291e77c6d8640c8dc58d2ac1db87d3038e9eb8afbdc8b", }, }, darwin: { x64: { url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-darwin-amd64.pkg`, checksum: - "sha256:d7c31914deff8b332bf3d0e18ed00660e47ace87f06f22606c7866f7e0809507", + "sha256:99868cb663eef44d063d995d2dcc063f55b10eb719ee945d05fe8cf5fef5e2a5", }, arm64: { url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-darwin-arm64.pkg`, checksum: - "sha256:73b092689e00c98e3c376afa50fc3477cedfd01445a113d42b36c5fcd956a6f4", + "sha256:000b334c2eb85d8692be5d23af73f8b9fb686c9db726992223187b341ea79306", }, }, }; diff --git a/packages/safe-chain/src/installation/installOnMacOS.js b/packages/safe-chain/src/installation/installOnMacOS.js index ab20a8a..ae4fea3 100644 --- a/packages/safe-chain/src/installation/installOnMacOS.js +++ b/packages/safe-chain/src/installation/installOnMacOS.js @@ -1,7 +1,7 @@ import { tmpdir } from "os"; -import { unlinkSync, rmSync } from "fs"; +import { unlinkSync } from "fs"; import { join } from "path"; -import { execSync } from "child_process"; +import { execSync, spawnSync } from "child_process"; import { ui } from "../environment/userInteraction.js"; import { printVerboseAndSafeSpawn } from "../utils/safeSpawn.js"; import { downloadAgentToFile, getAgentVersion } from "./downloadAgent.js"; @@ -73,6 +73,9 @@ export async function installOnMacOS() { } } +const MACOS_UNINSTALL_SCRIPT = + "/Library/Application Support/AikidoSecurity/SafeChainUltimate/scripts/uninstall"; + export async function uninstallOnMacOS() { if (!requireRootPrivileges("sudo safe-chain ultimate uninstall")) { return; @@ -85,14 +88,20 @@ export async function uninstallOnMacOS() { return; } - ui.writeInformation("⏹️ Stopping service..."); - await stopService(); + ui.writeInformation("🗑️ Uninstalling SafeChain Ultimate..."); + ui.writeVerbose(`Running: ${MACOS_UNINSTALL_SCRIPT}`); - ui.writeInformation("🗑️ Removing files..."); - removeKnownFiles(); + const result = spawnSync(MACOS_UNINSTALL_SCRIPT, { + stdio: "inherit", + shell: true, + }); - ui.writeInformation("🧹 Forgetting package receipt..."); - forgetPackage(); + if (result.status !== 0) { + ui.writeError( + `Uninstall script failed (exit code: ${result.status}). Please try again or remove manually.`, + ); + return; + } ui.emptyLine(); ui.writeInformation("✅ SafeChain Ultimate has been uninstalled."); @@ -111,46 +120,6 @@ function isPackageInstalled() { } } -async function stopService() { - const result = await printVerboseAndSafeSpawn( - "launchctl", - ["bootout", `system/${MACOS_PKG_IDENTIFIER}`], - { stdio: "pipe" }, - ); - - if (result.status !== 0) { - ui.writeVerbose("Service not running (will continue with uninstall)."); - } -} - -const MACOS_KNOWN_PATHS = [ - "/Library/Application Support/AikidoSecurity/SafeChainUltimate", - "/Library/Logs/AikidoSecurity/SafeChainUltimate", - `/Library/LaunchDaemons/${MACOS_PKG_IDENTIFIER}.plist`, -]; - -function removeKnownFiles() { - for (const filePath of MACOS_KNOWN_PATHS) { - try { - rmSync(filePath, { recursive: true, force: true }); - ui.writeVerbose(`Removed: ${filePath}`); - } catch { - ui.writeVerbose(`Failed to remove: ${filePath}`); - } - } -} - -function forgetPackage() { - try { - execSync(`pkgutil --forget ${MACOS_PKG_IDENTIFIER}`, { - encoding: "utf8", - stdio: "pipe", - }); - } catch { - ui.writeVerbose("Failed to forget package receipt."); - } -} - /** * @param {string} pkgPath */ From e36b7e80b427b55d2444cb704894c7fd9dfe577c Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 28 Jan 2026 15:42:15 +0100 Subject: [PATCH 138/360] Fix uninstall script --- packages/safe-chain/src/installation/installOnMacOS.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/installation/installOnMacOS.js b/packages/safe-chain/src/installation/installOnMacOS.js index ae4fea3..22ce1a8 100644 --- a/packages/safe-chain/src/installation/installOnMacOS.js +++ b/packages/safe-chain/src/installation/installOnMacOS.js @@ -74,7 +74,7 @@ export async function installOnMacOS() { } const MACOS_UNINSTALL_SCRIPT = - "/Library/Application Support/AikidoSecurity/SafeChainUltimate/scripts/uninstall"; + "/Library/Application\\ Support/AikidoSecurity/SafeChainUltimate/scripts/uninstall"; export async function uninstallOnMacOS() { if (!requireRootPrivileges("sudo safe-chain ultimate uninstall")) { From 4ccdd9fef6ba2df1dff75d81404fbf2a3e9ed014 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 29 Jan 2026 17:06:39 +0100 Subject: [PATCH 139/360] Bump agent version to v1.0.0 --- packages/safe-chain/src/installation/downloadAgent.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/safe-chain/src/installation/downloadAgent.js b/packages/safe-chain/src/installation/downloadAgent.js index a5dcb0d..297908a 100644 --- a/packages/safe-chain/src/installation/downloadAgent.js +++ b/packages/safe-chain/src/installation/downloadAgent.js @@ -3,31 +3,31 @@ import { createHash } from "crypto"; import { pipeline } from "stream/promises"; import fetch from "make-fetch-happen"; -const ULTIMATE_VERSION = "v0.2.3"; +const ULTIMATE_VERSION = "v1.0.0"; export const DOWNLOAD_URLS = { win32: { x64: { url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-windows-amd64.msi`, checksum: - "sha256:bd196ae05b876588f828a57c4d19b3e7ad96ba40007cf2b36693dc6e792d28cc", + "sha256:c6a36f9b8e55ab6b7e8742cbabc4469d85809237c0f5e6c21af20b36c416ee1d", }, arm64: { url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-windows-arm64.msi`, checksum: - "sha256:79e046f24405e869494291e77c6d8640c8dc58d2ac1db87d3038e9eb8afbdc8b", + "sha256:46acd1af6a9938ea194c8ee8b34ca9b47c8de22e088a0791f3c0751dd6239c90", }, }, darwin: { x64: { url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-darwin-amd64.pkg`, checksum: - "sha256:99868cb663eef44d063d995d2dcc063f55b10eb719ee945d05fe8cf5fef5e2a5", + "sha256:bb1829e8ca422e885baf37bef08dcbe7df7a30f248e2e89c4071564f7d4f3396", }, arm64: { url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-darwin-arm64.pkg`, checksum: - "sha256:000b334c2eb85d8692be5d23af73f8b9fb686c9db726992223187b341ea79306", + "sha256:7fe4a785709911cc366d8224b4c290677573b8c4833bd9054768299e55c5f0ed", }, }, }; From 632b3948e3214938c3b9186cc78a6cadd86d7e14 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 30 Jan 2026 13:57:39 +0100 Subject: [PATCH 140/360] Add troubleshooting steps for powershell when executionpolicy doens't allow to run code --- docs/troubleshooting.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 0cd6098..0b2845b 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -149,6 +149,37 @@ Should include `~/.safe-chain/bin` **If persists:** Re-run the installation script +### PowerShell Execution Policy Blocks Scripts (Windows) + +**Symptom:** When opening PowerShell, you see an error like: + +``` +. : File C:\Users\\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1 cannot be loaded because +running scripts is disabled on this system. +CategoryInfo : SecurityError: (:) [], PSSecurityException +FullyQualifiedErrorId : UnauthorizedAccess +``` + +**Cause:** Windows PowerShell's default execution policy (`Restricted`) blocks all script execution, including safe-chain's initialization script that's sourced from your PowerShell profile. + +**Resolution:** + +1. **Set the execution policy to allow local scripts:** + + Open PowerShell as Administrator and run: + + ```powershell + Set-ExecutionPolicy -ExecutionPolicy RemoteSigned + ``` + + This allows: + - Local scripts (like safe-chain's) to run without signing + - Downloaded scripts to run only if signed by a trusted publisher + +2. **Restart PowerShell** and verify the error is resolved. + +> **Note:** `RemoteSigned` is Microsoft's recommended execution policy for client computers. It provides a good balance between security and usability. + ### Shell Aliases Persist After Uninstallation **Symptom:** safe-chain commands still active after running uninstall script From dfac510c15d103f73f98669a656cbb0d1c0d3dad Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Fri, 30 Jan 2026 14:15:00 +0100 Subject: [PATCH 141/360] add safe-chain ultimate logs --- packages/safe-chain/bin/safe-chain.js | 5 ++ .../src/ultimate/printUltimateLogs.js | 69 +++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 packages/safe-chain/src/ultimate/printUltimateLogs.js diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index 9a07657..6ecdabd 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -20,6 +20,7 @@ import { installUltimate, uninstallUltimate, } from "../src/installation/installUltimate.js"; +import { printUltimateLogs } from "../src/ultimate/printUltimateLogs.js"; /** @type {string} */ // This checks the current file's dirname in a way that's compatible with: @@ -72,6 +73,10 @@ if (tool) { (async () => { await uninstallUltimate(); })(); + } else if (subCommand === "logs") { + (async () => { + await printUltimateLogs(); + })(); } else { (async () => { await installUltimate(); diff --git a/packages/safe-chain/src/ultimate/printUltimateLogs.js b/packages/safe-chain/src/ultimate/printUltimateLogs.js new file mode 100644 index 0000000..65a978e --- /dev/null +++ b/packages/safe-chain/src/ultimate/printUltimateLogs.js @@ -0,0 +1,69 @@ +// @ts-nocheck +import { platform } from 'os'; +import { ui } from "../environment/userInteraction.js"; +import { readFileSync, existsSync } from "node:fs"; + +export async function printUltimateLogs() { + const { proxyLogPath, ultimateLogPath, proxyErrLogPath, ultimateErrLogPath } = getPathsPerPlatform(); + + await printLogs( + "SafeChain Proxy", + proxyLogPath, + proxyErrLogPath + ); + + await printLogs( + "SafeChain Ultimate", + ultimateLogPath, + ultimateErrLogPath + ); +} + +function getPathsPerPlatform() { + const os = platform(); + if (os === 'win32') { + const logDir = `C:\\ProgramData\\AikidoSecurity\\SafeChainUltimate\\logs`; + return { + proxyLogPath: `${logDir}\\SafeChainProxy.log`, + ultimateLogPath: `${logDir}\\SafeChainUltimate.log`, + proxyErrLogPath: `${logDir}\\SafeChainProxy.err`, + ultimateErrLogPath: `${logDir}\\SafeChainUltimate.err`, + }; + } else if (os === 'darwin') { + const logDir = `/Library/Logs/AikidoSecurity/SafeChainUltimate`; + return { + proxyLogPath: `${logDir}/safechain-proxy.log`, + ultimateLogPath: `${logDir}/safechain-ultimate.log`, + proxyErrLogPath: `${logDir}/safechain-proxy.error.log`, + ultimateErrLogPath: `${logDir}/safechain-ultimate.error.log`, + }; + } else { + throw new Error('Unsupported platform for log printing.'); + } +} + +async function printLogs(appName, logPath, errLogPath) { + ui.writeInformation(`=== ${appName} Logs ===`); + try { + if (existsSync(logPath)) { + const logs = readFileSync(logPath, "utf-8"); + ui.writeInformation(logs); + } else { + ui.writeWarning(`${appName} log file not found: ${logPath}`); + } + } catch (error) { + ui.writeError(`Failed to read ${appName} logs: ${error.message}`); + } + + ui.writeInformation(`=== ${appName} Error Logs ===`); + try { + if (existsSync(errLogPath)) { + const errLogs = readFileSync(errLogPath, "utf-8"); + ui.writeInformation(errLogs); + } else { + ui.writeInformation(`No error log file found for ${appName}.`); + } + } catch (error) { + ui.writeError(`Failed to read ${appName} error logs: ${error.message}`); + } +} From 4c29eb3549905ce066c4a5c1eeadaa9b027a5fd1 Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Fri, 30 Jan 2026 14:22:21 +0100 Subject: [PATCH 142/360] install archiver --- package-lock.json | 923 ++++++++++++++++++++++++++++++++++++++++++++-- package.json | 7 +- 2 files changed, 902 insertions(+), 28 deletions(-) diff --git a/package-lock.json b/package-lock.json index c852d4f..9ca91f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,9 @@ "packages/*", "test/e2e" ], + "dependencies": { + "archiver": "^7.0.1" + }, "devDependencies": { "@yao-pkg/pkg": "6.10.1", "esbuild": "^0.27.0", @@ -555,6 +558,102 @@ "node": "20 || >=22" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -748,6 +847,16 @@ "win32" ] }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@types/ini": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz", @@ -919,6 +1028,18 @@ "node": ">= 6" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", @@ -932,7 +1053,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -942,7 +1062,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -954,6 +1073,243 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/archiver-utils/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/archiver/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/archiver/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -964,7 +1320,6 @@ "version": "1.7.3", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", - "dev": true, "license": "Apache-2.0", "peerDependencies": { "react-native-b4a": "*" @@ -975,11 +1330,16 @@ } } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/bare-events": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", - "dev": true, "license": "Apache-2.0", "peerDependencies": { "bare-abort-controller": "*" @@ -1076,7 +1436,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -1127,6 +1486,15 @@ "dev": true, "license": "MIT" }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -1152,6 +1520,15 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/cacache": { "version": "20.0.3", "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.3.tgz", @@ -1233,7 +1610,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -1246,7 +1622,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -1261,13 +1636,205 @@ "node": ">= 0.8" } }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/compress-commons/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/compress-commons/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/compress-commons/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, "license": "MIT" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/crc32-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/crc32-stream/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/crc32-stream/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -1354,11 +1921,16 @@ "readable-stream": "^2.0.2" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/encoding": { @@ -1484,11 +2056,28 @@ "node": ">=6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/events-universal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "bare-events": "^2.7.0" @@ -1508,7 +2097,6 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "dev": true, "license": "MIT" }, "node_modules/fdir": { @@ -1529,6 +2117,22 @@ } } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", @@ -1686,7 +2290,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/has-symbols": { @@ -1789,7 +2392,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -1819,7 +2421,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -1877,19 +2478,50 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1925,6 +2557,24 @@ ], "license": "MIT" }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "11.2.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", @@ -2281,6 +2931,15 @@ "nan": "^2.17.0" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/npm-package-arg": { "version": "13.0.2", "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-13.0.2.tgz", @@ -2381,6 +3040,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -2512,11 +3186,19 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, "license": "MIT" }, "node_modules/progress": { @@ -2580,7 +3262,6 @@ "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -2592,6 +3273,27 @@ "util-deprecate": "~1.0.1" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -2636,7 +3338,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, "license": "MIT" }, "node_modules/safer-buffer": { @@ -2658,6 +3359,39 @@ "node": ">=10" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -2769,7 +3503,6 @@ "version": "2.23.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", - "dev": true, "license": "MIT", "dependencies": { "events-universal": "^1.0.0", @@ -2781,7 +3514,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -2791,7 +3523,21 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -2806,7 +3552,19 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -2874,7 +3632,6 @@ "version": "3.1.7", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", - "dev": true, "license": "MIT", "dependencies": { "b4a": "^1.6.4", @@ -2896,7 +3653,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.4" @@ -3010,7 +3766,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/validate-npm-package-name": { @@ -3040,6 +3795,21 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -3058,6 +3828,24 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -3110,6 +3898,89 @@ "node": ">=10" } }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zip-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/zip-stream/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/zip-stream/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "packages/safe-chain": { "name": "@aikidosec/safe-chain", "version": "1.0.0", diff --git a/package.json b/package.json index 2793f9c..864ac73 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,11 @@ "author": "Aikido Security", "license": "AGPL-3.0-or-later", "devDependencies": { - "oxlint": "^1.22.0", + "@yao-pkg/pkg": "6.10.1", "esbuild": "^0.27.0", - "@yao-pkg/pkg": "6.10.1" + "oxlint": "^1.22.0" + }, + "dependencies": { + "archiver": "^7.0.1" } } From 460be68cd3646a348002e937089d8d40a9ea2c28 Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Fri, 30 Jan 2026 14:22:47 +0100 Subject: [PATCH 143/360] create an export async collectLogs --- .../src/ultimate/printUltimateLogs.js | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/safe-chain/src/ultimate/printUltimateLogs.js b/packages/safe-chain/src/ultimate/printUltimateLogs.js index 65a978e..c8e5403 100644 --- a/packages/safe-chain/src/ultimate/printUltimateLogs.js +++ b/packages/safe-chain/src/ultimate/printUltimateLogs.js @@ -1,7 +1,9 @@ -// @ts-nocheck import { platform } from 'os'; import { ui } from "../environment/userInteraction.js"; import { readFileSync, existsSync } from "node:fs"; +import {randomUUID} from "node:crypto"; +import {createWriteStream} from "fs"; +import archiver from 'archiver'; export async function printUltimateLogs() { const { proxyLogPath, ultimateLogPath, proxyErrLogPath, ultimateErrLogPath } = getPathsPerPlatform(); @@ -19,11 +21,44 @@ export async function printUltimateLogs() { ); } +export async function collectLogs() { + const { logDir } = getPathsPerPlatform(); + return new Promise((resolve, reject) => { + if (!existsSync(logDir)) { + ui.writeError(`Log directory not found: ${logDir}`); + reject(new Error(`Log directory not found: ${logDir}`)); + return; + } + + const date = new Date().toISOString().split('T')[0]; + const uuid = randomUUID(); + const zipFileName = `safechain-ultimate-${date}-${uuid}.zip`; + const output = createWriteStream(zipFileName); + const archive = archiver('zip', { zlib: { level: 9 } }); + + output.on('close', () => { + ui.writeInformation(`Logs collected and zipped as: ${zipFileName}`); + resolve(zipFileName); + }); + + archive.on('error', (err) => { + ui.writeError(`Failed to zip logs: ${err.message}`); + reject(err); + }); + + archive.pipe(output); + archive.directory(logDir, false); + archive.finalize(); + }); +} + + function getPathsPerPlatform() { const os = platform(); if (os === 'win32') { const logDir = `C:\\ProgramData\\AikidoSecurity\\SafeChainUltimate\\logs`; return { + logDir, proxyLogPath: `${logDir}\\SafeChainProxy.log`, ultimateLogPath: `${logDir}\\SafeChainUltimate.log`, proxyErrLogPath: `${logDir}\\SafeChainProxy.err`, @@ -32,6 +67,7 @@ function getPathsPerPlatform() { } else if (os === 'darwin') { const logDir = `/Library/Logs/AikidoSecurity/SafeChainUltimate`; return { + logDir, proxyLogPath: `${logDir}/safechain-proxy.log`, ultimateLogPath: `${logDir}/safechain-ultimate.log`, proxyErrLogPath: `${logDir}/safechain-proxy.error.log`, From 5ab5fee130240580ec113e07e85f89c8b73151cf Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Fri, 30 Jan 2026 14:25:20 +0100 Subject: [PATCH 144/360] add docs & collect-logs to safe-chain bin --- packages/safe-chain/bin/safe-chain.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index 6ecdabd..770362b 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -20,7 +20,10 @@ import { installUltimate, uninstallUltimate, } from "../src/installation/installUltimate.js"; -import { printUltimateLogs } from "../src/ultimate/printUltimateLogs.js"; +import { + collectLogs, + printUltimateLogs +} from "../src/ultimate/printUltimateLogs.js"; /** @type {string} */ // This checks the current file's dirname in a way that's compatible with: @@ -77,6 +80,10 @@ if (tool) { (async () => { await printUltimateLogs(); })(); + } else if (subCommand === "collect-logs") { + (async () => { + await collectLogs(); + })(); } else { (async () => { await installUltimate(); @@ -141,6 +148,16 @@ function writeHelp() { "safe-chain ultimate", )}: Install the ultimate version of safe-chain, enabling protection for more eco-systems.`, ); + ui.writeInformation( + `- ${chalk.cyan( + "safe-chain ultimate logs", + )}: Prints standard and error logs for safe-chain ultimate and it's proxy.`, + ); + ui.writeInformation( + `- ${chalk.cyan( + "safe-chain ultimate collect-logs", + )}: Creates a zip archive of safe-chain ultimate logs that can be shared with support.`, + ); ui.writeInformation( `- ${chalk.cyan( "safe-chain ultimate uninstall", From adc384dd7842bb06f3be55bc7b7a39977c8c3b5e Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Fri, 30 Jan 2026 14:26:26 +0100 Subject: [PATCH 145/360] use path.resolve to print full file --- packages/safe-chain/src/ultimate/printUltimateLogs.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/safe-chain/src/ultimate/printUltimateLogs.js b/packages/safe-chain/src/ultimate/printUltimateLogs.js index c8e5403..a11c9f7 100644 --- a/packages/safe-chain/src/ultimate/printUltimateLogs.js +++ b/packages/safe-chain/src/ultimate/printUltimateLogs.js @@ -4,6 +4,7 @@ import { readFileSync, existsSync } from "node:fs"; import {randomUUID} from "node:crypto"; import {createWriteStream} from "fs"; import archiver from 'archiver'; +import path from "node:path"; export async function printUltimateLogs() { const { proxyLogPath, ultimateLogPath, proxyErrLogPath, ultimateErrLogPath } = getPathsPerPlatform(); @@ -37,7 +38,7 @@ export async function collectLogs() { const archive = archiver('zip', { zlib: { level: 9 } }); output.on('close', () => { - ui.writeInformation(`Logs collected and zipped as: ${zipFileName}`); + ui.writeInformation(`Logs collected and zipped as: ${path.resolve(zipFileName)}`); resolve(zipFileName); }); From ef057626359dd19140978c6582111b3f5c456c57 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Fri, 30 Jan 2026 14:42:43 +0100 Subject: [PATCH 146/360] add 'archiver' types --- package-lock.json | 21 +++++++++++++++++++++ package.json | 1 + 2 files changed, 22 insertions(+) diff --git a/package-lock.json b/package-lock.json index 9ca91f2..4b20556 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "archiver": "^7.0.1" }, "devDependencies": { + "@types/archiver": "^7.0.0", "@yao-pkg/pkg": "6.10.1", "esbuild": "^0.27.0", "oxlint": "^1.22.0" @@ -857,6 +858,16 @@ "node": ">=14" } }, + "node_modules/@types/archiver": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-7.0.0.tgz", + "integrity": "sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/readdir-glob": "*" + } + }, "node_modules/@types/ini": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz", @@ -931,6 +942,16 @@ "@types/node": "*" } }, + "node_modules/@types/readdir-glob": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz", + "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/retry": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz", diff --git a/package.json b/package.json index 864ac73..818539e 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "author": "Aikido Security", "license": "AGPL-3.0-or-later", "devDependencies": { + "@types/archiver": "^7.0.0", "@yao-pkg/pkg": "6.10.1", "esbuild": "^0.27.0", "oxlint": "^1.22.0" From 38b7c51985ff3166ceac6423d7c752aa16540ba1 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Fri, 30 Jan 2026 14:44:26 +0100 Subject: [PATCH 147/360] Cleanup linting errors --- packages/safe-chain/src/ultimate/printUltimateLogs.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/safe-chain/src/ultimate/printUltimateLogs.js b/packages/safe-chain/src/ultimate/printUltimateLogs.js index a11c9f7..2fe432b 100644 --- a/packages/safe-chain/src/ultimate/printUltimateLogs.js +++ b/packages/safe-chain/src/ultimate/printUltimateLogs.js @@ -79,6 +79,11 @@ function getPathsPerPlatform() { } } +/** + * @param {string} appName + * @param {string} logPath + * @param {string} errLogPath + */ async function printLogs(appName, logPath, errLogPath) { ui.writeInformation(`=== ${appName} Logs ===`); try { @@ -89,7 +94,7 @@ async function printLogs(appName, logPath, errLogPath) { ui.writeWarning(`${appName} log file not found: ${logPath}`); } } catch (error) { - ui.writeError(`Failed to read ${appName} logs: ${error.message}`); + ui.writeError(`Failed to read ${appName} logs: ${error}`); } ui.writeInformation(`=== ${appName} Error Logs ===`); @@ -101,6 +106,6 @@ async function printLogs(appName, logPath, errLogPath) { ui.writeInformation(`No error log file found for ${appName}.`); } } catch (error) { - ui.writeError(`Failed to read ${appName} error logs: ${error.message}`); + ui.writeError(`Failed to read ${appName} error logs: ${error}`); } } From adcf609066c3408fedd20588d1da2db3574e00aa Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Fri, 30 Jan 2026 15:16:39 +0100 Subject: [PATCH 148/360] rename to troubleshooting-* --- packages/safe-chain/bin/safe-chain.js | 15 ++++++--------- ...UltimateLogs.js => ultimateTroubleshooting.js} | 2 +- 2 files changed, 7 insertions(+), 10 deletions(-) rename packages/safe-chain/src/ultimate/{printUltimateLogs.js => ultimateTroubleshooting.js} (98%) diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index 770362b..b1d66b1 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -20,10 +20,7 @@ import { installUltimate, uninstallUltimate, } from "../src/installation/installUltimate.js"; -import { - collectLogs, - printUltimateLogs -} from "../src/ultimate/printUltimateLogs.js"; +import {printUltimateLogs, troubleshootingExport } from "../src/ultimate/ultimateTroubleshooting.js"; /** @type {string} */ // This checks the current file's dirname in a way that's compatible with: @@ -76,13 +73,13 @@ if (tool) { (async () => { await uninstallUltimate(); })(); - } else if (subCommand === "logs") { + } else if (subCommand === "troubleshooting-logs") { (async () => { await printUltimateLogs(); })(); - } else if (subCommand === "collect-logs") { + } else if (subCommand === "troubleshooting-export") { (async () => { - await collectLogs(); + await troubleshootingExport(); })(); } else { (async () => { @@ -150,12 +147,12 @@ function writeHelp() { ); ui.writeInformation( `- ${chalk.cyan( - "safe-chain ultimate logs", + "safe-chain ultimate troubleshooting-logs", )}: Prints standard and error logs for safe-chain ultimate and it's proxy.`, ); ui.writeInformation( `- ${chalk.cyan( - "safe-chain ultimate collect-logs", + "safe-chain ultimate troubleshooting-export", )}: Creates a zip archive of safe-chain ultimate logs that can be shared with support.`, ); ui.writeInformation( diff --git a/packages/safe-chain/src/ultimate/printUltimateLogs.js b/packages/safe-chain/src/ultimate/ultimateTroubleshooting.js similarity index 98% rename from packages/safe-chain/src/ultimate/printUltimateLogs.js rename to packages/safe-chain/src/ultimate/ultimateTroubleshooting.js index 2fe432b..e333615 100644 --- a/packages/safe-chain/src/ultimate/printUltimateLogs.js +++ b/packages/safe-chain/src/ultimate/ultimateTroubleshooting.js @@ -22,7 +22,7 @@ export async function printUltimateLogs() { ); } -export async function collectLogs() { +export async function troubleshootingExport() { const { logDir } = getPathsPerPlatform(); return new Promise((resolve, reject) => { if (!existsSync(logDir)) { From 7e35d8df5690c8df48ffd8da47da2eeab8e92d5e Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Fri, 30 Jan 2026 15:19:56 +0100 Subject: [PATCH 149/360] troubleshooting-export: update description --- packages/safe-chain/bin/safe-chain.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index b1d66b1..e438e12 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -153,7 +153,7 @@ function writeHelp() { ui.writeInformation( `- ${chalk.cyan( "safe-chain ultimate troubleshooting-export", - )}: Creates a zip archive of safe-chain ultimate logs that can be shared with support.`, + )}: Creates a zip archive of useful data for troubleshooting safe-chain ultimate, that can be shared with our support team.`, ); ui.writeInformation( `- ${chalk.cyan( From ceaf69c27d51da2f36d203cafe6218474ee2ccd9 Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Fri, 30 Jan 2026 15:47:41 +0100 Subject: [PATCH 150/360] Revert "add 'archiver' types" This reverts commit ef057626359dd19140978c6582111b3f5c456c57. --- package-lock.json | 21 --------------------- package.json | 1 - 2 files changed, 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4b20556..9ca91f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "archiver": "^7.0.1" }, "devDependencies": { - "@types/archiver": "^7.0.0", "@yao-pkg/pkg": "6.10.1", "esbuild": "^0.27.0", "oxlint": "^1.22.0" @@ -858,16 +857,6 @@ "node": ">=14" } }, - "node_modules/@types/archiver": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-7.0.0.tgz", - "integrity": "sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/readdir-glob": "*" - } - }, "node_modules/@types/ini": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz", @@ -942,16 +931,6 @@ "@types/node": "*" } }, - "node_modules/@types/readdir-glob": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz", - "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/retry": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz", diff --git a/package.json b/package.json index 818539e..864ac73 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "author": "Aikido Security", "license": "AGPL-3.0-or-later", "devDependencies": { - "@types/archiver": "^7.0.0", "@yao-pkg/pkg": "6.10.1", "esbuild": "^0.27.0", "oxlint": "^1.22.0" From 90a44d999a0757dacd2d70e9eed0487579b51026 Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Fri, 30 Jan 2026 15:47:49 +0100 Subject: [PATCH 151/360] Revert "install archiver" This reverts commit 4c29eb3549905ce066c4a5c1eeadaa9b027a5fd1. --- package-lock.json | 923 ++-------------------------------------------- package.json | 7 +- 2 files changed, 28 insertions(+), 902 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9ca91f2..c852d4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,6 @@ "packages/*", "test/e2e" ], - "dependencies": { - "archiver": "^7.0.1" - }, "devDependencies": { "@yao-pkg/pkg": "6.10.1", "esbuild": "^0.27.0", @@ -558,102 +555,6 @@ "node": "20 || >=22" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -847,16 +748,6 @@ "win32" ] }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@types/ini": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz", @@ -1028,18 +919,6 @@ "node": ">= 6" } }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, "node_modules/agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", @@ -1053,6 +932,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1062,6 +942,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -1073,243 +954,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/archiver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", - "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", - "license": "MIT", - "dependencies": { - "archiver-utils": "^5.0.2", - "async": "^3.2.4", - "buffer-crc32": "^1.0.0", - "readable-stream": "^4.0.0", - "readdir-glob": "^1.1.2", - "tar-stream": "^3.0.0", - "zip-stream": "^6.0.1" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/archiver-utils": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", - "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", - "license": "MIT", - "dependencies": { - "glob": "^10.0.0", - "graceful-fs": "^4.2.0", - "is-stream": "^2.0.1", - "lazystream": "^1.0.0", - "lodash": "^4.17.15", - "normalize-path": "^3.0.0", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/archiver-utils/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/archiver-utils/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/archiver-utils/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" - }, - "node_modules/archiver-utils/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/archiver-utils/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/archiver-utils/node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/archiver-utils/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/archiver-utils/node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/archiver/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/archiver/node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/archiver/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/archiver/node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "license": "MIT" - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1320,6 +964,7 @@ "version": "1.7.3", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "dev": true, "license": "Apache-2.0", "peerDependencies": { "react-native-b4a": "*" @@ -1330,16 +975,11 @@ } } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, "node_modules/bare-events": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "dev": true, "license": "Apache-2.0", "peerDependencies": { "bare-abort-controller": "*" @@ -1436,6 +1076,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, "funding": [ { "type": "github", @@ -1486,15 +1127,6 @@ "dev": true, "license": "MIT" }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -1520,15 +1152,6 @@ "ieee754": "^1.1.13" } }, - "node_modules/buffer-crc32": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", - "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/cacache": { "version": "20.0.3", "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.3.tgz", @@ -1610,6 +1233,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -1622,6 +1246,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -1636,205 +1261,13 @@ "node": ">= 0.8" } }, - "node_modules/compress-commons": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", - "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", - "license": "MIT", - "dependencies": { - "crc-32": "^1.2.0", - "crc32-stream": "^6.0.0", - "is-stream": "^2.0.1", - "normalize-path": "^3.0.0", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/compress-commons/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/compress-commons/node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/compress-commons/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/compress-commons/node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, "license": "MIT" }, - "node_modules/crc-32": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", - "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", - "license": "Apache-2.0", - "bin": { - "crc32": "bin/crc32.njs" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/crc32-stream": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", - "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", - "license": "MIT", - "dependencies": { - "crc-32": "^1.2.0", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/crc32-stream/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/crc32-stream/node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/crc32-stream/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/crc32-stream/node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -1921,16 +1354,11 @@ "readable-stream": "^2.0.2" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/encoding": { @@ -2056,28 +1484,11 @@ "node": ">=6" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/events-universal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "bare-events": "^2.7.0" @@ -2097,6 +1508,7 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, "license": "MIT" }, "node_modules/fdir": { @@ -2117,22 +1529,6 @@ } } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/form-data": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", @@ -2290,6 +1686,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, "node_modules/has-symbols": { @@ -2392,6 +1789,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, "funding": [ { "type": "github", @@ -2421,6 +1819,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -2478,50 +1877,19 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, "license": "MIT" }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2557,24 +1925,6 @@ ], "license": "MIT" }, - "node_modules/lazystream": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", - "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", - "license": "MIT", - "dependencies": { - "readable-stream": "^2.0.5" - }, - "engines": { - "node": ">= 0.6.3" - } - }, - "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "license": "MIT" - }, "node_modules/lru-cache": { "version": "11.2.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", @@ -2931,15 +2281,6 @@ "nan": "^2.17.0" } }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/npm-package-arg": { "version": "13.0.2", "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-13.0.2.tgz", @@ -3040,21 +2381,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -3186,19 +2512,11 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, "license": "MIT" }, "node_modules/progress": { @@ -3262,6 +2580,7 @@ "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -3273,27 +2592,6 @@ "util-deprecate": "~1.0.1" } }, - "node_modules/readdir-glob": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", - "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", - "license": "Apache-2.0", - "dependencies": { - "minimatch": "^5.1.0" - } - }, - "node_modules/readdir-glob/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -3338,6 +2636,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, "license": "MIT" }, "node_modules/safer-buffer": { @@ -3359,39 +2658,6 @@ "node": ">=10" } }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -3503,6 +2769,7 @@ "version": "2.23.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "dev": true, "license": "MIT", "dependencies": { "events-universal": "^1.0.0", @@ -3514,6 +2781,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -3523,21 +2791,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -3552,19 +2806,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -3632,6 +2874,7 @@ "version": "3.1.7", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, "license": "MIT", "dependencies": { "b4a": "^1.6.4", @@ -3653,6 +2896,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.4" @@ -3766,6 +3010,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, "license": "MIT" }, "node_modules/validate-npm-package-name": { @@ -3795,21 +3040,6 @@ "webidl-conversions": "^3.0.0" } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -3828,24 +3058,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -3898,89 +3110,6 @@ "node": ">=10" } }, - "node_modules/zip-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", - "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", - "license": "MIT", - "dependencies": { - "archiver-utils": "^5.0.0", - "compress-commons": "^6.0.2", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/zip-stream/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/zip-stream/node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/zip-stream/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/zip-stream/node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "packages/safe-chain": { "name": "@aikidosec/safe-chain", "version": "1.0.0", diff --git a/package.json b/package.json index 864ac73..2793f9c 100644 --- a/package.json +++ b/package.json @@ -19,11 +19,8 @@ "author": "Aikido Security", "license": "AGPL-3.0-or-later", "devDependencies": { - "@yao-pkg/pkg": "6.10.1", + "oxlint": "^1.22.0", "esbuild": "^0.27.0", - "oxlint": "^1.22.0" - }, - "dependencies": { - "archiver": "^7.0.1" + "@yao-pkg/pkg": "6.10.1" } } From 768de61401937c008f08708d80e5caa853a03354 Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Fri, 30 Jan 2026 15:48:39 +0100 Subject: [PATCH 152/360] install deps in safe-chain/package.json --- package-lock.json | 942 ++++++++++++++++++++++++++++++- packages/safe-chain/package.json | 2 + 2 files changed, 918 insertions(+), 26 deletions(-) diff --git a/package-lock.json b/package-lock.json index c852d4f..ea8c410 100644 --- a/package-lock.json +++ b/package-lock.json @@ -555,6 +555,102 @@ "node": "20 || >=22" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -748,6 +844,26 @@ "win32" ] }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@types/archiver": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-7.0.0.tgz", + "integrity": "sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/readdir-glob": "*" + } + }, "node_modules/@types/ini": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz", @@ -822,6 +938,16 @@ "@types/node": "*" } }, + "node_modules/@types/readdir-glob": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz", + "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/retry": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz", @@ -919,6 +1045,18 @@ "node": ">= 6" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", @@ -932,7 +1070,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -942,7 +1079,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -954,6 +1090,243 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/archiver-utils/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/archiver/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/archiver/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -964,7 +1337,6 @@ "version": "1.7.3", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", - "dev": true, "license": "Apache-2.0", "peerDependencies": { "react-native-b4a": "*" @@ -975,11 +1347,16 @@ } } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/bare-events": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", - "dev": true, "license": "Apache-2.0", "peerDependencies": { "bare-abort-controller": "*" @@ -1076,7 +1453,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -1127,6 +1503,15 @@ "dev": true, "license": "MIT" }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -1152,6 +1537,15 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/cacache": { "version": "20.0.3", "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.3.tgz", @@ -1233,7 +1627,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -1246,7 +1639,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -1261,13 +1653,205 @@ "node": ">= 0.8" } }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/compress-commons/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/compress-commons/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/compress-commons/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, "license": "MIT" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/crc32-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/crc32-stream/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/crc32-stream/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -1354,11 +1938,16 @@ "readable-stream": "^2.0.2" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/encoding": { @@ -1484,11 +2073,28 @@ "node": ">=6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/events-universal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "bare-events": "^2.7.0" @@ -1508,7 +2114,6 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "dev": true, "license": "MIT" }, "node_modules/fdir": { @@ -1529,6 +2134,22 @@ } } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", @@ -1686,7 +2307,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/has-symbols": { @@ -1789,7 +2409,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -1819,7 +2438,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -1877,19 +2495,50 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1925,6 +2574,24 @@ ], "license": "MIT" }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "11.2.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", @@ -2281,6 +2948,15 @@ "nan": "^2.17.0" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/npm-package-arg": { "version": "13.0.2", "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-13.0.2.tgz", @@ -2381,6 +3057,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -2512,11 +3203,19 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, "license": "MIT" }, "node_modules/progress": { @@ -2580,7 +3279,6 @@ "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -2592,6 +3290,27 @@ "util-deprecate": "~1.0.1" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -2636,7 +3355,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, "license": "MIT" }, "node_modules/safer-buffer": { @@ -2658,6 +3376,39 @@ "node": ">=10" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -2769,7 +3520,6 @@ "version": "2.23.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", - "dev": true, "license": "MIT", "dependencies": { "events-universal": "^1.0.0", @@ -2781,7 +3531,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -2791,7 +3540,21 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -2806,7 +3569,19 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -2874,7 +3649,6 @@ "version": "3.1.7", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", - "dev": true, "license": "MIT", "dependencies": { "b4a": "^1.6.4", @@ -2896,7 +3670,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.4" @@ -3010,7 +3783,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/validate-npm-package-name": { @@ -3040,6 +3812,21 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -3058,6 +3845,24 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -3110,11 +3915,95 @@ "node": ">=10" } }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zip-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/zip-stream/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/zip-stream/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "packages/safe-chain": { "name": "@aikidosec/safe-chain", "version": "1.0.0", "license": "AGPL-3.0-or-later", "dependencies": { + "archiver": "^7.0.1", "certifi": "14.5.15", "chalk": "5.4.1", "https-proxy-agent": "7.0.6", @@ -3142,6 +4031,7 @@ "safe-chain": "bin/safe-chain.js" }, "devDependencies": { + "@types/archiver": "^7.0.0", "@types/ini": "^4.1.1", "@types/make-fetch-happen": "^10.0.4", "@types/node": "^18.19.130", diff --git a/packages/safe-chain/package.json b/packages/safe-chain/package.json index 3d527cb..d4f3501 100644 --- a/packages/safe-chain/package.json +++ b/packages/safe-chain/package.json @@ -38,6 +38,7 @@ "license": "AGPL-3.0-or-later", "description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), and [pip](https://pip.pypa.io/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, bunx, uv, or pip/pip3 from downloading or running the malware.", "dependencies": { + "archiver": "^7.0.1", "certifi": "14.5.15", "chalk": "5.4.1", "https-proxy-agent": "7.0.6", @@ -48,6 +49,7 @@ "semver": "7.7.2" }, "devDependencies": { + "@types/archiver": "^7.0.0", "@types/ini": "^4.1.1", "@types/make-fetch-happen": "^10.0.4", "@types/node": "^18.19.130", From e9ed6063c3c6a0f49cf435caa095bf380cc188d7 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 2 Feb 2026 15:28:44 +0100 Subject: [PATCH 153/360] Verify the number of arguments for ultimate commands --- packages/safe-chain/bin/safe-chain.js | 37 ++++++++++++++++++- .../src/installation/installUltimate.js | 2 - 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index e438e12..dbefa10 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -20,7 +20,10 @@ import { installUltimate, uninstallUltimate, } from "../src/installation/installUltimate.js"; -import {printUltimateLogs, troubleshootingExport } from "../src/ultimate/ultimateTroubleshooting.js"; +import { + printUltimateLogs, + troubleshootingExport, +} from "../src/ultimate/ultimateTroubleshooting.js"; /** @type {string} */ // This checks the current file's dirname in a way that's compatible with: @@ -68,20 +71,34 @@ if (tool) { } else if (command === "setup") { setup(); } else if (command === "ultimate") { - const subCommand = process.argv[3]; + const cliArgs = initializeCliArguments(process.argv.slice(2)); + const subCommand = cliArgs[1]; if (subCommand === "uninstall") { + guardCliArgsMaxLenght(2, cliArgs, "safe-chain ultimate uninstall"); (async () => { await uninstallUltimate(); })(); } else if (subCommand === "troubleshooting-logs") { + guardCliArgsMaxLenght( + 2, + cliArgs, + "safe-chain ultimate troubleshooting-logs", + ); (async () => { await printUltimateLogs(); })(); } else if (subCommand === "troubleshooting-export") { + guardCliArgsMaxLenght( + 2, + cliArgs, + "safe-chain ultimate troubleshooting-export", + ); (async () => { await troubleshootingExport(); })(); } else { + guardCliArgsMaxLenght(1, cliArgs, "safe-chain ultimate"); + // Install command = when no subcommand is provided (safe-chain ultimate) (async () => { await installUltimate(); })(); @@ -104,6 +121,22 @@ if (tool) { process.exit(1); } +/** + * @param {Number} maxLength + * @param {String[]} args + * @param {String} command + */ +function guardCliArgsMaxLenght(maxLength, args, command) { + if (args.length > maxLength) { + ui.writeError(`Unexpected number of arguments for command ${command}.`); + ui.emptyLine(); + + writeHelp(); + + process.exit(1); + } +} + function writeHelp() { ui.writeInformation( chalk.bold("Usage: ") + chalk.cyan("safe-chain "), diff --git a/packages/safe-chain/src/installation/installUltimate.js b/packages/safe-chain/src/installation/installUltimate.js index cfcdcca..257c953 100644 --- a/packages/safe-chain/src/installation/installUltimate.js +++ b/packages/safe-chain/src/installation/installUltimate.js @@ -21,8 +21,6 @@ export async function uninstallUltimate() { } export async function installUltimate() { - initializeCliArguments(process.argv); - const operatingSystem = platform(); if (operatingSystem === "win32") { From 90eba0a0b66aa6fe031fbd2c87b4762656df7c9a Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 4 Feb 2026 14:04:46 +0100 Subject: [PATCH 154/360] Document CI/CD for GitLab --- README.md | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 65 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 128d662..4973573 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,6 @@ You can find all available versions on the [releases page](https://github.com/Ai ### Verify the installation 1. **❗Restart your terminal** to start using the Aikido Safe Chain. - - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, poetry, uv and pipx are loaded correctly. If you do not restart your terminal, the aliases will not be available. 2. **Verify the installation** by running the verification command: @@ -159,7 +158,6 @@ You can control the output from Aikido Safe Chain using the `--safe-chain-loggin You can set the logging level through multiple sources (in order of priority): 1. **CLI Argument** (highest priority): - - `--safe-chain-logging=silent` - Suppresses all Aikido Safe Chain output except when malware is blocked. The package manager output is written to stdout as normal, and Safe Chain only writes a short message if it has blocked malware and causes the process to exit. ```shell @@ -288,6 +286,7 @@ iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download - ✅ **CircleCI** - ✅ **Jenkins** - ✅ **Bitbucket Pipelines** +- ✅ **GitLab Pipelines** ## GitHub Actions Example @@ -386,14 +385,76 @@ steps: - step: name: Install script: - - npm install -g @aikidosec/safe-chain - - safe-chain setup-ci + - curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci - export PATH=~/.safe-chain/shims:$PATH - npm ci ``` After setup, all subsequent package manager commands in your CI pipeline will automatically be protected by Aikido Safe Chain's malware detection. +## GitLab Pipelines Example + +To add safe-chain in GitLab pipelines, you need to install it in the image running the pipeline. This can be done by: + +1. Define a dockerfile to run your build + + ```dockerfile + FROM node:lts + + # Install safe-chain + RUN curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci + + # Add safe-chain to PATH + ENV PATH="/root/.safe-chain/shims:/root/.safe-chain/bin:${PATH}" + ``` + +2. Build the Docker image in your CI pipeline + + ```yaml + build-image: + stage: build-image + image: docker:latest + services: + - docker:dind + script: + - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + - docker build -t $CI_REGISTRY_IMAGE:latest . + - docker push $CI_REGISTRY_IMAGE:latest + ``` + +3. Use the image in your pipeline: + ```yaml + npm-ci: + stage: install + image: $CI_REGISTRY_IMAGE:latest + script: + - npm ci + ``` + +The full pipeline for this example looks like this: + +```yaml +stages: + - build-image + - install + +build-image: + stage: build-image + image: docker:latest + services: + - docker:dind + script: + - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + - docker build -t $CI_REGISTRY_IMAGE:latest . + - docker push $CI_REGISTRY_IMAGE:latest + +npm-ci: + stage: install + image: $CI_REGISTRY_IMAGE:latest + script: + - npm ci +``` + # Troubleshooting Having issues? See the [Troubleshooting Guide](https://github.com/AikidoSec/safe-chain/blob/main/docs/troubleshooting.md) for help with common problems. From c765438e63e7d175208a779d70cc1836eca128bc Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 4 Feb 2026 16:30:29 +0100 Subject: [PATCH 155/360] Powershell: check if the executionpolicy allow to run safe-chain --- install-scripts/install-safe-chain.ps1 | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index ffe2505..25ef8b7 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -31,6 +31,28 @@ function Write-Error-Custom { exit 1 } +# Check if the PowerShell execution policy allows script execution +function Test-ExecutionPolicy { + $policy = Get-ExecutionPolicy + $acceptablePolicies = @('RemoteSigned', 'Unrestricted', 'Bypass') + return $acceptablePolicies -contains $policy +} + + +if (-not (Test-ExecutionPolicy)) { + $currentPolicy = Get-ExecutionPolicy + Write-Error-Custom @" +PowerShell execution policy is set to '$currentPolicy', which prevents safe-chain from running. + +The execution policy must be at least 'RemoteSigned' to allow safe-chain's initialization script to run. + +To fix this, open PowerShell as Administrator and run: + Set-ExecutionPolicy -ExecutionPolicy RemoteSigned + +Then restart this installation. +"@ +} + # Get currently installed version of safe-chain function Get-InstalledVersion { # Check if safe-chain command exists @@ -157,7 +179,8 @@ function Install-SafeChain { Write-Warn "" if ($ci) { Write-Warn " iex `"& { `$(iwr 'https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1' -UseBasicParsing) } -ci`"" - } else { + } + else { Write-Warn " iex (iwr `"https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1`" -UseBasicParsing)" } Write-Warn "" From e9799e283fc74137bf5b246bec38959812af29b9 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 5 Feb 2026 09:49:36 +0100 Subject: [PATCH 156/360] Check powershell execution policy in setup function --- .../src/shell-integration/helpers.js | 33 ++++++++++++++++- .../supported-shells/powershell.js | 11 ++++++ .../supported-shells/powershell.spec.js | 36 +++++++++++++++---- .../supported-shells/windowsPowershell.js | 11 ++++++ .../windowsPowershell.spec.js | 36 +++++++++++++++---- 5 files changed, 114 insertions(+), 13 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index 3e71d71..17b527c 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -1,4 +1,4 @@ -import { spawnSync } from "child_process"; +import { spawnSync, execSync } from "child_process"; import * as os from "os"; import fs from "fs"; import path from "path"; @@ -243,3 +243,34 @@ function createFileIfNotExists(filePath) { fs.writeFileSync(filePath, "", "utf-8"); } + +/** + * Checks if PowerShell execution policy allows script execution + * @param {string} shellExecutableName - The name of the PowerShell executable ("pwsh" or "powershell") + * @returns {{isValid: boolean, policy: string}} validation result + */ +export function validatePowerShellExecutionPolicy(shellExecutableName) { + // Security: Only allow known shell executables + const validShells = ["pwsh", "powershell"]; + if (!validShells.includes(shellExecutableName)) { + return { isValid: false, policy: "Unknown" }; + } + + try { + // Security: Use literal command string, no interpolation + const policy = execSync("Get-ExecutionPolicy", { + encoding: "utf8", + shell: shellExecutableName, + timeout: 5000, // 5 second timeout + }).trim(); + + const acceptablePolicies = ["RemoteSigned", "Unrestricted", "Bypass"]; + return { + isValid: acceptablePolicies.includes(policy), + policy: policy, + }; + } catch (/** @type {any} */ error) { + // If we can't check the policy, return false to be safe + return { isValid: false, policy: "Unknown" }; + } +} diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js index 8cec258..b26a3ff 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js @@ -2,6 +2,7 @@ import { addLineToFile, doesExecutableExistOnSystem, removeLinesMatchingPattern, + validatePowerShellExecutionPolicy, } from "../helpers.js"; import { execSync } from "child_process"; @@ -39,6 +40,16 @@ function teardown(tools) { } function setup() { + // Check execution policy + const { isValid, policy } = validatePowerShellExecutionPolicy(executableName); + if (!isValid) { + throw new Error( + `PowerShell execution policy is set to '${policy}', which prevents safe-chain from running. ` + + `To fix this, open PowerShell as Administrator and run: Set-ExecutionPolicy -ExecutionPolicy RemoteSigned. ` + + `For more information, see: https://github.com/AikidoSec/safe-chain/blob/main/docs/troubleshooting.md#powershell-execution-policy-blocks-scripts-windows` + ); + } + const startupFile = getStartupFile(); addLineToFile( diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js index 3a15376..5c93f45 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js @@ -8,14 +8,20 @@ import { knownAikidoTools } from "../helpers.js"; describe("PowerShell Core shell integration", () => { let mockStartupFile; let powershell; + let executionPolicyResult; beforeEach(async () => { // Create temporary startup file for testing mockStartupFile = path.join( tmpdir(), - `test-powershell-profile-${Date.now()}.ps1` + `test-powershell-profile-${Date.now()}.ps1`, ); + executionPolicyResult = { + isValid: true, + policy: "RemoteSigned", + }; + // Mock the helpers module mock.module("../helpers.js", { namedExports: { @@ -33,6 +39,7 @@ describe("PowerShell Core shell integration", () => { const filteredLines = lines.filter((line) => !pattern.test(line)); fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8"); }, + validatePowerShellExecutionPolicy: () => executionPolicyResult, }, }); @@ -76,8 +83,8 @@ describe("PowerShell Core shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( content.includes( - '. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script' - ) + '. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script', + ), ); }); }); @@ -98,7 +105,7 @@ describe("PowerShell Core shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - !content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"') + !content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'), ); assert.ok(content.includes("Set-Alias ls ")); assert.ok(content.includes("Set-Alias grep ")); @@ -173,14 +180,14 @@ describe("PowerShell Core shell integration", () => { powershell.setup(); let content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"') + content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'), ); // Teardown powershell.teardown(knownAikidoTools); content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - !content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"') + !content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'), ); }); @@ -197,4 +204,21 @@ describe("PowerShell Core shell integration", () => { assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines"); }); }); + + describe("execution policy", () => { + it(`should throw for restricted policies`, () => { + executionPolicyResult = { + isValid: false, + policy: "Restricted", + }; + + assert.throws( + () => powershell.setup(), + (err) => + err.message.startsWith( + "PowerShell execution policy is set to 'Restricted'", + ), + ); + }); + }); }); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js index e554a32..cb07e0f 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -2,6 +2,7 @@ import { addLineToFile, doesExecutableExistOnSystem, removeLinesMatchingPattern, + validatePowerShellExecutionPolicy, } from "../helpers.js"; import { execSync } from "child_process"; @@ -39,6 +40,16 @@ function teardown(tools) { } function setup() { + // Check execution policy + const { isValid, policy } = validatePowerShellExecutionPolicy(executableName); + if (!isValid) { + throw new Error( + `PowerShell execution policy is set to '${policy}', which prevents safe-chain from running. ` + + `To fix this, open PowerShell as Administrator and run: Set-ExecutionPolicy -ExecutionPolicy RemoteSigned. ` + + `For more information, see: https://github.com/AikidoSec/safe-chain/blob/main/docs/troubleshooting.md#powershell-execution-policy-blocks-scripts-windows` + ); + } + const startupFile = getStartupFile(); addLineToFile( diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js index c201c60..9a3a696 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js @@ -8,14 +8,20 @@ import { knownAikidoTools } from "../helpers.js"; describe("Windows PowerShell shell integration", () => { let mockStartupFile; let windowsPowershell; + let executionPolicyResult; beforeEach(async () => { // Create temporary startup file for testing mockStartupFile = path.join( tmpdir(), - `test-windows-powershell-profile-${Date.now()}.ps1` + `test-windows-powershell-profile-${Date.now()}.ps1`, ); + executionPolicyResult = { + isValid: true, + policy: "RemoteSigned", + }; + // Mock the helpers module mock.module("../helpers.js", { namedExports: { @@ -33,6 +39,7 @@ describe("Windows PowerShell shell integration", () => { const filteredLines = lines.filter((line) => !pattern.test(line)); fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8"); }, + validatePowerShellExecutionPolicy: () => executionPolicyResult, }, }); @@ -76,8 +83,8 @@ describe("Windows PowerShell shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( content.includes( - '. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script' - ) + '. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script', + ), ); }); }); @@ -98,7 +105,7 @@ describe("Windows PowerShell shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - !content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"') + !content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'), ); assert.ok(content.includes("Set-Alias ls ")); assert.ok(content.includes("Set-Alias grep ")); @@ -173,14 +180,14 @@ describe("Windows PowerShell shell integration", () => { windowsPowershell.setup(); let content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"') + content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'), ); // Teardown windowsPowershell.teardown(knownAikidoTools); content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - !content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"') + !content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'), ); }); @@ -197,4 +204,21 @@ describe("Windows PowerShell shell integration", () => { assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines"); }); }); + + describe("execution policy", () => { + it(`should throw for restricted policies`, () => { + executionPolicyResult = { + isValid: false, + policy: "Restricted", + }; + + assert.throws( + () => windowsPowershell.setup(), + (err) => + err.message.startsWith( + "PowerShell execution policy is set to 'Restricted'", + ), + ); + }); + }); }); From ff16530314f0951491cc7a656b707ce00eebe80d Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 5 Feb 2026 09:52:18 +0100 Subject: [PATCH 157/360] Fix linting --- packages/safe-chain/src/shell-integration/helpers.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index 17b527c..044cc07 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -269,7 +269,7 @@ export function validatePowerShellExecutionPolicy(shellExecutableName) { isValid: acceptablePolicies.includes(policy), policy: policy, }; - } catch (/** @type {any} */ error) { + } catch { // If we can't check the policy, return false to be safe return { isValid: false, policy: "Unknown" }; } From ad32a8d9be67ce3b412ad7ea5e033fa9ee6b6607 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 5 Feb 2026 10:05:26 +0100 Subject: [PATCH 158/360] Run command for execution policy with -Command --- packages/safe-chain/src/shell-integration/helpers.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index 044cc07..3c60ac1 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -258,7 +258,7 @@ export function validatePowerShellExecutionPolicy(shellExecutableName) { try { // Security: Use literal command string, no interpolation - const policy = execSync("Get-ExecutionPolicy", { + const policy = execSync('-Command "Get-ExecutionPolicy"', { encoding: "utf8", shell: shellExecutableName, timeout: 5000, // 5 second timeout From 3e90c0abd115c0584ef6dd6cb971bfe666d89368 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 5 Feb 2026 10:12:43 +0100 Subject: [PATCH 159/360] Import module for execution policy --- .../safe-chain/src/shell-integration/helpers.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index 3c60ac1..d243123 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -258,11 +258,15 @@ export function validatePowerShellExecutionPolicy(shellExecutableName) { try { // Security: Use literal command string, no interpolation - const policy = execSync('-Command "Get-ExecutionPolicy"', { - encoding: "utf8", - shell: shellExecutableName, - timeout: 5000, // 5 second timeout - }).trim(); + // Import the Security module first - works for both powershell.exe and pwsh.exe + const policy = execSync( + "Import-Module Microsoft.PowerShell.Security; Get-ExecutionPolicy", + { + encoding: "utf8", + shell: shellExecutableName, + timeout: 5000, // 5 second timeout + } + ).trim(); const acceptablePolicies = ["RemoteSigned", "Unrestricted", "Bypass"]; return { From aa461b27c36b1bbcb13e177abafde2740b6bbd0e Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 5 Feb 2026 10:24:28 +0100 Subject: [PATCH 160/360] Use safeSpawn --- .../src/shell-integration/helpers.js | 23 ++++++------- .../safe-chain/src/shell-integration/setup.js | 32 +++++++++---------- .../src/shell-integration/shellDetection.js | 4 +-- .../supported-shells/powershell.js | 15 +++++---- .../supported-shells/powershell.spec.js | 18 +++++------ .../supported-shells/windowsPowershell.js | 15 +++++---- .../windowsPowershell.spec.js | 18 +++++------ 7 files changed, 62 insertions(+), 63 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index d243123..a3d2f5e 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -1,8 +1,9 @@ -import { spawnSync, execSync } from "child_process"; +import { spawnSync } from "child_process"; import * as os from "os"; import fs from "fs"; import path from "path"; import { ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; +import { safeSpawn } from "../utils/safeSpawn.js"; /** * @typedef {Object} AikidoTool @@ -247,9 +248,9 @@ function createFileIfNotExists(filePath) { /** * Checks if PowerShell execution policy allows script execution * @param {string} shellExecutableName - The name of the PowerShell executable ("pwsh" or "powershell") - * @returns {{isValid: boolean, policy: string}} validation result + * @returns {Promise<{isValid: boolean, policy: string}>} validation result */ -export function validatePowerShellExecutionPolicy(shellExecutableName) { +export async function validatePowerShellExecutionPolicy(shellExecutableName) { // Security: Only allow known shell executables const validShells = ["pwsh", "powershell"]; if (!validShells.includes(shellExecutableName)) { @@ -257,16 +258,12 @@ export function validatePowerShellExecutionPolicy(shellExecutableName) { } try { - // Security: Use literal command string, no interpolation - // Import the Security module first - works for both powershell.exe and pwsh.exe - const policy = execSync( - "Import-Module Microsoft.PowerShell.Security; Get-ExecutionPolicy", - { - encoding: "utf8", - shell: shellExecutableName, - timeout: 5000, // 5 second timeout - } - ).trim(); + const commandResult = await safeSpawn(shellExecutableName, [ + "-Command", + "Get-ExecutionPolicy", + ]); + + const policy = commandResult.stdout.trim(); const acceptablePolicies = ["RemoteSigned", "Unrestricted", "Bypass"]; return { diff --git a/packages/safe-chain/src/shell-integration/setup.js b/packages/safe-chain/src/shell-integration/setup.js index 7e64c0b..4138db6 100644 --- a/packages/safe-chain/src/shell-integration/setup.js +++ b/packages/safe-chain/src/shell-integration/setup.js @@ -1,7 +1,11 @@ import chalk from "chalk"; import { ui } from "../environment/userInteraction.js"; import { detectShells } from "./shellDetection.js"; -import { knownAikidoTools, getPackageManagerList, getScriptsDir } from "./helpers.js"; +import { + knownAikidoTools, + getPackageManagerList, + getScriptsDir, +} from "./helpers.js"; import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; @@ -26,7 +30,7 @@ if (import.meta.url) { export async function setup() { ui.writeInformation( chalk.bold("Setting up shell aliases.") + - ` This will wrap safe-chain around ${getPackageManagerList()}.` + ` This will wrap safe-chain around ${getPackageManagerList()}.`, ); ui.emptyLine(); @@ -42,12 +46,12 @@ export async function setup() { ui.writeInformation( `Detected ${shells.length} supported shell(s): ${shells .map((shell) => chalk.bold(shell.name)) - .join(", ")}.` + .join(", ")}.`, ); let updatedCount = 0; for (const shell of shells) { - if (setupShell(shell)) { + if (await setupShell(shell)) { updatedCount++; } } @@ -58,7 +62,7 @@ export async function setup() { } } catch (/** @type {any} */ error) { ui.writeError( - `Failed to set up shell aliases: ${error.message}. Please check your shell configuration.` + `Failed to set up shell aliases: ${error.message}. Please check your shell configuration.`, ); return; } @@ -68,12 +72,12 @@ export async function setup() { * Calls the setup function for the given shell and reports the result. * @param {import("./shellDetection.js").Shell} shell */ -function setupShell(shell) { +async function setupShell(shell) { let success = false; let error; try { shell.teardown(knownAikidoTools); // First, tear down to prevent duplicate aliases - success = shell.setup(knownAikidoTools); + success = await shell.setup(knownAikidoTools); } catch (/** @type {any} */ err) { success = false; error = err; @@ -82,14 +86,14 @@ function setupShell(shell) { if (success) { ui.writeInformation( `${chalk.bold("- " + shell.name + ":")} ${chalk.green( - "Setup successful" - )}` + "Setup successful", + )}`, ); } else { ui.writeError( `${chalk.bold("- " + shell.name + ":")} ${chalk.red( - "Setup failed" - )}. Please check your ${shell.name} configuration.` + "Setup failed", + )}. Please check your ${shell.name} configuration.`, ); if (error) { let message = ` Error: ${error.message}`; @@ -115,11 +119,7 @@ function copyStartupFiles() { } // Use absolute path for source - const sourcePath = path.join( - dirname, - "startup-scripts", - file - ); + const sourcePath = path.join(dirname, "startup-scripts", file); fs.copyFileSync(sourcePath, targetPath); } } diff --git a/packages/safe-chain/src/shell-integration/shellDetection.js b/packages/safe-chain/src/shell-integration/shellDetection.js index 9e0f110..996125c 100644 --- a/packages/safe-chain/src/shell-integration/shellDetection.js +++ b/packages/safe-chain/src/shell-integration/shellDetection.js @@ -9,7 +9,7 @@ import { ui } from "../environment/userInteraction.js"; * @typedef {Object} Shell * @property {string} name * @property {() => boolean} isInstalled - * @property {(tools: import("./helpers.js").AikidoTool[]) => boolean} setup + * @property {(tools: import("./helpers.js").AikidoTool[]) => boolean|Promise} setup * @property {(tools: import("./helpers.js").AikidoTool[]) => boolean} teardown */ @@ -28,7 +28,7 @@ export function detectShells() { } } catch (/** @type {any} */ error) { ui.writeError( - `We were not able to detect which shells are installed on your system. Please check your shell configuration. Error: ${error.message}` + `We were not able to detect which shells are installed on your system. Please check your shell configuration. Error: ${error.message}`, ); return []; } diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js index b26a3ff..a169915 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js @@ -26,27 +26,28 @@ function teardown(tools) { // Remove any existing alias for the tool removeLinesMatchingPattern( startupFile, - new RegExp(`^Set-Alias\\s+${tool}\\s+`) + new RegExp(`^Set-Alias\\s+${tool}\\s+`), ); } // Remove the line that sources the safe-chain PowerShell initialization script removeLinesMatchingPattern( startupFile, - /^\.\s+["']?\$HOME[/\\].safe-chain[/\\]scripts[/\\]init-pwsh\.ps1["']?/ + /^\.\s+["']?\$HOME[/\\].safe-chain[/\\]scripts[/\\]init-pwsh\.ps1["']?/, ); return true; } -function setup() { +async function setup() { // Check execution policy - const { isValid, policy } = validatePowerShellExecutionPolicy(executableName); + const { isValid, policy } = + await validatePowerShellExecutionPolicy(executableName); if (!isValid) { throw new Error( `PowerShell execution policy is set to '${policy}', which prevents safe-chain from running. ` + `To fix this, open PowerShell as Administrator and run: Set-ExecutionPolicy -ExecutionPolicy RemoteSigned. ` + - `For more information, see: https://github.com/AikidoSec/safe-chain/blob/main/docs/troubleshooting.md#powershell-execution-policy-blocks-scripts-windows` + `For more information, see: https://github.com/AikidoSec/safe-chain/blob/main/docs/troubleshooting.md#powershell-execution-policy-blocks-scripts-windows`, ); } @@ -54,7 +55,7 @@ function setup() { addLineToFile( startupFile, - `. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script` + `. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script`, ); return true; @@ -68,7 +69,7 @@ function getStartupFile() { }).trim(); } catch (/** @type {any} */ error) { throw new Error( - `Command failed: ${startupFileCommand}. Error: ${error.message}` + `Command failed: ${startupFileCommand}. Error: ${error.message}`, ); } } diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js index 5c93f45..de2c14b 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js @@ -76,8 +76,8 @@ describe("PowerShell Core shell integration", () => { }); describe("setup", () => { - it("should add init-pwsh.ps1 source line", () => { - const result = powershell.setup(); + it("should add init-pwsh.ps1 source line", async () => { + const result = await powershell.setup(); assert.strictEqual(result, true); const content = fs.readFileSync(mockStartupFile, "utf-8"); @@ -175,9 +175,9 @@ describe("PowerShell Core shell integration", () => { }); describe("integration tests", () => { - it("should handle complete setup and teardown cycle", () => { + it("should handle complete setup and teardown cycle", async () => { // Setup - powershell.setup(); + await powershell.setup(); let content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'), @@ -191,10 +191,10 @@ describe("PowerShell Core shell integration", () => { ); }); - it("should handle multiple setup calls", () => { - powershell.setup(); + it("should handle multiple setup calls", async () => { + await powershell.setup(); powershell.teardown(knownAikidoTools); - powershell.setup(); + await powershell.setup(); const content = fs.readFileSync(mockStartupFile, "utf-8"); const sourceMatches = ( @@ -206,13 +206,13 @@ describe("PowerShell Core shell integration", () => { }); describe("execution policy", () => { - it(`should throw for restricted policies`, () => { + it(`should throw for restricted policies`, async () => { executionPolicyResult = { isValid: false, policy: "Restricted", }; - assert.throws( + await assert.rejects( () => powershell.setup(), (err) => err.message.startsWith( diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js index cb07e0f..acf0830 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -26,27 +26,28 @@ function teardown(tools) { // Remove any existing alias for the tool removeLinesMatchingPattern( startupFile, - new RegExp(`^Set-Alias\\s+${tool}\\s+`) + new RegExp(`^Set-Alias\\s+${tool}\\s+`), ); } // Remove the line that sources the safe-chain PowerShell initialization script removeLinesMatchingPattern( startupFile, - /^\.\s+["']?\$HOME[/\\].safe-chain[/\\]scripts[/\\]init-pwsh\.ps1["']?/ + /^\.\s+["']?\$HOME[/\\].safe-chain[/\\]scripts[/\\]init-pwsh\.ps1["']?/, ); return true; } -function setup() { +async function setup() { // Check execution policy - const { isValid, policy } = validatePowerShellExecutionPolicy(executableName); + const { isValid, policy } = + await validatePowerShellExecutionPolicy(executableName); if (!isValid) { throw new Error( `PowerShell execution policy is set to '${policy}', which prevents safe-chain from running. ` + `To fix this, open PowerShell as Administrator and run: Set-ExecutionPolicy -ExecutionPolicy RemoteSigned. ` + - `For more information, see: https://github.com/AikidoSec/safe-chain/blob/main/docs/troubleshooting.md#powershell-execution-policy-blocks-scripts-windows` + `For more information, see: https://github.com/AikidoSec/safe-chain/blob/main/docs/troubleshooting.md#powershell-execution-policy-blocks-scripts-windows`, ); } @@ -54,7 +55,7 @@ function setup() { addLineToFile( startupFile, - `. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script` + `. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script`, ); return true; @@ -68,7 +69,7 @@ function getStartupFile() { }).trim(); } catch (/** @type {any} */ error) { throw new Error( - `Command failed: ${startupFileCommand}. Error: ${error.message}` + `Command failed: ${startupFileCommand}. Error: ${error.message}`, ); } } diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js index 9a3a696..561d0d4 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js @@ -76,8 +76,8 @@ describe("Windows PowerShell shell integration", () => { }); describe("setup", () => { - it("should add init-pwsh.ps1 source line", () => { - const result = windowsPowershell.setup(); + it("should add init-pwsh.ps1 source line", async () => { + const result = await windowsPowershell.setup(); assert.strictEqual(result, true); const content = fs.readFileSync(mockStartupFile, "utf-8"); @@ -175,9 +175,9 @@ describe("Windows PowerShell shell integration", () => { }); describe("integration tests", () => { - it("should handle complete setup and teardown cycle", () => { + it("should handle complete setup and teardown cycle", async () => { // Setup - windowsPowershell.setup(); + await windowsPowershell.setup(); let content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'), @@ -191,10 +191,10 @@ describe("Windows PowerShell shell integration", () => { ); }); - it("should handle multiple setup calls", () => { - windowsPowershell.setup(); + it("should handle multiple setup calls", async () => { + await windowsPowershell.setup(); windowsPowershell.teardown(knownAikidoTools); - windowsPowershell.setup(); + await windowsPowershell.setup(); const content = fs.readFileSync(mockStartupFile, "utf-8"); const sourceMatches = ( @@ -206,13 +206,13 @@ describe("Windows PowerShell shell integration", () => { }); describe("execution policy", () => { - it(`should throw for restricted policies`, () => { + it(`should throw for restricted policies`, async () => { executionPolicyResult = { isValid: false, policy: "Restricted", }; - assert.throws( + await assert.rejects( () => windowsPowershell.setup(), (err) => err.message.startsWith( From 13f2ae6e2228866dcf69419c4e8f29d0c2153169 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 5 Feb 2026 10:45:13 +0100 Subject: [PATCH 161/360] Fix PSModulePath --- .../src/shell-integration/helpers.js | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index a3d2f5e..23380db 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -258,10 +258,30 @@ export async function validatePowerShellExecutionPolicy(shellExecutableName) { } try { - const commandResult = await safeSpawn(shellExecutableName, [ - "-Command", - "Get-ExecutionPolicy", - ]); + const spawnOptions = {}; + + // For Windows PowerShell (5.1), clean PSModulePath to avoid conflicts with PowerShell 7 modules + // When PowerShell 7 is installed, it adds its module paths to PSModulePath, causing + // Windows PowerShell to try loading incompatible PowerShell 7 modules (TypeData conflicts) + if (shellExecutableName === "powershell") { + const userProfile = process.env.USERPROFILE || ""; + const cleanPSModulePath = [ + path.join(userProfile, "Documents", "WindowsPowerShell", "Modules"), + "C:\\Program Files\\WindowsPowerShell\\Modules", + "C:\\WINDOWS\\system32\\WindowsPowerShell\\v1.0\\Modules", + ].join(";"); + + spawnOptions.env = { + ...process.env, + PSModulePath: cleanPSModulePath, + }; + } + + const commandResult = await safeSpawn( + shellExecutableName, + ["-Command", "Get-ExecutionPolicy"], + spawnOptions + ); const policy = commandResult.stdout.trim(); From 0dfa151b024da1a32cf88bcc2e11f5399132a967 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 5 Feb 2026 10:45:45 +0100 Subject: [PATCH 162/360] Fix linting --- .../safe-chain/src/shell-integration/helpers.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index 23380db..8f1450d 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -258,11 +258,10 @@ export async function validatePowerShellExecutionPolicy(shellExecutableName) { } try { - const spawnOptions = {}; - // For Windows PowerShell (5.1), clean PSModulePath to avoid conflicts with PowerShell 7 modules // When PowerShell 7 is installed, it adds its module paths to PSModulePath, causing // Windows PowerShell to try loading incompatible PowerShell 7 modules (TypeData conflicts) + let spawnOptions; if (shellExecutableName === "powershell") { const userProfile = process.env.USERPROFILE || ""; const cleanPSModulePath = [ @@ -271,10 +270,14 @@ export async function validatePowerShellExecutionPolicy(shellExecutableName) { "C:\\WINDOWS\\system32\\WindowsPowerShell\\v1.0\\Modules", ].join(";"); - spawnOptions.env = { - ...process.env, - PSModulePath: cleanPSModulePath, + spawnOptions = { + env: { + ...process.env, + PSModulePath: cleanPSModulePath, + }, }; + } else { + spawnOptions = {}; } const commandResult = await safeSpawn( From f1e5e7bab29c71e862d258a06c3e03a1dbad2d0b Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 5 Feb 2026 11:01:56 +0100 Subject: [PATCH 163/360] Improve error message --- .../src/shell-integration/supported-shells/powershell.js | 4 +--- .../shell-integration/supported-shells/windowsPowershell.js | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js index a169915..fd2e3dd 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js @@ -45,9 +45,7 @@ async function setup() { await validatePowerShellExecutionPolicy(executableName); if (!isValid) { throw new Error( - `PowerShell execution policy is set to '${policy}', which prevents safe-chain from running. ` + - `To fix this, open PowerShell as Administrator and run: Set-ExecutionPolicy -ExecutionPolicy RemoteSigned. ` + - `For more information, see: https://github.com/AikidoSec/safe-chain/blob/main/docs/troubleshooting.md#powershell-execution-policy-blocks-scripts-windows`, + `PowerShell execution policy is set to '${policy}', which prevents safe-chain from running.\n\nTo fix this, open PowerShell as Administrator and run: Set-ExecutionPolicy -ExecutionPolicy RemoteSigned. `, ); } diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js index acf0830..0a4d282 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -45,9 +45,7 @@ async function setup() { await validatePowerShellExecutionPolicy(executableName); if (!isValid) { throw new Error( - `PowerShell execution policy is set to '${policy}', which prevents safe-chain from running. ` + - `To fix this, open PowerShell as Administrator and run: Set-ExecutionPolicy -ExecutionPolicy RemoteSigned. ` + - `For more information, see: https://github.com/AikidoSec/safe-chain/blob/main/docs/troubleshooting.md#powershell-execution-policy-blocks-scripts-windows`, + `PowerShell execution policy is set to '${policy}', which prevents safe-chain from running.\n\nTo fix this, open PowerShell as Administrator and run: Set-ExecutionPolicy -ExecutionPolicy RemoteSigned. `, ); } From bab128ab2663acb9a754d3440f38c09be1b91def Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 5 Feb 2026 11:03:49 +0100 Subject: [PATCH 164/360] Undo install script changes --- install-scripts/install-safe-chain.ps1 | 25 +------------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index 25ef8b7..ffe2505 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -31,28 +31,6 @@ function Write-Error-Custom { exit 1 } -# Check if the PowerShell execution policy allows script execution -function Test-ExecutionPolicy { - $policy = Get-ExecutionPolicy - $acceptablePolicies = @('RemoteSigned', 'Unrestricted', 'Bypass') - return $acceptablePolicies -contains $policy -} - - -if (-not (Test-ExecutionPolicy)) { - $currentPolicy = Get-ExecutionPolicy - Write-Error-Custom @" -PowerShell execution policy is set to '$currentPolicy', which prevents safe-chain from running. - -The execution policy must be at least 'RemoteSigned' to allow safe-chain's initialization script to run. - -To fix this, open PowerShell as Administrator and run: - Set-ExecutionPolicy -ExecutionPolicy RemoteSigned - -Then restart this installation. -"@ -} - # Get currently installed version of safe-chain function Get-InstalledVersion { # Check if safe-chain command exists @@ -179,8 +157,7 @@ function Install-SafeChain { Write-Warn "" if ($ci) { Write-Warn " iex `"& { `$(iwr 'https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1' -UseBasicParsing) } -ci`"" - } - else { + } else { Write-Warn " iex (iwr `"https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1`" -UseBasicParsing)" } Write-Warn "" From 369167e005808d40f499bb05abe873a34ee94201 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 5 Feb 2026 11:08:04 +0100 Subject: [PATCH 165/360] Error message indentation fix --- .../src/shell-integration/supported-shells/powershell.js | 2 +- .../src/shell-integration/supported-shells/windowsPowershell.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js index fd2e3dd..b05b57b 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js @@ -45,7 +45,7 @@ async function setup() { await validatePowerShellExecutionPolicy(executableName); if (!isValid) { throw new Error( - `PowerShell execution policy is set to '${policy}', which prevents safe-chain from running.\n\nTo fix this, open PowerShell as Administrator and run: Set-ExecutionPolicy -ExecutionPolicy RemoteSigned. `, + `PowerShell execution policy is set to '${policy}', which prevents safe-chain from running.\n -> To fix this, open PowerShell as Administrator and run: Set-ExecutionPolicy -ExecutionPolicy RemoteSigned. `, ); } diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js index 0a4d282..17820e0 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -45,7 +45,7 @@ async function setup() { await validatePowerShellExecutionPolicy(executableName); if (!isValid) { throw new Error( - `PowerShell execution policy is set to '${policy}', which prevents safe-chain from running.\n\nTo fix this, open PowerShell as Administrator and run: Set-ExecutionPolicy -ExecutionPolicy RemoteSigned. `, + `PowerShell execution policy is set to '${policy}', which prevents safe-chain from running.\n -> To fix this, open PowerShell as Administrator and run: Set-ExecutionPolicy -ExecutionPolicy RemoteSigned. `, ); } From 03d67d92be865bb576e9c35ae24d6045a8e91489 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 5 Feb 2026 11:09:15 +0100 Subject: [PATCH 166/360] Change teardown order --- packages/safe-chain/bin/safe-chain.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index dbefa10..2913d28 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -104,8 +104,8 @@ if (tool) { })(); } } else if (command === "teardown") { - teardownDirectories(); teardown(); + teardownDirectories(); } else if (command === "setup-ci") { setupCi(); } else if (command === "--version" || command === "-v" || command === "-v") { From 149a28e0dc0ac99c92a9e85c2b605478dd86439d Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 5 Feb 2026 11:20:14 +0100 Subject: [PATCH 167/360] Improve comments --- packages/safe-chain/src/shell-integration/helpers.js | 7 ++++--- .../src/shell-integration/supported-shells/powershell.js | 1 - .../supported-shells/windowsPowershell.js | 1 - 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index 8f1450d..36fa908 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -259,8 +259,9 @@ export async function validatePowerShellExecutionPolicy(shellExecutableName) { try { // For Windows PowerShell (5.1), clean PSModulePath to avoid conflicts with PowerShell 7 modules - // When PowerShell 7 is installed, it adds its module paths to PSModulePath, causing - // Windows PowerShell to try loading incompatible PowerShell 7 modules (TypeData conflicts) + // When safe-chain is invoked from PowerShell 7, it sets its module paths to PSModulePath, causing + // Windows PowerShell to try loading incompatible PowerShell 7 modules. + // Setting the environment to Windows PowerShell's modules fixes this. let spawnOptions; if (shellExecutableName === "powershell") { const userProfile = process.env.USERPROFILE || ""; @@ -283,7 +284,7 @@ export async function validatePowerShellExecutionPolicy(shellExecutableName) { const commandResult = await safeSpawn( shellExecutableName, ["-Command", "Get-ExecutionPolicy"], - spawnOptions + spawnOptions, ); const policy = commandResult.stdout.trim(); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js index b05b57b..657548a 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js @@ -40,7 +40,6 @@ function teardown(tools) { } async function setup() { - // Check execution policy const { isValid, policy } = await validatePowerShellExecutionPolicy(executableName); if (!isValid) { diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js index 17820e0..f6f67aa 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -40,7 +40,6 @@ function teardown(tools) { } async function setup() { - // Check execution policy const { isValid, policy } = await validatePowerShellExecutionPolicy(executableName); if (!isValid) { From cab1e11e95b3aea93ffcce17dd53bbafb312e117 Mon Sep 17 00:00:00 2001 From: Samuel Date: Thu, 5 Feb 2026 11:33:37 +0100 Subject: [PATCH 168/360] Remove duplicate verbose logging information from troubleshooting Removed section on enabling verbose logging for diagnostics. --- docs/troubleshooting.md | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 0b2845b..456fe58 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -308,22 +308,6 @@ Look for and remove: rm -rf ~/.safe-chain ``` -## Getting More Information - -### Enable Verbose Logging - -Get detailed diagnostic output using a CLI flag or environment variable: - -```bash -# Using CLI flag -npm install express --safe-chain-logging=verbose -pip install requests --safe-chain-logging=verbose - -# Using environment variable (applies to all commands) -export SAFE_CHAIN_LOGGING=verbose -npm install express -``` - ### Report Issues If you encounter problems: From 446f45cc283c51b8315b6e910269f87166cfa66a Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 5 Feb 2026 11:35:30 +0100 Subject: [PATCH 169/360] Add link to help --- .../src/shell-integration/supported-shells/powershell.js | 2 +- .../src/shell-integration/supported-shells/windowsPowershell.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js index 657548a..96eb219 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js @@ -44,7 +44,7 @@ async function setup() { await validatePowerShellExecutionPolicy(executableName); if (!isValid) { throw new Error( - `PowerShell execution policy is set to '${policy}', which prevents safe-chain from running.\n -> To fix this, open PowerShell as Administrator and run: Set-ExecutionPolicy -ExecutionPolicy RemoteSigned. `, + `PowerShell execution policy is set to '${policy}', which prevents safe-chain from running.\n -> To fix this, open PowerShell as Administrator and run: Set-ExecutionPolicy -ExecutionPolicy RemoteSigned.\n For more information, see: https://help.aikido.dev/code-scanning/aikido-malware-scanning/safe-chain-troubleshooting#powershell-execution-policy-blocks-scripts-windows`, ); } diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js index f6f67aa..2740456 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -44,7 +44,7 @@ async function setup() { await validatePowerShellExecutionPolicy(executableName); if (!isValid) { throw new Error( - `PowerShell execution policy is set to '${policy}', which prevents safe-chain from running.\n -> To fix this, open PowerShell as Administrator and run: Set-ExecutionPolicy -ExecutionPolicy RemoteSigned. `, + `PowerShell execution policy is set to '${policy}', which prevents safe-chain from running.\n -> To fix this, open PowerShell as Administrator and run: Set-ExecutionPolicy -ExecutionPolicy RemoteSigned.\n For more information, see: https://help.aikido.dev/code-scanning/aikido-malware-scanning/safe-chain-troubleshooting#powershell-execution-policy-blocks-scripts-windows`, ); } From 8ea4463ac5bf64bb823df36673be6312b572d375 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 5 Feb 2026 11:38:28 +0100 Subject: [PATCH 170/360] Update troubleshooting link --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 128d662..003921c 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,6 @@ You can find all available versions on the [releases page](https://github.com/Ai ### Verify the installation 1. **❗Restart your terminal** to start using the Aikido Safe Chain. - - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, poetry, uv and pipx are loaded correctly. If you do not restart your terminal, the aliases will not be available. 2. **Verify the installation** by running the verification command: @@ -159,7 +158,6 @@ You can control the output from Aikido Safe Chain using the `--safe-chain-loggin You can set the logging level through multiple sources (in order of priority): 1. **CLI Argument** (highest priority): - - `--safe-chain-logging=silent` - Suppresses all Aikido Safe Chain output except when malware is blocked. The package manager output is written to stdout as normal, and Safe Chain only writes a short message if it has blocked malware and causes the process to exit. ```shell @@ -396,4 +394,4 @@ After setup, all subsequent package manager commands in your CI pipeline will au # Troubleshooting -Having issues? See the [Troubleshooting Guide](https://github.com/AikidoSec/safe-chain/blob/main/docs/troubleshooting.md) for help with common problems. +Having issues? See the [Troubleshooting Guide](https://help.aikido.dev/code-scanning/aikido-malware-scanning/safe-chain-troubleshooting) for help with common problems. From 87c5eddc9e934834406eef1f8e37e66808643e4f Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 5 Feb 2026 11:52:06 +0100 Subject: [PATCH 171/360] Write warning when getting executionpolicy fails --- packages/safe-chain/src/shell-integration/helpers.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index 36fa908..18ba52e 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -4,6 +4,7 @@ import fs from "fs"; import path from "path"; import { ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; import { safeSpawn } from "../utils/safeSpawn.js"; +import { ui } from "../environment/userInteraction.js"; /** * @typedef {Object} AikidoTool @@ -294,8 +295,10 @@ export async function validatePowerShellExecutionPolicy(shellExecutableName) { isValid: acceptablePolicies.includes(policy), policy: policy, }; - } catch { - // If we can't check the policy, return false to be safe + } catch (err) { + ui.writeWarning( + `An error happened while trying to find the current executionpolicy in powershell: ${err}`, + ); return { isValid: false, policy: "Unknown" }; } } From dc09d871ed25a3b71d69aecfbc850246ec7093aa Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 17 Feb 2026 12:33:25 +0100 Subject: [PATCH 172/360] Remove ultimate commands (not ready yet) --- packages/safe-chain/bin/safe-chain.js | 72 --------------------------- 1 file changed, 72 deletions(-) diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index 2913d28..3bc8a5a 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -70,39 +70,6 @@ if (tool) { process.exit(0); } else if (command === "setup") { setup(); -} else if (command === "ultimate") { - const cliArgs = initializeCliArguments(process.argv.slice(2)); - const subCommand = cliArgs[1]; - if (subCommand === "uninstall") { - guardCliArgsMaxLenght(2, cliArgs, "safe-chain ultimate uninstall"); - (async () => { - await uninstallUltimate(); - })(); - } else if (subCommand === "troubleshooting-logs") { - guardCliArgsMaxLenght( - 2, - cliArgs, - "safe-chain ultimate troubleshooting-logs", - ); - (async () => { - await printUltimateLogs(); - })(); - } else if (subCommand === "troubleshooting-export") { - guardCliArgsMaxLenght( - 2, - cliArgs, - "safe-chain ultimate troubleshooting-export", - ); - (async () => { - await troubleshootingExport(); - })(); - } else { - guardCliArgsMaxLenght(1, cliArgs, "safe-chain ultimate"); - // Install command = when no subcommand is provided (safe-chain ultimate) - (async () => { - await installUltimate(); - })(); - } } else if (command === "teardown") { teardown(); teardownDirectories(); @@ -121,22 +88,6 @@ if (tool) { process.exit(1); } -/** - * @param {Number} maxLength - * @param {String[]} args - * @param {String} command - */ -function guardCliArgsMaxLenght(maxLength, args, command) { - if (args.length > maxLength) { - ui.writeError(`Unexpected number of arguments for command ${command}.`); - ui.emptyLine(); - - writeHelp(); - - process.exit(1); - } -} - function writeHelp() { ui.writeInformation( chalk.bold("Usage: ") + chalk.cyan("safe-chain "), @@ -171,29 +122,6 @@ function writeHelp() { )}): Display the current version of safe-chain.`, ); ui.emptyLine(); - ui.writeInformation(chalk.bold("Ultimate commands:")); - ui.emptyLine(); - ui.writeInformation( - `- ${chalk.cyan( - "safe-chain ultimate", - )}: Install the ultimate version of safe-chain, enabling protection for more eco-systems.`, - ); - ui.writeInformation( - `- ${chalk.cyan( - "safe-chain ultimate troubleshooting-logs", - )}: Prints standard and error logs for safe-chain ultimate and it's proxy.`, - ); - ui.writeInformation( - `- ${chalk.cyan( - "safe-chain ultimate troubleshooting-export", - )}: Creates a zip archive of useful data for troubleshooting safe-chain ultimate, that can be shared with our support team.`, - ); - ui.writeInformation( - `- ${chalk.cyan( - "safe-chain ultimate uninstall", - )}: Uninstall the ultimate version of safe-chain.`, - ); - ui.emptyLine(); } async function getVersion() { From 688f017d3c22033a89f528da956144fd64e083f8 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 17 Feb 2026 12:35:16 +0100 Subject: [PATCH 173/360] Fix linting issues --- packages/safe-chain/bin/safe-chain.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index 3bc8a5a..86a154d 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -16,14 +16,6 @@ import path from "path"; import { fileURLToPath } from "url"; import fs from "fs"; import { knownAikidoTools } from "../src/shell-integration/helpers.js"; -import { - installUltimate, - uninstallUltimate, -} from "../src/installation/installUltimate.js"; -import { - printUltimateLogs, - troubleshootingExport, -} from "../src/ultimate/ultimateTroubleshooting.js"; /** @type {string} */ // This checks the current file's dirname in a way that's compatible with: From e6a58ef5ae7f53df81d0c7c4414fe0039b7cd449 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 17 Feb 2026 12:36:32 +0100 Subject: [PATCH 174/360] Remove ultimate from list of available commands --- packages/safe-chain/bin/safe-chain.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index 86a154d..8d942e4 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -88,7 +88,7 @@ function writeHelp() { ui.writeInformation( `Available commands: ${chalk.cyan("setup")}, ${chalk.cyan( "teardown", - )}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("ultimate")}, ${chalk.cyan("help")}, ${chalk.cyan( + )}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("help")}, ${chalk.cyan( "--version", )}`, ); From 62e262785f4b908291e7a99a7ca4e329d3948138 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 27 Feb 2026 01:09:45 +0800 Subject: [PATCH 175/360] fix(cli): surface package manager command execution failures --- .../packagemanager/_shared/commandErrors.js | 17 ++++++ .../_shared/commandErrors.spec.js | 59 +++++++++++++++++++ .../bun/createBunPackageManager.js | 8 +-- .../src/packagemanager/npm/runNpmCommand.js | 8 +-- .../src/packagemanager/npx/runNpxCommand.js | 8 +-- .../src/packagemanager/pip/runPipCommand.js | 9 +-- .../src/packagemanager/pipx/runPipXCommand.js | 9 +-- .../src/packagemanager/pnpm/runPnpmCommand.js | 9 +-- .../poetry/createPoetryPackageManager.js | 9 +-- .../src/packagemanager/uv/runUvCommand.js | 9 +-- .../src/packagemanager/yarn/runYarnCommand.js | 8 +-- 11 files changed, 95 insertions(+), 58 deletions(-) create mode 100644 packages/safe-chain/src/packagemanager/_shared/commandErrors.js create mode 100644 packages/safe-chain/src/packagemanager/_shared/commandErrors.spec.js diff --git a/packages/safe-chain/src/packagemanager/_shared/commandErrors.js b/packages/safe-chain/src/packagemanager/_shared/commandErrors.js new file mode 100644 index 0000000..bee68e4 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/_shared/commandErrors.js @@ -0,0 +1,17 @@ +import { ui } from "../../environment/userInteraction.js"; + +/** + * Centralized logging for package-manager command launch failures. + * + * @param {any} error - Error thrown by safeSpawn while preparing/running the command. + * @param {string} command - Command name that failed to execute. + * @returns {{status: number}} + */ +export function reportCommandExecutionFailure(error, command) { + const message = typeof error?.message === "string" ? error.message : "Unknown error"; + ui.writeError(`Error executing command: ${message}`); + + ui.writeError(`Is '${command}' installed and available on your system?`); + + return { status: typeof error?.status === "number" ? error.status : 1 }; +} diff --git a/packages/safe-chain/src/packagemanager/_shared/commandErrors.spec.js b/packages/safe-chain/src/packagemanager/_shared/commandErrors.spec.js new file mode 100644 index 0000000..350228a --- /dev/null +++ b/packages/safe-chain/src/packagemanager/_shared/commandErrors.spec.js @@ -0,0 +1,59 @@ +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; + +describe("reportCommandExecutionFailure", () => { + let errorLines; + + beforeEach(async () => { + errorLines = []; + + mock.module("../../environment/userInteraction.js", { + namedExports: { + ui: { + writeError: (...args) => { + errorLines.push(args.join(" ")); + }, + }, + }, + }); + }); + + afterEach(() => { + mock.reset(); + }); + + it("reports command errors while preserving exit status", async () => { + const { reportCommandExecutionFailure } = await import("./commandErrors.js"); + + const result = reportCommandExecutionFailure( + { + status: 127, + message: "Command failed: command -v bun", + }, + "bun", + ); + + assert.deepStrictEqual(result, { status: 127 }); + assert.deepStrictEqual(errorLines, [ + "Error executing command: Command failed: command -v bun", + "Is 'bun' installed and available on your system?", + ]); + }); + + it("falls back to exit code 1 when status is missing", async () => { + const { reportCommandExecutionFailure } = await import("./commandErrors.js"); + + const result = reportCommandExecutionFailure( + { + message: "Network error", + }, + "npm", + ); + + assert.deepStrictEqual(result, { status: 1 }); + assert.deepStrictEqual(errorLines, [ + "Error executing command: Network error", + "Is 'npm' installed and available on your system?", + ]); + }); +}); diff --git a/packages/safe-chain/src/packagemanager/bun/createBunPackageManager.js b/packages/safe-chain/src/packagemanager/bun/createBunPackageManager.js index 037a512..1138203 100644 --- a/packages/safe-chain/src/packagemanager/bun/createBunPackageManager.js +++ b/packages/safe-chain/src/packagemanager/bun/createBunPackageManager.js @@ -1,6 +1,7 @@ import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; +import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; /** * @returns {import("../currentPackageManager.js").PackageManager} @@ -43,11 +44,6 @@ async function runBunCommand(command, args) { }); return { status: result.status }; } catch (/** @type any */ error) { - if (error.status) { - return { status: error.status }; - } else { - ui.writeError("Error executing command:", error.message); - return { status: 1 }; - } + return reportCommandExecutionFailure(error, command); } } diff --git a/packages/safe-chain/src/packagemanager/npm/runNpmCommand.js b/packages/safe-chain/src/packagemanager/npm/runNpmCommand.js index af57fad..4a1f0b1 100644 --- a/packages/safe-chain/src/packagemanager/npm/runNpmCommand.js +++ b/packages/safe-chain/src/packagemanager/npm/runNpmCommand.js @@ -1,6 +1,7 @@ import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; +import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; /** * @param {string[]} args @@ -15,11 +16,6 @@ export async function runNpm(args) { }); return { status: result.status }; } catch (/** @type any */ error) { - if (error.status) { - return { status: error.status }; - } else { - ui.writeError("Error executing command:", error.message); - return { status: 1 }; - } + return reportCommandExecutionFailure(error, "npm"); } } diff --git a/packages/safe-chain/src/packagemanager/npx/runNpxCommand.js b/packages/safe-chain/src/packagemanager/npx/runNpxCommand.js index 2501b79..6aebc3e 100644 --- a/packages/safe-chain/src/packagemanager/npx/runNpxCommand.js +++ b/packages/safe-chain/src/packagemanager/npx/runNpxCommand.js @@ -1,6 +1,7 @@ import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; +import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; /** * @param {string[]} args @@ -15,11 +16,6 @@ export async function runNpx(args) { }); return { status: result.status }; } catch (/** @type any */ error) { - if (error.status) { - return { status: error.status }; - } else { - ui.writeError("Error executing command:", error.message); - return { status: 1 }; - } + return reportCommandExecutionFailure(error, "npx"); } } diff --git a/packages/safe-chain/src/packagemanager/pip/runPipCommand.js b/packages/safe-chain/src/packagemanager/pip/runPipCommand.js index 83bc03e..4f4e401 100644 --- a/packages/safe-chain/src/packagemanager/pip/runPipCommand.js +++ b/packages/safe-chain/src/packagemanager/pip/runPipCommand.js @@ -9,6 +9,7 @@ import os from "node:os"; import path from "node:path"; import ini from "ini"; import { spawn } from "child_process"; +import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; /** * Checks if this pip invocation should bypass safe-chain and spawn directly. @@ -203,12 +204,6 @@ export async function runPip(command, args) { return { status: result.status }; } catch (/** @type any */ error) { - if (error.status) { - return { status: error.status }; - } else { - ui.writeError(`Error executing command: ${error.message}`); - ui.writeError(`Is '${command}' installed and available on your system?`); - return { status: 1 }; - } + return reportCommandExecutionFailure(error, command); } } diff --git a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js index 2f70cfa..c374e2a 100644 --- a/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js +++ b/packages/safe-chain/src/packagemanager/pipx/runPipXCommand.js @@ -2,6 +2,7 @@ import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js"; +import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; /** * Sets CA bundle environment variables used by Python libraries and pipx. @@ -54,12 +55,6 @@ export async function runPipX(command, args) { return { status: result.status }; } catch (/** @type any */ error) { - if (error.status) { - return { status: error.status }; - } else { - ui.writeError(`Error executing command: ${error.message}`); - ui.writeError(`Is '${command}' installed and available on your system?`); - return { status: 1 }; - } + return reportCommandExecutionFailure(error, command); } } diff --git a/packages/safe-chain/src/packagemanager/pnpm/runPnpmCommand.js b/packages/safe-chain/src/packagemanager/pnpm/runPnpmCommand.js index d958fb8..cad4afe 100644 --- a/packages/safe-chain/src/packagemanager/pnpm/runPnpmCommand.js +++ b/packages/safe-chain/src/packagemanager/pnpm/runPnpmCommand.js @@ -1,6 +1,7 @@ import { ui } from "../../environment/userInteraction.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; +import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; /** * @param {string[]} args @@ -26,11 +27,7 @@ export async function runPnpmCommand(args, toolName = "pnpm") { return { status: result.status }; } catch (/** @type any */ error) { - if (error.status) { - return { status: error.status }; - } else { - ui.writeError("Error executing command:", error.message); - return { status: 1 }; - } + const target = toolName === "pnpm" ? "pnpm" : "pnpx"; + return reportCommandExecutionFailure(error, target); } } diff --git a/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.js b/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.js index c8094e5..567fb43 100644 --- a/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.js +++ b/packages/safe-chain/src/packagemanager/poetry/createPoetryPackageManager.js @@ -2,6 +2,7 @@ import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js"; +import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; /** * @returns {import("../currentPackageManager.js").PackageManager} @@ -66,12 +67,6 @@ async function runPoetryCommand(args) { return { status: result.status }; } catch (/** @type any */ error) { - if (error.status) { - return { status: error.status }; - } else { - ui.writeError("Error executing command:", error.message); - ui.writeError("Is 'poetry' installed and available on your system?"); - return { status: 1 }; - } + return reportCommandExecutionFailure(error, "poetry"); } } diff --git a/packages/safe-chain/src/packagemanager/uv/runUvCommand.js b/packages/safe-chain/src/packagemanager/uv/runUvCommand.js index ed02fe3..7c22518 100644 --- a/packages/safe-chain/src/packagemanager/uv/runUvCommand.js +++ b/packages/safe-chain/src/packagemanager/uv/runUvCommand.js @@ -2,6 +2,7 @@ import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js"; +import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; /** * Sets CA bundle environment variables used by Python libraries and uv. @@ -60,12 +61,6 @@ export async function runUv(command, args) { return { status: result.status }; } catch (/** @type any */ error) { - if (error.status) { - return { status: error.status }; - } else { - ui.writeError(`Error executing command: ${error.message}`); - ui.writeError(`Is '${command}' installed and available on your system?`); - return { status: 1 }; - } + return reportCommandExecutionFailure(error, command); } } diff --git a/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.js b/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.js index 2089551..cdf216f 100644 --- a/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.js +++ b/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.js @@ -1,6 +1,7 @@ import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; +import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; /** * @param {string[]} args @@ -18,12 +19,7 @@ export async function runYarnCommand(args) { }); return { status: result.status }; } catch (/** @type any */ error) { - if (error.status) { - return { status: error.status }; - } else { - ui.writeError("Error executing command:", error.message); - return { status: 1 }; - } + return reportCommandExecutionFailure(error, "yarn"); } } From ce05e82885109202df5d96c374d1e8717cef6d49 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 27 Feb 2026 01:36:42 +0800 Subject: [PATCH 176/360] fix(cli): remove unused ui imports after error-helper refactor --- .../safe-chain/src/packagemanager/bun/createBunPackageManager.js | 1 - packages/safe-chain/src/packagemanager/npm/runNpmCommand.js | 1 - packages/safe-chain/src/packagemanager/npx/runNpxCommand.js | 1 - packages/safe-chain/src/packagemanager/pnpm/runPnpmCommand.js | 1 - packages/safe-chain/src/packagemanager/yarn/runYarnCommand.js | 1 - 5 files changed, 5 deletions(-) diff --git a/packages/safe-chain/src/packagemanager/bun/createBunPackageManager.js b/packages/safe-chain/src/packagemanager/bun/createBunPackageManager.js index 1138203..a9279b9 100644 --- a/packages/safe-chain/src/packagemanager/bun/createBunPackageManager.js +++ b/packages/safe-chain/src/packagemanager/bun/createBunPackageManager.js @@ -1,4 +1,3 @@ -import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; diff --git a/packages/safe-chain/src/packagemanager/npm/runNpmCommand.js b/packages/safe-chain/src/packagemanager/npm/runNpmCommand.js index 4a1f0b1..2622afc 100644 --- a/packages/safe-chain/src/packagemanager/npm/runNpmCommand.js +++ b/packages/safe-chain/src/packagemanager/npm/runNpmCommand.js @@ -1,4 +1,3 @@ -import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; diff --git a/packages/safe-chain/src/packagemanager/npx/runNpxCommand.js b/packages/safe-chain/src/packagemanager/npx/runNpxCommand.js index 6aebc3e..7edbfd3 100644 --- a/packages/safe-chain/src/packagemanager/npx/runNpxCommand.js +++ b/packages/safe-chain/src/packagemanager/npx/runNpxCommand.js @@ -1,4 +1,3 @@ -import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; diff --git a/packages/safe-chain/src/packagemanager/pnpm/runPnpmCommand.js b/packages/safe-chain/src/packagemanager/pnpm/runPnpmCommand.js index cad4afe..3b90422 100644 --- a/packages/safe-chain/src/packagemanager/pnpm/runPnpmCommand.js +++ b/packages/safe-chain/src/packagemanager/pnpm/runPnpmCommand.js @@ -1,4 +1,3 @@ -import { ui } from "../../environment/userInteraction.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; diff --git a/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.js b/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.js index cdf216f..fdf601a 100644 --- a/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.js +++ b/packages/safe-chain/src/packagemanager/yarn/runYarnCommand.js @@ -1,4 +1,3 @@ -import { ui } from "../../environment/userInteraction.js"; import { safeSpawn } from "../../utils/safeSpawn.js"; import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; From c87a8ad7d9807c8c4952e9d51a9a69cb73e76bf3 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 2 Mar 2026 11:49:39 +0100 Subject: [PATCH 177/360] Use latest version --- .github/workflows/create-artifact.yml | 2 +- .github/workflows/test-on-pr.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/create-artifact.yml b/.github/workflows/create-artifact.yml index 9a1702d..90b9745 100644 --- a/.github/workflows/create-artifact.yml +++ b/.github/workflows/create-artifact.yml @@ -70,7 +70,7 @@ jobs: node-version: "20.x" - name: Setup safe-chain - run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/0.0.1-windows-install-script-in-git-bash-beta/install-safe-chain.sh | sh -s -- --ci + run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci shell: bash - name: Install dependencies diff --git a/.github/workflows/test-on-pr.yml b/.github/workflows/test-on-pr.yml index 5d5564e..e6ef9df 100644 --- a/.github/workflows/test-on-pr.yml +++ b/.github/workflows/test-on-pr.yml @@ -23,7 +23,7 @@ jobs: node-version: "lts/*" - name: Setup safe-chain - run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/0.0.1-windows-install-script-in-git-bash-beta/install-safe-chain.sh | sh -s -- --ci + run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci shell: bash - name: Install dependencies From 9de74886b6d839c492de43e85c1f5c29e558b229 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 13 Mar 2026 11:53:31 +0100 Subject: [PATCH 178/360] Implement Aikido Endpoint installation script --- install-scripts/install-endpoint-mac.sh | 130 ++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 install-scripts/install-endpoint-mac.sh diff --git a/install-scripts/install-endpoint-mac.sh b/install-scripts/install-endpoint-mac.sh new file mode 100644 index 0000000..e44d854 --- /dev/null +++ b/install-scripts/install-endpoint-mac.sh @@ -0,0 +1,130 @@ +#!/bin/sh + +# Downloads and installs SafeChain Ultimate endpoint on macOS +# +# Usage: curl -fsSL | sudo sh -s -- --token + +set -e # Exit on error + +# Configuration +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.4/SafeChainUltimate.pkg" +DOWNLOAD_SHA256="9c341c479e022cc98ddaeb704681a08c8eaacdcaa59e4256ecf90362af6a5514" +TOKEN_FILE="/tmp/aikido_endpoint_token.txt" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' # No Color + +# Helper functions +info() { + printf "${GREEN}[INFO]${NC} %s\n" "$1" +} + +error() { + printf "${RED}[ERROR]${NC} %s\n" "$1" >&2 + exit 1 +} + +# Download file +download() { + url="$1" + dest="$2" + + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$dest" || error "Failed to download from $url" + elif command -v wget >/dev/null 2>&1; then + wget -q "$url" -O "$dest" || error "Failed to download from $url" + else + error "Neither curl nor wget found. Please install one of them." + fi +} + +# Verify SHA256 checksum +verify_checksum() { + file="$1" + expected="$2" + + actual=$(shasum -a 256 "$file" | awk '{ print $1 }') + + if [ "$actual" != "$expected" ]; then + error "Checksum verification failed. Expected: $expected, Got: $actual" + fi + + info "Checksum verified successfully." +} + +# Cleanup temporary files +cleanup() { + if [ -f "$PKG_FILE" ]; then + rm -f "$PKG_FILE" + fi + if [ -f "$TOKEN_FILE" ]; then + rm -f "$TOKEN_FILE" + fi +} + +# Parse command-line arguments +parse_arguments() { + TOKEN="" + + while [ $# -gt 0 ]; do + case "$1" in + --token) + if [ -z "${2:-}" ]; then + error "--token requires a value" + fi + TOKEN="$2" + shift 2 + ;; + *) + error "Unknown argument: $1" + ;; + esac + done +} + +# Main installation +main() { + parse_arguments "$@" + + # 1. Check if we're running on macOS + if [ "$(uname -s)" != "Darwin" ]; then + error "This script is only supported on macOS." + fi + + # Check if we're running as root + if [ "$(id -u)" -ne 0 ]; then + error "Root privileges required. Please run with sudo: sudo sh $0 --token " + fi + + # Prompt for token if not provided via CLI + if [ -z "$TOKEN" ]; then + printf "Enter your Aikido endpoint token: " + read -r TOKEN + if [ -z "$TOKEN" ]; then + error "Token is required. Pass it with --token or enter it when prompted." + fi + fi + + # 2. Download and verify checksum + PKG_FILE=$(mktemp /tmp/SafeChainUltimate.XXXXXX.pkg) + trap cleanup EXIT + + info "Downloading SafeChain Ultimate..." + download "$INSTALL_URL" "$PKG_FILE" + + info "Verifying checksum..." + verify_checksum "$PKG_FILE" "$DOWNLOAD_SHA256" + + # 3. Write token to file for the installer + printf "%s" "$TOKEN" > "$TOKEN_FILE" + + # 4. Install the package + info "Installing SafeChain Ultimate..." + installer -pkg "$PKG_FILE" -target / + + info "SafeChain Ultimate installed successfully!" +} + +main "$@" From b3d81d2f43a56dccc47626e88d180d8af5dfade7 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 13 Mar 2026 11:58:44 +0100 Subject: [PATCH 179/360] Don't prompt for token --- install-scripts/install-endpoint-mac.sh | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/install-scripts/install-endpoint-mac.sh b/install-scripts/install-endpoint-mac.sh index e44d854..f13474d 100644 --- a/install-scripts/install-endpoint-mac.sh +++ b/install-scripts/install-endpoint-mac.sh @@ -95,16 +95,12 @@ main() { # Check if we're running as root if [ "$(id -u)" -ne 0 ]; then - error "Root privileges required. Please run with sudo: sudo sh $0 --token " + error "Root privileges required. Please re-run with sudo, e.g.: curl -fsSL | sudo sh -s -- --token " fi - # Prompt for token if not provided via CLI + # Check if token is provided via command argument if [ -z "$TOKEN" ]; then - printf "Enter your Aikido endpoint token: " - read -r TOKEN - if [ -z "$TOKEN" ]; then - error "Token is required. Pass it with --token or enter it when prompted." - fi + error "Token is required. Pass it with --token or enter it when prompted." fi # 2. Download and verify checksum From 5dfccaac9d0cbc249b039cdd62a5b48ad533f5b5 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 13 Mar 2026 12:27:21 +0100 Subject: [PATCH 180/360] Update install url to arm64 pkg --- 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 f13474d..ebcf4aa 100644 --- 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.4/SafeChainUltimate.pkg" -DOWNLOAD_SHA256="9c341c479e022cc98ddaeb704681a08c8eaacdcaa59e4256ecf90362af6a5514" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.2/SafeChainUltimate-darwin-arm64.pkg" +DOWNLOAD_SHA256="779edc4d2fa367582bf9af6be30a0533fcd2a3490d921f834129719eb4f02f42" TOKEN_FILE="/tmp/aikido_endpoint_token.txt" # Colors for output From 7c5692f700c4c0cc18514859b43c62a0d64a9ef1 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 13 Mar 2026 13:30:13 +0100 Subject: [PATCH 181/360] Update endpoint to 1.2.5 --- 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 ebcf4aa..8a0424d 100644 --- 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.2/SafeChainUltimate-darwin-arm64.pkg" -DOWNLOAD_SHA256="779edc4d2fa367582bf9af6be30a0533fcd2a3490d921f834129719eb4f02f42" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.5/SafeChainUltimate.pkg" +DOWNLOAD_SHA256="abc2b0e6c6a4ca33cd893eeb16744f9f2da90013fb1abac301f5c00c2ad8bc30" TOKEN_FILE="/tmp/aikido_endpoint_token.txt" # Colors for output From 4bf27ac2db6d8203e7e115061b7bd8494c338291 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 13 Mar 2026 13:36:56 +0100 Subject: [PATCH 182/360] Add windows install script --- install-scripts/install-endpoint-windows.ps1 | 95 ++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 install-scripts/install-endpoint-windows.ps1 diff --git a/install-scripts/install-endpoint-windows.ps1 b/install-scripts/install-endpoint-windows.ps1 new file mode 100644 index 0000000..5c6eb3e --- /dev/null +++ b/install-scripts/install-endpoint-windows.ps1 @@ -0,0 +1,95 @@ +# Downloads and installs SafeChain Ultimate endpoint on Windows +# +# Usage: iex "& { $(iwr '' -UseBasicParsing) } -token " + +param( + [string]$token +) + +# Configuration +$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.5/SafeChainUltimate.msi" +$DownloadSha256 = "c4d1be7bb2128473b8e955244dc186b5d3f091f668b43cdd3d810cff9d38193c" + +# Ensure TLS 1.2 is enabled for downloads +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +# Helper functions +function Write-Info { + param([string]$Message) + Write-Host "[INFO] $Message" -ForegroundColor Green +} + +function Write-Error-Custom { + param([string]$Message) + Write-Host "[ERROR] $Message" -ForegroundColor Red + exit 1 +} + +# Check if running as Administrator +function Test-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +# Main installation +function Install-Endpoint { + # 1. Check if we're running as Administrator + if (-not (Test-Administrator)) { + Write-Error-Custom "Administrator privileges required. Please run this script in an elevated terminal (Run as Administrator)." + } + + # Check if token is provided, prompt if not + if ([string]::IsNullOrWhiteSpace($token)) { + $token = Read-Host "Enter your Aikido endpoint token" + if ([string]::IsNullOrWhiteSpace($token)) { + Write-Error-Custom "Token is required. Pass it with -token or enter it when prompted." + } + } + + # 2. Download the .msi + $msiFile = Join-Path $env:TEMP "SafeChainUltimate-$([System.Guid]::NewGuid().ToString('N')).msi" + + Write-Info "Downloading SafeChain Ultimate..." + try { + $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri $InstallUrl -OutFile $msiFile -UseBasicParsing + $ProgressPreference = 'Continue' + } + catch { + Write-Error-Custom "Failed to download from $InstallUrl : $_" + } + + try { + # Verify SHA256 checksum + Write-Info "Verifying checksum..." + $actualHash = (Get-FileHash -Path $msiFile -Algorithm SHA256).Hash.ToLower() + if ($actualHash -ne $DownloadSha256) { + Write-Error-Custom "Checksum verification failed. Expected: $DownloadSha256, Got: $actualHash" + } + Write-Info "Checksum verified successfully." + + # 3. Install the package with token passed as MSI property + Write-Info "Installing SafeChain Ultimate..." + $process = Start-Process -FilePath "msiexec" -ArgumentList "/i", "`"$msiFile`"", "/qn", "/norestart", "AIKIDO_TOKEN=$token" -Wait -PassThru + if ($process.ExitCode -ne 0) { + Write-Error-Custom "MSI installer failed (exit code: $($process.ExitCode))." + } + + Write-Info "SafeChain Ultimate installed successfully!" + } + finally { + # Cleanup + if (Test-Path $msiFile) { + Remove-Item -Path $msiFile -Force -ErrorAction SilentlyContinue + } + } +} + +# Run installation +try { + Install-Endpoint +} +catch { + Write-Error-Custom "Installation failed: $_" +} From af90b20f1271a3e73ed31458c25fc40fd5e6b66a Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 13 Mar 2026 14:09:50 +0100 Subject: [PATCH 183/360] Add uninstall scripts --- install-scripts/uninstall-endpoint-mac.sh | 50 ++++++++++++++++ .../uninstall-endpoint-windows.ps1 | 59 +++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 install-scripts/uninstall-endpoint-mac.sh create mode 100644 install-scripts/uninstall-endpoint-windows.ps1 diff --git a/install-scripts/uninstall-endpoint-mac.sh b/install-scripts/uninstall-endpoint-mac.sh new file mode 100644 index 0000000..b1ba6e4 --- /dev/null +++ b/install-scripts/uninstall-endpoint-mac.sh @@ -0,0 +1,50 @@ +#!/bin/sh + +# Uninstalls SafeChain Ultimate endpoint on macOS +# +# Usage: curl -fsSL | sudo sh + +set -e # Exit on error + +# Configuration +UNINSTALL_SCRIPT="/Library/Application Support/AikidoSecurity/SafeChainUltimate/scripts/uninstall" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' # No Color + +# Helper functions +info() { + printf "${GREEN}[INFO]${NC} %s\n" "$1" +} + +error() { + printf "${RED}[ERROR]${NC} %s\n" "$1" >&2 + exit 1 +} + +# Main uninstallation +main() { + # Check if we're running on macOS + if [ "$(uname -s)" != "Darwin" ]; then + error "This script is only supported on macOS." + fi + + # Check if we're running as root + if [ "$(id -u)" -ne 0 ]; then + error "Root privileges required. Please re-run with sudo, e.g.: curl -fsSL | sudo sh" + fi + + # Check if the uninstall script exists + if [ ! -f "$UNINSTALL_SCRIPT" ]; then + error "SafeChain Ultimate does not appear to be installed (uninstall script not found)." + fi + + info "Uninstalling SafeChain Ultimate..." + "$UNINSTALL_SCRIPT" + + info "SafeChain Ultimate uninstalled successfully!" +} + +main "$@" diff --git a/install-scripts/uninstall-endpoint-windows.ps1 b/install-scripts/uninstall-endpoint-windows.ps1 new file mode 100644 index 0000000..5de5bfe --- /dev/null +++ b/install-scripts/uninstall-endpoint-windows.ps1 @@ -0,0 +1,59 @@ +# Uninstalls SafeChain Ultimate endpoint on Windows +# +# Usage: iex (iwr '' -UseBasicParsing) + +# Configuration +$AppName = "SafeChain Ultimate" + +# Helper functions +function Write-Info { + param([string]$Message) + Write-Host "[INFO] $Message" -ForegroundColor Green +} + +function Write-Error-Custom { + param([string]$Message) + Write-Host "[ERROR] $Message" -ForegroundColor Red + exit 1 +} + +# Check if running as Administrator +function Test-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +# Main uninstallation +function Uninstall-Endpoint { + # Check if we're running as Administrator + if (-not (Test-Administrator)) { + Write-Error-Custom "Administrator privileges required. Please run this script in an elevated terminal (Run as Administrator)." + } + + # Find the installed product + Write-Info "Looking for SafeChain Ultimate installation..." + $app = Get-WmiObject -Class Win32_Product -Filter "Name='$AppName'" + + if (-not $app) { + Write-Error-Custom "SafeChain Ultimate does not appear to be installed." + } + + $productCode = $app.IdentifyingNumber + + Write-Info "Uninstalling SafeChain Ultimate..." + $process = Start-Process -FilePath "msiexec" -ArgumentList "/x", $productCode, "/qn", "/norestart" -Wait -PassThru + if ($process.ExitCode -ne 0) { + Write-Error-Custom "Uninstall failed (exit code: $($process.ExitCode))." + } + + Write-Info "SafeChain Ultimate uninstalled successfully!" +} + +# Run uninstallation +try { + Uninstall-Endpoint +} +catch { + Write-Error-Custom "Uninstallation failed: $_" +} From 8eabdd17ba9bb6890190971b1f91a9a90a9b9fdb Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 13 Mar 2026 14:19:25 +0100 Subject: [PATCH 184/360] Verify token format --- install-scripts/install-endpoint-mac.sh | 7 +++++++ install-scripts/install-endpoint-windows.ps1 | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/install-scripts/install-endpoint-mac.sh b/install-scripts/install-endpoint-mac.sh index 8a0424d..684a8a8 100644 --- a/install-scripts/install-endpoint-mac.sh +++ b/install-scripts/install-endpoint-mac.sh @@ -103,6 +103,13 @@ main() { error "Token is required. Pass it with --token or enter it when prompted." fi + # Validate token to prevent injection + case "$TOKEN" in + *[\"\'\;\`\$\ ]*) + error "Invalid token format. Token must not contain quotes, semicolons, backticks, dollar signs, or whitespace." + ;; + esac + # 2. Download and verify checksum PKG_FILE=$(mktemp /tmp/SafeChainUltimate.XXXXXX.pkg) trap cleanup EXIT diff --git a/install-scripts/install-endpoint-windows.ps1 b/install-scripts/install-endpoint-windows.ps1 index 5c6eb3e..f99d1ff 100644 --- a/install-scripts/install-endpoint-windows.ps1 +++ b/install-scripts/install-endpoint-windows.ps1 @@ -47,6 +47,11 @@ function Install-Endpoint { } } + # Validate token to prevent command/property injection via msiexec + if ($token -match '[";`$\s]') { + Write-Error-Custom "Invalid token format. Token must not contain quotes, semicolons, backticks, dollar signs, or whitespace." + } + # 2. Download the .msi $msiFile = Join-Path $env:TEMP "SafeChainUltimate-$([System.Guid]::NewGuid().ToString('N')).msi" From b3e5726a836a12044ed96e0724e4ad845a9d5f8b Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 13 Mar 2026 14:30:29 +0100 Subject: [PATCH 185/360] Add new scripts to release --- .github/workflows/build-and-release.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index a752eb8..bab932c 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -77,6 +77,10 @@ jobs: sed "s/\$Version = Get-LatestVersion/\$Version = \"${VERSION}\"/" 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 + cp install-scripts/install-endpoint-windows.ps1 release-artifacts/install-endpoint-windows.ps1 + cp install-scripts/uninstall-endpoint-mac.sh release-artifacts/uninstall-endpoint-mac.sh + cp install-scripts/uninstall-endpoint-windows.ps1 release-artifacts/uninstall-endpoint-windows.ps1 - name: Upload binaries to existing GitHub Release env: @@ -94,7 +98,11 @@ jobs: release-artifacts/install-safe-chain.sh \ release-artifacts/install-safe-chain.ps1 \ release-artifacts/uninstall-safe-chain.sh \ - release-artifacts/uninstall-safe-chain.ps1 + release-artifacts/uninstall-safe-chain.ps1 \ + release-artifacts/install-endpoint-mac.sh \ + release-artifacts/install-endpoint-windows.ps1 \ + release-artifacts/uninstall-endpoint-mac.sh \ + release-artifacts/uninstall-endpoint-windows.ps1 publish-npm: name: Publish to npm From 9494b5aae8d822add1d38a97f8fdc6c132e1c8ee Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 19 Mar 2026 09:13:45 +0100 Subject: [PATCH 186/360] Remove the .aikido directory when uninstalling --- install-scripts/uninstall-safe-chain.ps1 | 40 +++++++++++++----------- install-scripts/uninstall-safe-chain.sh | 21 +++++++++---- 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/install-scripts/uninstall-safe-chain.ps1 b/install-scripts/uninstall-safe-chain.ps1 index f1e1ff7..5fdae1c 100644 --- a/install-scripts/uninstall-safe-chain.ps1 +++ b/install-scripts/uninstall-safe-chain.ps1 @@ -4,7 +4,9 @@ # Use HOME on Unix, USERPROFILE on Windows (PowerShell Core is cross-platform) $HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE } -$InstallDir = Join-Path $HomeDir ".safe-chain/bin" +$DotSafeChain = Join-Path $HomeDir ".safe-chain" +$DotAikido = Join-Path $HomeDir ".aikido" +$InstallDir = Join-Path $DotSafeChain "bin" # Helper functions function Write-Info { @@ -123,34 +125,34 @@ function Uninstall-SafeChain { Remove-NpmInstallation Remove-VoltaInstallation - # Remove installation directory - if (Test-Path $InstallDir) { - Write-Info "Removing installation directory: $InstallDir" + # Remove .safe-chain directory + if (Test-Path $DotSafeChain) { + Write-Info "Removing installation directory: $DotSafeChain" try { - Remove-Item -Path $InstallDir -Recurse -Force + Remove-Item -Path $DotSafeChain -Recurse -Force Write-Info "Successfully removed installation directory" } catch { - Write-Error-Custom "Failed to remove $InstallDir : $_" + Write-Error-Custom "Failed to remove $DotSafeChain : $_" } } else { - Write-Info "Installation directory $InstallDir does not exist. Nothing to remove." + Write-Info "Installation directory $DotSafeChain does not exist. Nothing to remove." } - # Also try to remove the parent .safe-chain directory if it's empty - $parentDir = Split-Path $InstallDir -Parent - if (Test-Path $parentDir) { - $items = Get-ChildItem -Path $parentDir -Force - if ($items.Count -eq 0) { - Write-Info "Removing empty parent directory: $parentDir" - try { - Remove-Item -Path $parentDir -Force - } - catch { - Write-Warn "Could not remove empty parent directory: $_" - } + # Remove .aikido directory + if (Test-Path $DotAikido) { + Write-Info "Removing installation directory: $DotAikido" + try { + Remove-Item -Path $DotAikido -Recurse -Force + Write-Info "Successfully removed installation directory" } + catch { + Write-Error-Custom "Failed to remove $DotAikido : $_" + } + } + else { + Write-Info "Installation directory $DotAikido does not exist. Nothing to remove." } Write-Info "safe-chain has been uninstalled successfully!" diff --git a/install-scripts/uninstall-safe-chain.sh b/install-scripts/uninstall-safe-chain.sh index e208319..0d04128 100755 --- a/install-scripts/uninstall-safe-chain.sh +++ b/install-scripts/uninstall-safe-chain.sh @@ -7,7 +7,8 @@ set -e # Exit on error # Configuration -INSTALL_DIR="${HOME}/.safe-chain/bin" +DOT_SAFE_CHAIN="${HOME}/.safe-chain" +DOT_AIKIDO="${HOME}/.aikido" # Colors for output RED='\033[0;31m' @@ -139,7 +140,7 @@ remove_nvm_installation() { # Main uninstallation main() { - SAFE_CHAIN_LOCATION="$INSTALL_DIR/safe-chain" + SAFE_CHAIN_LOCATION="$DOT_SAFE_CHAIN/bin/safe-chain" if [ -x "$SAFE_CHAIN_LOCATION" ]; then info "Running safe-chain teardown..." @@ -157,11 +158,19 @@ main() { remove_nvm_installation # Remove install dir recursively if it exists - if [ -d "$INSTALL_DIR" ]; then - info "Removing installation directory $INSTALL_DIR" - rm -rf "$INSTALL_DIR" || error "Failed to remove $INSTALL_DIR" + if [ -d "$DOT_SAFE_CHAIN" ]; then + info "Removing installation directory $DOT_SAFE_CHAIN" + rm -rf "$DOT_SAFE_CHAIN" || error "Failed to remove $DOT_SAFE_CHAIN" else - info "Installation directory $INSTALL_DIR does not exist. Nothing to remove." + info "Installation directory $DOT_SAFE_CHAIN does not exist. Nothing to remove." + fi + + # Remove install dir recursively if it exists + if [ -d "$DOT_AIKIDO" ]; then + info "Removing installation directory $DOT_AIKIDO" + rm -rf "$DOT_AIKIDO" || error "Failed to remove $DOT_AIKIDO" + else + info "Installation directory $DOT_AIKIDO does not exist. Nothing to remove." fi } From 527e3cd70a6b07be5beb277f991f48ce16afb116 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 19 Mar 2026 11:08:38 +0100 Subject: [PATCH 187/360] Cleanup generated cert bundles --- .../src/registryProxy/certBundle.js | 30 ++++++++++++++++--- .../src/registryProxy/registryProxy.js | 8 +++-- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/certBundle.js b/packages/safe-chain/src/registryProxy/certBundle.js index 42549b9..9093f07 100644 --- a/packages/safe-chain/src/registryProxy/certBundle.js +++ b/packages/safe-chain/src/registryProxy/certBundle.js @@ -8,6 +8,9 @@ import { X509Certificate } from "node:crypto"; import { getCaCertPath } from "./certUtils.js"; import { ui } from "../environment/userInteraction.js"; +/** @type {string | null} */ +let bundlePath = null; + /** * Check if a PEM string contains only parsable cert blocks. * @param {string} pem - PEM-encoded certificate string @@ -54,6 +57,11 @@ function isParsable(pem) { * @returns {string} Path to the combined CA bundle PEM file */ export function getCombinedCaBundlePath() { + if (bundlePath) + { + return bundlePath; + } + const parts = []; // 1) Safe Chain CA (for MITM'd registries) @@ -62,7 +70,7 @@ export function getCombinedCaBundlePath() { const safeChainPem = fs.readFileSync(safeChainPath, "utf8"); if (isParsable(safeChainPem)) parts.push(safeChainPem.trim()); } catch { - // Ignore if Safe Chain CA is not available + // Ignore if Safe Chain CA. is not available } // 2) certifi (Mozilla CA bundle for all public HTTPS) @@ -99,9 +107,23 @@ export function getCombinedCaBundlePath() { } const combined = parts.filter(Boolean).join("\n"); - const target = path.join(os.tmpdir(), `safe-chain-ca-bundle-${Date.now()}.pem`); - fs.writeFileSync(target, combined, { encoding: "utf8" }); - return target; + bundlePath = path.join(os.tmpdir(), `safe-chain-ca-bundle-${Date.now()}.pem`); + fs.writeFileSync(bundlePath, combined, { encoding: "utf8" }); + return bundlePath; +} + +/** + * Remove the generated CA bundle file from disk. + */ +export function cleanupCertBundle() { + if (bundlePath) { + try { + fs.unlinkSync(bundlePath); + } catch { + // Ignore errors (file may already be gone) + } + bundlePath = null; + } } /** diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index 47ec256..2de776e 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -2,7 +2,7 @@ import * as http from "http"; import { tunnelRequest } from "./tunnelRequestHandler.js"; import { mitmConnect } from "./mitmRequestHandler.js"; import { handleHttpProxyRequest } from "./plainHttpProxy.js"; -import { getCombinedCaBundlePath } from "./certBundle.js"; +import { getCombinedCaBundlePath, cleanupCertBundle } from "./certBundle.js"; import { ui } from "../environment/userInteraction.js"; import chalk from "chalk"; import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js"; @@ -115,12 +115,16 @@ function stopServer(server) { return new Promise((resolve) => { try { server.close(() => { + cleanupCertBundle(); resolve(); }); } catch { resolve(); } - setTimeout(() => resolve(), SERVER_STOP_TIMEOUT_MS); + setTimeout(() => { + cleanupCertBundle(); + resolve(); + }, SERVER_STOP_TIMEOUT_MS); }); } From 47377711b8c3101423381e0ebefcbf269f5bddff Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 19 Mar 2026 11:11:34 +0100 Subject: [PATCH 188/360] Write log when certbundle could not be deleted --- packages/safe-chain/src/registryProxy/certBundle.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/certBundle.js b/packages/safe-chain/src/registryProxy/certBundle.js index 9093f07..a7d1096 100644 --- a/packages/safe-chain/src/registryProxy/certBundle.js +++ b/packages/safe-chain/src/registryProxy/certBundle.js @@ -119,8 +119,8 @@ export function cleanupCertBundle() { if (bundlePath) { try { fs.unlinkSync(bundlePath); - } catch { - // Ignore errors (file may already be gone) + } catch (err) { + ui.writeVerbose(`Failed to cleanup the create bundle at ${bundlePath}`, err) } bundlePath = null; } From d9e6b899183f85795c614bfb87c74e9ff04b83a0 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 19 Mar 2026 15:42:09 +0100 Subject: [PATCH 189/360] Undo dot in comment --- packages/safe-chain/src/registryProxy/certBundle.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/registryProxy/certBundle.js b/packages/safe-chain/src/registryProxy/certBundle.js index a7d1096..19dc800 100644 --- a/packages/safe-chain/src/registryProxy/certBundle.js +++ b/packages/safe-chain/src/registryProxy/certBundle.js @@ -70,7 +70,7 @@ export function getCombinedCaBundlePath() { const safeChainPem = fs.readFileSync(safeChainPath, "utf8"); if (isParsable(safeChainPem)) parts.push(safeChainPem.trim()); } catch { - // Ignore if Safe Chain CA. is not available + // Ignore if Safe Chain CA is not available } // 2) certifi (Mozilla CA bundle for all public HTTPS) From ffbdedc7cdbf39ef42e362754112b661fdb68422 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 19 Mar 2026 15:51:20 +0100 Subject: [PATCH 190/360] Don't delete .aikido folder --- install-scripts/uninstall-safe-chain.ps1 | 16 ---------------- install-scripts/uninstall-safe-chain.sh | 9 --------- 2 files changed, 25 deletions(-) diff --git a/install-scripts/uninstall-safe-chain.ps1 b/install-scripts/uninstall-safe-chain.ps1 index 5fdae1c..3292cdd 100644 --- a/install-scripts/uninstall-safe-chain.ps1 +++ b/install-scripts/uninstall-safe-chain.ps1 @@ -5,7 +5,6 @@ # Use HOME on Unix, USERPROFILE on Windows (PowerShell Core is cross-platform) $HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE } $DotSafeChain = Join-Path $HomeDir ".safe-chain" -$DotAikido = Join-Path $HomeDir ".aikido" $InstallDir = Join-Path $DotSafeChain "bin" # Helper functions @@ -140,21 +139,6 @@ function Uninstall-SafeChain { Write-Info "Installation directory $DotSafeChain does not exist. Nothing to remove." } - # Remove .aikido directory - if (Test-Path $DotAikido) { - Write-Info "Removing installation directory: $DotAikido" - try { - Remove-Item -Path $DotAikido -Recurse -Force - Write-Info "Successfully removed installation directory" - } - catch { - Write-Error-Custom "Failed to remove $DotAikido : $_" - } - } - else { - Write-Info "Installation directory $DotAikido does not exist. Nothing to remove." - } - Write-Info "safe-chain has been uninstalled successfully!" } diff --git a/install-scripts/uninstall-safe-chain.sh b/install-scripts/uninstall-safe-chain.sh index 0d04128..dff6f31 100755 --- a/install-scripts/uninstall-safe-chain.sh +++ b/install-scripts/uninstall-safe-chain.sh @@ -8,7 +8,6 @@ set -e # Exit on error # Configuration DOT_SAFE_CHAIN="${HOME}/.safe-chain" -DOT_AIKIDO="${HOME}/.aikido" # Colors for output RED='\033[0;31m' @@ -164,14 +163,6 @@ main() { else info "Installation directory $DOT_SAFE_CHAIN does not exist. Nothing to remove." fi - - # Remove install dir recursively if it exists - if [ -d "$DOT_AIKIDO" ]; then - info "Removing installation directory $DOT_AIKIDO" - rm -rf "$DOT_AIKIDO" || error "Failed to remove $DOT_AIKIDO" - else - info "Installation directory $DOT_AIKIDO does not exist. Nothing to remove." - fi } main "$@" From cfaa8e45ad4a0bb502da23f54293a1a825fafdf5 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 19 Mar 2026 16:10:32 +0100 Subject: [PATCH 191/360] Move config file to .safe-chain path. --- README.md | 4 +- packages/safe-chain/src/config/configFile.js | 25 +++- .../safe-chain/src/config/configFile.spec.js | 130 ++++++++++++------ .../supported-shells/bash.js | 2 +- .../shell-integration/supported-shells/zsh.js | 2 +- 5 files changed, 115 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index d5270e5..4daf1d2 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ You can set the minimum package age through multiple sources (in order of priori npm install express ``` -3. **Config File** (`~/.aikido/config.json`): +3. **Config File** (`~/.safe-chain/config.json`): ```json { @@ -246,7 +246,7 @@ You can set custom registries through environment variable or config file. Both export SAFE_CHAIN_PIP_CUSTOM_REGISTRIES="pip.company.com,registry.internal.net" ``` -2. **Config File** (`~/.aikido/config.json`): +2. **Config File** (`~/.safe-chain/config.json`): ```json { diff --git a/packages/safe-chain/src/config/configFile.js b/packages/safe-chain/src/config/configFile.js index fd6ac26..bc4dc94 100644 --- a/packages/safe-chain/src/config/configFile.js +++ b/packages/safe-chain/src/config/configFile.js @@ -252,7 +252,30 @@ function getDatabaseVersionPath() { * @returns {string} */ function getConfigFilePath() { - return path.join(getAikidoDirectory(), "config.json"); + const primaryPath = path.join(getSafeChainDirectory(), "config.json"); + if (fs.existsSync(primaryPath)) { + return primaryPath; + } + + const legacyPath = path.join(getAikidoDirectory(), "config.json"); + if (fs.existsSync(legacyPath)) { + return legacyPath; + } + + return primaryPath; +} + +/** + * @returns {string} + */ +function getSafeChainDirectory() { + const homeDir = os.homedir(); + const safeChainDir = path.join(homeDir, ".safe-chain"); + + if (!fs.existsSync(safeChainDir)) { + fs.mkdirSync(safeChainDir, { recursive: true }); + } + return safeChainDir; } /** diff --git a/packages/safe-chain/src/config/configFile.spec.js b/packages/safe-chain/src/config/configFile.spec.js index eff4048..8b36ff2 100644 --- a/packages/safe-chain/src/config/configFile.spec.js +++ b/packages/safe-chain/src/config/configFile.spec.js @@ -1,16 +1,35 @@ import { describe, it, beforeEach, afterEach, mock } from "node:test"; import assert from "node:assert"; +import os from "os"; +import path from "path"; -let configFileContent = undefined; +const safeChainConfigPath = path.join(os.homedir(), ".safe-chain", "config.json"); +const aikidoConfigPath = path.join(os.homedir(), ".aikido", "config.json"); + +/** @type {Map} */ +let mockFiles = new Map(); mock.module("fs", { namedExports: { - existsSync: () => configFileContent !== undefined, - readFileSync: () => configFileContent, - writeFileSync: (content) => (configFileContent = content), + existsSync: (filePath) => mockFiles.has(filePath), + readFileSync: (filePath) => { + if (!mockFiles.has(filePath)) { + throw new Error(`ENOENT: no such file: ${filePath}`); + } + return mockFiles.get(filePath); + }, + writeFileSync: (filePath, content) => mockFiles.set(filePath, content), mkdirSync: () => {}, }, }); +/** + * Helper to set config content at the primary (~/.safe-chain/) location. + * @param {string} content + */ +function setConfigContent(content) { + mockFiles.set(safeChainConfigPath, content); +} + describe("getScanTimeout", async () => { let originalEnv; @@ -29,12 +48,11 @@ describe("getScanTimeout", async () => { delete process.env.AIKIDO_SCAN_TIMEOUT_MS; } - configFileContent = undefined; + mockFiles.clear(); }); it("should return default timeout of 10000ms when no config or env var is set", () => { delete process.env.AIKIDO_SCAN_TIMEOUT_MS; - configFileContent = undefined; const timeout = getScanTimeout(); @@ -43,7 +61,7 @@ describe("getScanTimeout", async () => { it("should return timeout from config file when set", () => { delete process.env.AIKIDO_SCAN_TIMEOUT_MS; - configFileContent = JSON.stringify({ scanTimeout: 5000 }); + setConfigContent(JSON.stringify({ scanTimeout: 5000 })); const timeout = getScanTimeout(); @@ -52,7 +70,7 @@ describe("getScanTimeout", async () => { it("should prioritize environment variable over config file", () => { process.env.AIKIDO_SCAN_TIMEOUT_MS = "20000"; - configFileContent = JSON.stringify({ scanTimeout: 5000 }); + setConfigContent(JSON.stringify({ scanTimeout: 5000 })); const timeout = getScanTimeout(); @@ -61,7 +79,7 @@ describe("getScanTimeout", async () => { it("should handle invalid environment variable and fall back to config", () => { process.env.AIKIDO_SCAN_TIMEOUT_MS = "invalid"; - configFileContent = JSON.stringify({ scanTimeout: 7000 }); + setConfigContent(JSON.stringify({ scanTimeout: 7000 })); const timeout = getScanTimeout(); @@ -69,8 +87,6 @@ describe("getScanTimeout", async () => { }); it("should ignore zero and negative values and fall back to default", () => { - configFileContent = undefined; - process.env.AIKIDO_SCAN_TIMEOUT_MS = "0"; let timeout = getScanTimeout(); @@ -84,7 +100,7 @@ describe("getScanTimeout", async () => { it("should ignore textual non-numeric values in environment variable and fall back to config", () => { process.env.AIKIDO_SCAN_TIMEOUT_MS = "fast"; - configFileContent = JSON.stringify({ scanTimeout: 8000 }); + setConfigContent(JSON.stringify({ scanTimeout: 8000 })); const timeout = getScanTimeout(); @@ -93,7 +109,7 @@ describe("getScanTimeout", async () => { it("should ignore textual non-numeric values in config file and fall back to default", () => { delete process.env.AIKIDO_SCAN_TIMEOUT_MS; - configFileContent = JSON.stringify({ scanTimeout: "slow" }); + setConfigContent(JSON.stringify({ scanTimeout: "slow" })); const timeout = getScanTimeout(); @@ -102,7 +118,7 @@ describe("getScanTimeout", async () => { it("should ignore textual non-numeric values in both env and config, fall back to default", () => { process.env.AIKIDO_SCAN_TIMEOUT_MS = "quick"; - configFileContent = JSON.stringify({ scanTimeout: "medium" }); + setConfigContent(JSON.stringify({ scanTimeout: "medium" })); const timeout = getScanTimeout(); @@ -111,7 +127,7 @@ describe("getScanTimeout", async () => { it("should ignore mixed alphanumeric strings in environment variable", () => { process.env.AIKIDO_SCAN_TIMEOUT_MS = "5000ms"; - configFileContent = JSON.stringify({ scanTimeout: 6000 }); + setConfigContent(JSON.stringify({ scanTimeout: 6000 })); const timeout = getScanTimeout(); @@ -120,7 +136,7 @@ describe("getScanTimeout", async () => { it("should ignore mixed alphanumeric strings in config file", () => { delete process.env.AIKIDO_SCAN_TIMEOUT_MS; - configFileContent = JSON.stringify({ scanTimeout: "3000ms" }); + setConfigContent(JSON.stringify({ scanTimeout: "3000ms" })); const timeout = getScanTimeout(); @@ -132,19 +148,17 @@ describe("getMinimumPackageAgeHours", async () => { const { getMinimumPackageAgeHours } = await import("./configFile.js"); afterEach(() => { - configFileContent = undefined; + mockFiles.clear(); }); it("should return null when config file doesn't exist", () => { - configFileContent = undefined; - const hours = getMinimumPackageAgeHours(); assert.strictEqual(hours, undefined); }); it("should return null when config file exists but minimumPackageAgeHours is not set", () => { - configFileContent = JSON.stringify({ scanTimeout: 5000 }); + setConfigContent(JSON.stringify({ scanTimeout: 5000 })); const hours = getMinimumPackageAgeHours(); @@ -152,7 +166,7 @@ describe("getMinimumPackageAgeHours", async () => { }); it("should return value from config file when set to valid number", () => { - configFileContent = JSON.stringify({ minimumPackageAgeHours: 48 }); + setConfigContent(JSON.stringify({ minimumPackageAgeHours: 48 })); const hours = getMinimumPackageAgeHours(); @@ -160,7 +174,7 @@ describe("getMinimumPackageAgeHours", async () => { }); it("should handle string numbers in config file", () => { - configFileContent = JSON.stringify({ minimumPackageAgeHours: "72" }); + setConfigContent(JSON.stringify({ minimumPackageAgeHours: "72" })); const hours = getMinimumPackageAgeHours(); @@ -168,7 +182,7 @@ describe("getMinimumPackageAgeHours", async () => { }); it("should handle decimal values", () => { - configFileContent = JSON.stringify({ minimumPackageAgeHours: 1.5 }); + setConfigContent(JSON.stringify({ minimumPackageAgeHours: 1.5 })); const hours = getMinimumPackageAgeHours(); @@ -176,7 +190,7 @@ describe("getMinimumPackageAgeHours", async () => { }); it("should return null for non-numeric strings", () => { - configFileContent = JSON.stringify({ minimumPackageAgeHours: "invalid" }); + setConfigContent(JSON.stringify({ minimumPackageAgeHours: "invalid" })); const hours = getMinimumPackageAgeHours(); @@ -184,7 +198,7 @@ describe("getMinimumPackageAgeHours", async () => { }); it("should return undefined for values with units suffix", () => { - configFileContent = JSON.stringify({ minimumPackageAgeHours: "48h" }); + setConfigContent(JSON.stringify({ minimumPackageAgeHours: "48h" })); const hours = getMinimumPackageAgeHours(); @@ -192,7 +206,7 @@ describe("getMinimumPackageAgeHours", async () => { }); it("should handle malformed JSON and return null", () => { - configFileContent = "{ invalid json"; + setConfigContent("{ invalid json"); const hours = getMinimumPackageAgeHours(); @@ -200,7 +214,7 @@ describe("getMinimumPackageAgeHours", async () => { }); it("should return 0 when minimumPackageAgeHours is set to 0", () => { - configFileContent = JSON.stringify({ minimumPackageAgeHours: 0 }); + setConfigContent(JSON.stringify({ minimumPackageAgeHours: 0 })); const hours = getMinimumPackageAgeHours(); @@ -208,7 +222,7 @@ describe("getMinimumPackageAgeHours", async () => { }); it("should return 0 when minimumPackageAgeHours is set to string '0'", () => { - configFileContent = JSON.stringify({ minimumPackageAgeHours: "0" }); + setConfigContent(JSON.stringify({ minimumPackageAgeHours: "0" })); const hours = getMinimumPackageAgeHours(); @@ -216,7 +230,7 @@ describe("getMinimumPackageAgeHours", async () => { }); it("should handle negative numeric values", () => { - configFileContent = JSON.stringify({ minimumPackageAgeHours: -24 }); + setConfigContent(JSON.stringify({ minimumPackageAgeHours: -24 })); const hours = getMinimumPackageAgeHours(); @@ -224,7 +238,7 @@ describe("getMinimumPackageAgeHours", async () => { }); it("should handle negative string values", () => { - configFileContent = JSON.stringify({ minimumPackageAgeHours: "-48" }); + setConfigContent(JSON.stringify({ minimumPackageAgeHours: "-48" })); const hours = getMinimumPackageAgeHours(); @@ -249,19 +263,17 @@ for (const { packageManager, getCustomRegistries } of [ { describe(getCustomRegistries.name, async () => { afterEach(() => { - configFileContent = undefined; + mockFiles.clear(); }); it("should return empty array when config file doesn't exist", () => { - configFileContent = undefined; - const registries = getCustomRegistries(); assert.deepStrictEqual(registries, []); }); it(`should return empty array when ${packageManager} config is not set`, () => { - configFileContent = JSON.stringify({ scanTimeout: 5000 }); + setConfigContent(JSON.stringify({ scanTimeout: 5000 })); const registries = getCustomRegistries(); @@ -269,9 +281,9 @@ for (const { packageManager, getCustomRegistries } of [ }); it("should return empty array when customRegistries is not an array", () => { - configFileContent = JSON.stringify({ + setConfigContent(JSON.stringify({ [packageManager]: { customRegistries: "not-an-array" }, - }); + })); const registries = getCustomRegistries(); @@ -279,11 +291,11 @@ for (const { packageManager, getCustomRegistries } of [ }); it("should return array of custom registries when set", () => { - configFileContent = JSON.stringify({ + setConfigContent(JSON.stringify({ [packageManager]: { customRegistries: [`${packageManager}.company.com`, "registry.internal.net"], }, - }); + })); const registries = getCustomRegistries(); @@ -294,7 +306,7 @@ for (const { packageManager, getCustomRegistries } of [ }); it("should filter out non-string values", () => { - configFileContent = JSON.stringify({ + setConfigContent(JSON.stringify({ [packageManager]: { customRegistries: [ `${packageManager}.company.com`, @@ -305,7 +317,7 @@ for (const { packageManager, getCustomRegistries } of [ {}, ], }, - }); + })); const registries = getCustomRegistries(); @@ -316,9 +328,9 @@ for (const { packageManager, getCustomRegistries } of [ }); it("should return empty array for empty customRegistries array", () => { - configFileContent = JSON.stringify({ + setConfigContent(JSON.stringify({ [packageManager]: { customRegistries: [] }, - }); + })); const registries = getCustomRegistries(); @@ -326,7 +338,7 @@ for (const { packageManager, getCustomRegistries } of [ }); it("should handle malformed JSON and return empty array", () => { - configFileContent = "{ invalid json"; + setConfigContent("{ invalid json"); const registries = getCustomRegistries(); @@ -334,3 +346,35 @@ for (const { packageManager, getCustomRegistries } of [ }); }); } + +describe("config file location fallback", async () => { + const { getScanTimeout } = await import("./configFile.js"); + + afterEach(() => { + mockFiles.clear(); + delete process.env.AIKIDO_SCAN_TIMEOUT_MS; + }); + + it("should read config from ~/.safe-chain/config.json when it exists", () => { + mockFiles.set(safeChainConfigPath, JSON.stringify({ scanTimeout: 3000 })); + + assert.strictEqual(getScanTimeout(), 3000); + }); + + it("should fall back to ~/.aikido/config.json when primary does not exist", () => { + mockFiles.set(aikidoConfigPath, JSON.stringify({ scanTimeout: 4000 })); + + assert.strictEqual(getScanTimeout(), 4000); + }); + + it("should prefer ~/.safe-chain/config.json when both exist", () => { + mockFiles.set(safeChainConfigPath, JSON.stringify({ scanTimeout: 3000 })); + mockFiles.set(aikidoConfigPath, JSON.stringify({ scanTimeout: 4000 })); + + assert.strictEqual(getScanTimeout(), 3000); + }); + + it("should return default when neither config file exists", () => { + assert.strictEqual(getScanTimeout(), 10000); + }); +}); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.js index a2a3739..07d89cb 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.js @@ -32,7 +32,7 @@ function teardown(tools) { ); } - // Removes the line that sources the safe-chain bash initialization script (~/.aikido/scripts/init-posix.sh) + // Removes the line that sources the safe-chain bash initialization script (~/.safe-chain/scripts/init-posix.sh) removeLinesMatchingPattern( startupFile, /^source\s+~\/\.safe-chain\/scripts\/init-posix\.sh/, diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js index fc2b807..6086095 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js @@ -31,7 +31,7 @@ function teardown(tools) { ); } - // Removes the line that sources the safe-chain zsh initialization script (~/.aikido/scripts/init-posix.sh) + // Removes the line that sources the safe-chain zsh initialization script (~/.safe-chain/scripts/init-posix.sh) removeLinesMatchingPattern( startupFile, /^source\s+~\/\.safe-chain\/scripts\/init-posix\.sh/, From cddcec9ba52df44c2064fb777ce5aa7accc5d7a8 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 19 Mar 2026 14:14:13 -0700 Subject: [PATCH 192/360] Fetch new package list --- packages/safe-chain/src/api/aikido.js | 82 ++++++- packages/safe-chain/src/api/aikido.spec.js | 85 ++++++- packages/safe-chain/src/config/configFile.js | 64 +++++ .../src/scanning/newPackagesDatabase.js | 112 +++++++++ .../src/scanning/newPackagesDatabase.spec.js | 230 ++++++++++++++++++ .../src/ultimate/ultimateTroubleshooting.js | 2 +- 6 files changed, 564 insertions(+), 11 deletions(-) create mode 100644 packages/safe-chain/src/scanning/newPackagesDatabase.js create mode 100644 packages/safe-chain/src/scanning/newPackagesDatabase.spec.js diff --git a/packages/safe-chain/src/api/aikido.js b/packages/safe-chain/src/api/aikido.js index abb2135..fb01f42 100644 --- a/packages/safe-chain/src/api/aikido.js +++ b/packages/safe-chain/src/api/aikido.js @@ -11,6 +11,13 @@ const malwareDatabaseUrls = { [ECOSYSTEM_PY]: "https://malware-list.aikido.dev/malware_pypi.json", }; +// TODO: replace with the real CDN URL once core publishes the S3 endpoint +const newPackagesListUrls = { + [ECOSYSTEM_JS]: "https://new-packages.aikido.dev/js_packages.json", +}; + +const DEFAULT_FETCH_RETRY_ATTEMPTS = 4; + /** * @typedef {Object} MalwarePackage * @property {string} package_name @@ -18,12 +25,19 @@ const malwareDatabaseUrls = { * @property {string} reason */ +/** + * @typedef {Object} NewPackageEntry + * @property {string} source + * @property {string} name + * @property {string} version + * @property {number} released_on - Unix timestamp (seconds) + * @property {number} scraped_on - Unix timestamp (seconds) + */ + /** * @returns {Promise<{malwareDatabase: MalwarePackage[], version: string | undefined}>} */ export async function fetchMalwareDatabase() { - const numberOfAttempts = 4; - return retry(async () => { const ecosystem = getEcoSystem(); const malwareDatabaseUrl = @@ -46,15 +60,13 @@ export async function fetchMalwareDatabase() { } catch (/** @type {any} */ error) { throw new Error(`Error parsing malware database: ${error.message}`); } - }, numberOfAttempts); + }, DEFAULT_FETCH_RETRY_ATTEMPTS); } /** * @returns {Promise} */ export async function fetchMalwareDatabaseVersion() { - const numberOfAttempts = 4; - return retry(async () => { const ecosystem = getEcoSystem(); const malwareDatabaseUrl = @@ -71,7 +83,63 @@ export async function fetchMalwareDatabaseVersion() { ); } return response.headers.get("etag") || undefined; - }, numberOfAttempts); + }, DEFAULT_FETCH_RETRY_ATTEMPTS); +} + +/** + * @returns {Promise<{newPackagesList: NewPackageEntry[], version: string | undefined}>} + */ +export async function fetchNewPackagesList() { + return retry(async () => { + const ecosystem = getEcoSystem(); + const url = + newPackagesListUrls[/** @type {keyof typeof newPackagesListUrls} */ (ecosystem)]; + + if (!url) { + return { newPackagesList: [], version: undefined }; + } + + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Error fetching ${ecosystem} new packages list: ${response.statusText}` + ); + } + + try { + const newPackagesList = await response.json(); + return { + newPackagesList, + version: response.headers.get("etag") || undefined, + }; + } catch (/** @type {any} */ error) { + throw new Error(`Error parsing new packages list: ${error.message}`); + } + }, DEFAULT_FETCH_RETRY_ATTEMPTS); +} + +/** + * @returns {Promise} + */ +export async function fetchNewPackagesListVersion() { + return retry(async () => { + const ecosystem = getEcoSystem(); + const url = + newPackagesListUrls[/** @type {keyof typeof newPackagesListUrls} */ (ecosystem)]; + + if (!url) { + return undefined; + } + + const response = await fetch(url, { method: "HEAD" }); + if (!response.ok) { + throw new Error( + `Error fetching ${ecosystem} new packages list version: ${response.statusText}` + ); + } + + return response.headers.get("etag") || undefined; + }, DEFAULT_FETCH_RETRY_ATTEMPTS); } /** @@ -91,7 +159,7 @@ async function retry(func, attempts) { return await func(); } catch (error) { ui.writeVerbose( - "An error occurred while trying to download the Aikido Malware database", + "An error occurred while trying to download Aikido data", error ); lastError = error; diff --git a/packages/safe-chain/src/api/aikido.spec.js b/packages/safe-chain/src/api/aikido.spec.js index 2e7cecb..b2d25c2 100644 --- a/packages/safe-chain/src/api/aikido.spec.js +++ b/packages/safe-chain/src/api/aikido.spec.js @@ -3,6 +3,7 @@ import assert from "node:assert"; describe("aikido API", async () => { const mockFetch = mock.fn(); + let ecosystem = "js"; mock.module("make-fetch-happen", { defaultExport: mockFetch, @@ -18,17 +19,22 @@ describe("aikido API", async () => { mock.module("../config/settings.js", { namedExports: { - getEcoSystem: () => "js", + getEcoSystem: () => ecosystem, ECOSYSTEM_JS: "js", ECOSYSTEM_PY: "py", }, }); - const { fetchMalwareDatabase, fetchMalwareDatabaseVersion } = - await import("./aikido.js"); + const { + fetchMalwareDatabase, + fetchMalwareDatabaseVersion, + fetchNewPackagesList, + fetchNewPackagesListVersion, + } = await import("./aikido.js"); beforeEach(() => { mockFetch.mock.resetCalls(); + ecosystem = "js"; }); describe("fetchMalwareDatabase", () => { @@ -130,4 +136,77 @@ describe("aikido API", async () => { assert.strictEqual(result, '"final-etag"'); }); }); + + describe("fetchNewPackagesList", () => { + it("should succeed immediately when fetch succeeds on first try", async () => { + const releases = [ + { + source: "NPM", + name: "fresh-pkg", + version: "1.0.0", + released_on: 123, + scraped_on: 456, + }, + ]; + mockFetch.mock.mockImplementationOnce(() => ({ + ok: true, + json: async () => releases, + headers: { get: () => '"etag-new-packages"' }, + })); + + const result = await fetchNewPackagesList(); + + assert.strictEqual(mockFetch.mock.calls.length, 1); + assert.deepStrictEqual(result.newPackagesList, releases); + assert.strictEqual(result.version, '"etag-new-packages"'); + }); + + it("should throw error after exhausting all retries", async () => { + mockFetch.mock.mockImplementation(() => { + throw new Error("Network error"); + }); + + await assert.rejects(() => fetchNewPackagesList(), { + message: "Network error", + }); + + assert.strictEqual(mockFetch.mock.calls.length, 4); + }); + + it("should return an empty list without fetching for unsupported ecosystems", async () => { + ecosystem = "py"; + + const result = await fetchNewPackagesList(); + + assert.strictEqual(mockFetch.mock.calls.length, 0); + assert.deepStrictEqual(result.newPackagesList, []); + assert.strictEqual(result.version, undefined); + }); + }); + + describe("fetchNewPackagesListVersion", () => { + it("should succeed immediately when fetch succeeds on first try", async () => { + mockFetch.mock.mockImplementationOnce(() => ({ + ok: true, + headers: { get: () => '"new-packages-etag"' }, + })); + + const result = await fetchNewPackagesListVersion(); + + assert.strictEqual(mockFetch.mock.calls.length, 1); + assert.strictEqual(result, '"new-packages-etag"'); + }); + + it("should throw error after exhausting all retries", async () => { + mockFetch.mock.mockImplementation(() => { + throw new Error("Connection refused"); + }); + + await assert.rejects(() => fetchNewPackagesListVersion(), { + message: "Connection refused", + }); + + assert.strictEqual(mockFetch.mock.calls.length, 4); + }); + }); }); diff --git a/packages/safe-chain/src/config/configFile.js b/packages/safe-chain/src/config/configFile.js index bc4dc94..0246fa9 100644 --- a/packages/safe-chain/src/config/configFile.js +++ b/packages/safe-chain/src/config/configFile.js @@ -203,6 +203,70 @@ export function readDatabaseFromLocalCache() { } } +/** + * @param {import("../api/aikido.js").NewPackageEntry[]} data + * @param {string | number} version + * + * @returns {void} + */ +export function writeNewPackagesListToLocalCache(data, version) { + try { + const listPath = getNewPackagesListPath(); + const versionPath = getNewPackagesListVersionPath(); + + fs.writeFileSync(listPath, JSON.stringify(data)); + fs.writeFileSync(versionPath, version.toString()); + } catch { + ui.writeWarning( + "Failed to write new packages list to local cache, next time the list will be fetched from the server again." + ); + } +} + +/** + * @returns {{newPackagesList: import("../api/aikido.js").NewPackageEntry[] | null, version: string | null}} + */ +export function readNewPackagesListFromLocalCache() { + try { + const listPath = getNewPackagesListPath(); + if (!fs.existsSync(listPath)) { + return { newPackagesList: null, version: null }; + } + + const data = fs.readFileSync(listPath, "utf8"); + const newPackagesList = JSON.parse(data); + const versionPath = getNewPackagesListVersionPath(); + let version = null; + if (fs.existsSync(versionPath)) { + version = fs.readFileSync(versionPath, "utf8").trim(); + } + return { newPackagesList, version }; + } catch { + ui.writeWarning( + "Failed to read new packages list from local cache. Continuing without local cache." + ); + return { newPackagesList: null, version: null }; + } +} + +/** + * @returns {string} + */ +function getNewPackagesListPath() { + const safeChainDir = getSafeChainDirectory(); + const ecosystem = getEcoSystem(); + return path.join(safeChainDir, `newPackagesList_${ecosystem}.json`); +} + +/** + * @returns {string} + */ +function getNewPackagesListVersionPath() { + const safeChainDir = getSafeChainDirectory(); + const ecosystem = getEcoSystem(); + return path.join(safeChainDir, `newPackagesList_version_${ecosystem}.txt`); +} + /** * @returns {SafeChainConfig} */ diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.js b/packages/safe-chain/src/scanning/newPackagesDatabase.js new file mode 100644 index 0000000..31afb7d --- /dev/null +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.js @@ -0,0 +1,112 @@ +import { + fetchNewPackagesList, + fetchNewPackagesListVersion, +} from "../api/aikido.js"; +import { + readNewPackagesListFromLocalCache, + writeNewPackagesListToLocalCache, +} from "../config/configFile.js"; +import { ui } from "../environment/userInteraction.js"; +import { + getMinimumPackageAgeHours, + getEcoSystem, + ECOSYSTEM_JS, +} from "../config/settings.js"; + +/** + * @typedef {Object} NewPackagesDatabase + * @property {function(string, string): boolean} isNewlyReleasedPackage + */ + +/** @type {NewPackagesDatabase | null} */ +let cachedNewPackagesDatabase = null; + +/** + * Returns the source identifier used in the feed for the current ecosystem. + * @returns {string} + */ +function getCurrentFeedSource() { + return getEcoSystem(); +} + +/** + * @returns {Promise} + */ +export async function openNewPackagesDatabase() { + if (cachedNewPackagesDatabase) { + return cachedNewPackagesDatabase; + } + + if (getEcoSystem() !== ECOSYSTEM_JS) { + cachedNewPackagesDatabase = { isNewlyReleasedPackage: () => false }; + return cachedNewPackagesDatabase; + } + + const newPackagesList = await getNewPackagesList(); + + /** + * @param {string} name + * @param {string} version + * @returns {boolean} + */ + function isNewlyReleasedPackage(name, version) { + const cutOff = new Date( + new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000 + ); + const expectedSource = getCurrentFeedSource(); + + const entry = newPackagesList.find( + (pkg) => + pkg.source?.toLowerCase() === expectedSource && + pkg.name === name && + pkg.version === version + ); + + if (!entry) { + return false; + } + + const releasedOn = new Date(entry.released_on * 1000); + return releasedOn > cutOff; + } + + cachedNewPackagesDatabase = { isNewlyReleasedPackage }; + return cachedNewPackagesDatabase; +} + +/** + * @returns {Promise} + */ +async function getNewPackagesList() { + const { newPackagesList: cachedList, version: cachedVersion } = + readNewPackagesListFromLocalCache(); + + try { + if (cachedList) { + const currentVersion = await fetchNewPackagesListVersion(); + if (cachedVersion === currentVersion) { + return cachedList; + } + } + + const { newPackagesList, version } = await fetchNewPackagesList(); + + if (version) { + writeNewPackagesListToLocalCache(newPackagesList, version); + return newPackagesList; + } else { + ui.writeWarning( + "The new packages list was downloaded, but could not be cached due to a missing version." + ); + return newPackagesList; + } + } catch (/** @type {any} */ error) { + if (cachedList) { + ui.writeWarning( + "Failed to fetch the latest new packages list. Using cached version." + ); + return cachedList; + } + throw error; + } +} diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js new file mode 100644 index 0000000..60a806f --- /dev/null +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js @@ -0,0 +1,230 @@ +import { describe, it, mock, beforeEach } from "node:test"; +import assert from "node:assert"; + +// --- shared mutable state for mocks --- +let cachedList = null; +let cachedVersion = null; +let fetchedList = []; +let fetchedVersion = "etag-1"; +let fetchVersionResult = "etag-1"; +let minimumPackageAgeHours = 24; +let ecosystem = "js"; +let writeWarningCalls = []; +let fetchListError = null; +let fetchVersionError = null; +let importCounter = 0; + +mock.module("../api/aikido.js", { + namedExports: { + fetchNewPackagesList: async () => { + if (fetchListError) { + throw fetchListError; + } + + return { + newPackagesList: fetchedList, + version: fetchedVersion, + }; + }, + fetchNewPackagesListVersion: async () => { + if (fetchVersionError) { + throw fetchVersionError; + } + + return fetchVersionResult; + }, + }, +}); + +mock.module("../config/configFile.js", { + namedExports: { + readNewPackagesListFromLocalCache: () => ({ + newPackagesList: cachedList, + version: cachedVersion, + }), + writeNewPackagesListToLocalCache: () => {}, + }, +}); + +mock.module("../environment/userInteraction.js", { + namedExports: { + ui: { + writeWarning: (msg) => writeWarningCalls.push(msg), + writeVerbose: () => {}, + }, + }, +}); + +mock.module("../config/settings.js", { + namedExports: { + getMinimumPackageAgeHours: () => minimumPackageAgeHours, + getEcoSystem: () => ecosystem, + ECOSYSTEM_JS: "js", + ECOSYSTEM_PY: "py", + }, +}); + +describe("newPackagesDatabase", async () => { + beforeEach(() => { + cachedList = null; + cachedVersion = null; + fetchedList = []; + fetchedVersion = "etag-1"; + fetchVersionResult = "etag-1"; + minimumPackageAgeHours = 24; + ecosystem = "js"; + writeWarningCalls = []; + fetchListError = null; + fetchVersionError = null; + }); + + async function openNewPackagesDatabase() { + const module = await import( + `./newPackagesDatabase.js?test_case=${importCounter++}` + ); + return module.openNewPackagesDatabase(); + } + + function hoursAgo(hours) { + return Math.floor((Date.now() - hours * 3600 * 1000) / 1000); + } + + describe("isNewlyReleasedPackage", () => { + it("returns true for a package released within the age threshold", async () => { + fetchedList = [ + { source: "js", name: "foo", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + ]; + + const db = await openNewPackagesDatabase(); + assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true); + }); + + it("returns false for a package released outside the age threshold", async () => { + fetchedList = [ + { source: "js", name: "foo", version: "1.0.0", released_on: hoursAgo(48), scraped_on: hoursAgo(48) }, + ]; + + const db = await openNewPackagesDatabase(); + assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false); + }); + + it("returns false for a package not in the list", async () => { + fetchedList = []; + + const db = await openNewPackagesDatabase(); + assert.strictEqual(db.isNewlyReleasedPackage("not-there", "1.0.0"), false); + }); + + it("returns false for a known package but different version", async () => { + fetchedList = [ + { source: "js", name: "foo", version: "2.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + ]; + + const db = await openNewPackagesDatabase(); + assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false); + }); + + it("ignores entries from a different source in a mixed feed", async () => { + fetchedList = [ + { + source: "npm", + name: "foo", + version: "1.0.0", + released_on: hoursAgo(1), + scraped_on: hoursAgo(1), + }, + { + source: "js", + name: "bar", + version: "1.0.0", + released_on: hoursAgo(1), + scraped_on: hoursAgo(1), + }, + ]; + + const db = await openNewPackagesDatabase(); + + assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false); + assert.strictEqual(db.isNewlyReleasedPackage("bar", "1.0.0"), true); + }); + + it("respects a custom minimumPackageAgeHours threshold", async () => { + minimumPackageAgeHours = 168; // 7 days + fetchedList = [ + { source: "js", name: "foo", version: "1.0.0", released_on: hoursAgo(100), scraped_on: hoursAgo(100) }, + ]; + + const db = await openNewPackagesDatabase(); + assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true); + }); + + it("returns false for all packages when ecosystem is not JS", async () => { + ecosystem = "py"; + const db = await openNewPackagesDatabase(); + assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false); + }); + }); + + describe("caching behaviour", () => { + it("uses local cache when etag matches", async () => { + cachedList = [ + { source: "js", name: "cached-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + ]; + cachedVersion = "etag-1"; + fetchVersionResult = "etag-1"; + // fetchedList is empty — if we used the remote list, the lookup would return false + fetchedList = []; + + const db = await openNewPackagesDatabase(); + assert.strictEqual(db.isNewlyReleasedPackage("cached-pkg", "1.0.0"), true); + }); + + it("fetches fresh list when etag does not match", async () => { + cachedList = [ + { source: "js", name: "stale-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + ]; + cachedVersion = "etag-old"; + fetchVersionResult = "etag-new"; + fetchedList = [ + { source: "js", name: "fresh-pkg", version: "2.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + ]; + + const db = await openNewPackagesDatabase(); + assert.strictEqual(db.isNewlyReleasedPackage("stale-pkg", "1.0.0"), false); + assert.strictEqual(db.isNewlyReleasedPackage("fresh-pkg", "2.0.0"), true); + }); + + it("falls back to local cache when fetch fails", async () => { + cachedList = [ + { + source: "js", + name: "cached-pkg", + version: "1.0.0", + released_on: hoursAgo(1), + scraped_on: hoursAgo(1), + }, + ]; + cachedVersion = "etag-old"; + fetchVersionResult = "etag-new"; + fetchListError = new Error("Network error"); + + const db = await openNewPackagesDatabase(); + + assert.strictEqual(db.isNewlyReleasedPackage("cached-pkg", "1.0.0"), true); + assert.strictEqual(writeWarningCalls.length, 1); + assert.ok(writeWarningCalls[0].includes("Using cached version")); + }); + + it("emits a warning when list has no version (cannot be cached)", async () => { + fetchedList = [ + { source: "js", name: "foo", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + ]; + fetchedVersion = undefined; + + const db = await openNewPackagesDatabase(); + assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true); + assert.strictEqual(writeWarningCalls.length, 1); + assert.ok(writeWarningCalls[0].includes("could not be cached")); + }); + }); +}); diff --git a/packages/safe-chain/src/ultimate/ultimateTroubleshooting.js b/packages/safe-chain/src/ultimate/ultimateTroubleshooting.js index e333615..114bd5e 100644 --- a/packages/safe-chain/src/ultimate/ultimateTroubleshooting.js +++ b/packages/safe-chain/src/ultimate/ultimateTroubleshooting.js @@ -42,7 +42,7 @@ export async function troubleshootingExport() { resolve(zipFileName); }); - archive.on('error', (err) => { + archive.on('error', (/** @type {Error} */ err) => { ui.writeError(`Failed to zip logs: ${err.message}`); reject(err); }); From 2f4268f1af8c51f95c2d93d7f627a69232ace965 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 19 Mar 2026 15:58:42 -0700 Subject: [PATCH 193/360] Add extra check --- packages/safe-chain/src/main.js | 4 + .../interceptors/interceptorBuilder.js | 41 +++++++++- .../interceptors/npm/modifyNpmInfo.js | 2 +- .../interceptors/npm/npmInterceptor.js | 26 +++++++ .../npm/npmInterceptor.minPackageAge.spec.js | 74 +++++++++++++++++++ .../npmInterceptor.packageDownload.spec.js | 52 ++++++++++++- .../interceptors/npm/parseNpmPackageUrl.js | 10 ++- .../src/registryProxy/registryProxy.js | 62 +++++++++++++++- .../src/scanning/newPackagesDatabase.js | 18 ++++- .../src/scanning/newPackagesDatabase.spec.js | 21 ++++++ 10 files changed, 298 insertions(+), 12 deletions(-) diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index 0b37eba..9d5c031 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -68,6 +68,10 @@ export async function main(args) { return 1; } + if (!proxy.verifyNoMinimumAgeBlockedRequests()) { + return 1; + } + const auditStats = getAuditStats(); if (auditStats.totalPackages > 0) { ui.writeVerbose( diff --git a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js index 7a844e9..fbfc131 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js +++ b/packages/safe-chain/src/registryProxy/interceptors/interceptorBuilder.js @@ -10,6 +10,7 @@ import { EventEmitter } from "events"; * @typedef {Object} RequestInterceptionContext * @property {string} targetUrl * @property {(packageName: string | undefined, version: string | undefined) => void} blockMalware + * @property {(packageName: string, version: string, message: string) => void} blockMinimumAgeRequest * @property {(modificationFunc: (headers: NodeJS.Dict) => NodeJS.Dict) => void} modifyRequestHeaders * @property {(modificationFunc: (body: Buffer, headers: NodeJS.Dict | undefined) => Buffer) => void} modifyBody * @property {() => RequestInterceptionHandler} build @@ -26,6 +27,12 @@ import { EventEmitter } from "events"; * @property {string} version * @property {string} targetUrl * @property {number} timestamp + * + * @typedef {Object} MinimumAgeRequestBlockedEvent + * @property {string} packageName + * @property {string} version + * @property {string} targetUrl + * @property {number} timestamp */ /** @@ -81,10 +88,7 @@ function createRequestContext(targetUrl, eventEmitter) { * @param {string | undefined} version */ function blockMalwareSetup(packageName, version) { - blockResponse = { - statusCode: 403, - message: "Forbidden - blocked by safe-chain", - }; + blockResponse = createBlockResponse("Forbidden - blocked by safe-chain"); // Emit the malwareBlocked event eventEmitter.emit("malwareBlocked", { @@ -95,6 +99,34 @@ function createRequestContext(targetUrl, eventEmitter) { }); } + /** + * @param {string} message + */ + function blockMinimumAgeRequestSetup( + /** @type {string} */ packageName, + /** @type {string} */ version, + /** @type {string} */ message + ) { + blockResponse = createBlockResponse(message); + eventEmitter.emit("minimumAgeRequestBlocked", { + packageName, + version, + targetUrl, + timestamp: Date.now(), + }); + } + + /** + * @param {string} message + * @returns {{statusCode: number, message: string}} + */ + function createBlockResponse(message) { + return { + statusCode: 403, + message, + }; + } + /** @returns {RequestInterceptionHandler} */ function build() { /** @@ -139,6 +171,7 @@ function createRequestContext(targetUrl, eventEmitter) { return { targetUrl, blockMalware: blockMalwareSetup, + blockMinimumAgeRequest: blockMinimumAgeRequestSetup, modifyRequestHeaders: (func) => reqheaderModificationFuncs.push(func), modifyBody: (func) => modifyBodyFuncs.push(func), build, diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js index 14e3ba7..dfab97b 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js @@ -195,7 +195,7 @@ export function getHasSuppressedVersions() { * @param {string} pattern * @returns {boolean} */ -function matchesExclusionPattern(packageName, pattern) { +export function matchesExclusionPattern(packageName, pattern) { if (pattern.endsWith("/*")) { return packageName.startsWith(pattern.slice(0, -1)); } diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js index 3d3b8b4..b912977 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js @@ -1,15 +1,18 @@ import { getNpmCustomRegistries, + getNpmMinimumPackageAgeExclusions, skipMinimumPackageAge, } from "../../../config/settings.js"; import { isMalwarePackage } from "../../../scanning/audit/index.js"; import { interceptRequests } from "../interceptorBuilder.js"; import { isPackageInfoUrl, + matchesExclusionPattern, modifyNpmInfoRequestHeaders, modifyNpmInfoResponse, } from "./modifyNpmInfo.js"; import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js"; +import { openNewPackagesDatabase } from "../../../scanning/newPackagesDatabase.js"; const knownJsRegistries = [ "registry.npmjs.org", @@ -46,11 +49,34 @@ function buildNpmInterceptor(registry) { if (await isMalwarePackage(packageName, version)) { reqContext.blockMalware(packageName, version); + return; } if (!skipMinimumPackageAge() && isPackageInfoUrl(reqContext.targetUrl)) { reqContext.modifyRequestHeaders(modifyNpmInfoRequestHeaders); reqContext.modifyBody(modifyNpmInfoResponse); + return; + } + + // For tarball requests the metadata check above is skipped, so we check the + // new packages list as a fallback (covers e.g. frozen-lockfile installs). + if (!skipMinimumPackageAge() && packageName && version) { + const exclusions = getNpmMinimumPackageAgeExclusions(); + const isExcluded = exclusions.some((pattern) => + matchesExclusionPattern(packageName, pattern) + ); + + if (!isExcluded) { + const newPackagesDatabase = await openNewPackagesDatabase(); + + if (newPackagesDatabase.isNewlyReleasedPackage(packageName, version)) { + reqContext.blockMinimumAgeRequest( + packageName, + version, + `Forbidden - blocked by safe-chain minimum package age (${packageName}@${version})` + ); + } + } } }); } diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js index 834a2ad..2e43119 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js @@ -5,13 +5,25 @@ describe("npmInterceptor minimum package age", async () => { let minimumPackageAgeSettings = 48; let skipMinimumPackageAgeSetting = false; let minimumPackageAgeExclusionsSetting = []; + let newlyReleasedPackages = new Set(); mock.module("../../../config/settings.js", { namedExports: { + ECOSYSTEM_JS: "js", + ECOSYSTEM_PY: "py", getMinimumPackageAgeHours: () => minimumPackageAgeSettings, skipMinimumPackageAge: () => skipMinimumPackageAgeSetting, getNpmCustomRegistries: () => [], getNpmMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting, + getEcoSystem: () => "js", + }, + }); + mock.module("../../../scanning/newPackagesDatabase.js", { + namedExports: { + openNewPackagesDatabase: async () => ({ + isNewlyReleasedPackage: (name, version) => + newlyReleasedPackages.has(`${name}@${version}`), + }), }, }); @@ -359,6 +371,67 @@ describe("npmInterceptor minimum package age", async () => { assert.equal(modifiedJson["dist-tags"]["latest"], "2.0.0"); }); + it("Should suppress too-young versions on metadata requests without directly blocking the request", async () => { + minimumPackageAgeSettings = 5; + skipMinimumPackageAgeSetting = false; + const packageUrl = "https://registry.npmjs.org/lodash"; + + const interceptor = npmInterceptorForUrl(packageUrl); + const requestHandler = await interceptor.handleRequest(packageUrl); + + assert.equal(requestHandler.blockResponse, undefined); + assert.equal(requestHandler.modifiesResponse(), true); + }); + + it("Should directly block tarball requests when the new packages list marks them as too young", async () => { + minimumPackageAgeSettings = 5; + skipMinimumPackageAgeSetting = false; + newlyReleasedPackages = new Set(["lodash@4.17.21"]); + const packageUrl = + "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz?integrity=sha512-abc123"; + + const interceptor = npmInterceptorForUrl(packageUrl); + const requestHandler = await interceptor.handleRequest(packageUrl); + + assert.ok(requestHandler.blockResponse); + assert.equal(requestHandler.modifiesResponse(), false); + assert.equal(requestHandler.blockResponse.statusCode, 403); + assert.equal( + requestHandler.blockResponse.message, + "Forbidden - blocked by safe-chain minimum package age (lodash@4.17.21)" + ); + }); + + it("Should not block tarball requests when skipMinimumPackageAge is enabled", async () => { + minimumPackageAgeSettings = 5; + skipMinimumPackageAgeSetting = true; + minimumPackageAgeExclusionsSetting = []; + newlyReleasedPackages = new Set(["lodash@4.17.21"]); + const packageUrl = + "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"; + + const interceptor = npmInterceptorForUrl(packageUrl); + const requestHandler = await interceptor.handleRequest(packageUrl); + + assert.equal(requestHandler.blockResponse, undefined); + assert.equal(requestHandler.modifiesResponse(), false); + }); + + it("Should not block tarball requests when the package is excluded from minimum age", async () => { + minimumPackageAgeSettings = 5; + skipMinimumPackageAgeSetting = false; + minimumPackageAgeExclusionsSetting = ["lodash"]; + newlyReleasedPackages = new Set(["lodash@4.17.21"]); + const packageUrl = + "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"; + + const interceptor = npmInterceptorForUrl(packageUrl); + const requestHandler = await interceptor.handleRequest(packageUrl); + + assert.equal(requestHandler.blockResponse, undefined); + assert.equal(requestHandler.modifiesResponse(), false); + }); + it("Should not filter packages when package is in exclusion list", async () => { minimumPackageAgeSettings = 5; skipMinimumPackageAgeSetting = false; @@ -540,6 +613,7 @@ describe("npmInterceptor minimum package age", async () => { minimumPackageAgeSettings = 5; skipMinimumPackageAgeSetting = false; minimumPackageAgeExclusionsSetting = []; // Reset to empty + newlyReleasedPackages = new Set(); const packageUrl = "https://registry.npmjs.org/lodash"; diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js index e1b7c79..839605b 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js @@ -1,9 +1,11 @@ -import { describe, it, mock } from "node:test"; +import { describe, it, mock, beforeEach } from "node:test"; import assert from "node:assert"; let lastPackage; let malwareResponse = false; let customRegistries = []; +let newlyReleasedPackages = new Set(); +let skipMinimumPackageAgeSetting = false; mock.module("../../../scanning/audit/index.js", { namedExports: { @@ -27,13 +29,29 @@ mock.module("../../../config/settings.js", { getMinimumPackageAgeHours: () => 24, getNpmCustomRegistries: () => customRegistries, getNpmMinimumPackageAgeExclusions: () => [], - skipMinimumPackageAge: () => false, + skipMinimumPackageAge: () => skipMinimumPackageAgeSetting, + }, +}); +mock.module("../../../scanning/newPackagesDatabase.js", { + namedExports: { + openNewPackagesDatabase: async () => ({ + isNewlyReleasedPackage: (name, version) => + newlyReleasedPackages.has(`${name}@${version}`), + }), }, }); describe("npmInterceptor", async () => { const { npmInterceptorForUrl } = await import("./npmInterceptor.js"); + beforeEach(() => { + lastPackage = undefined; + malwareResponse = false; + customRegistries = []; + newlyReleasedPackages = new Set(); + skipMinimumPackageAgeSetting = false; + }); + const parserCases = [ // Regular packages { @@ -178,6 +196,36 @@ describe("npmInterceptor", async () => { "Block response should have correct status message" ); }); + + it("should block direct tarball downloads for newly released packages", async () => { + const url = + "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz?integrity=sha512-abc123"; + malwareResponse = false; + skipMinimumPackageAgeSetting = false; + newlyReleasedPackages = new Set(["lodash@4.17.21"]); + + const interceptor = npmInterceptorForUrl(url); + const result = await interceptor.handleRequest(url); + + assert.ok(result.blockResponse); + assert.equal(result.blockResponse.statusCode, 403); + assert.equal( + result.blockResponse.message, + "Forbidden - blocked by safe-chain minimum package age (lodash@4.17.21)" + ); + }); + + it("should not block direct tarball downloads when minimum age checks are skipped", async () => { + const url = "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"; + malwareResponse = false; + skipMinimumPackageAgeSetting = true; + newlyReleasedPackages = new Set(["lodash@4.17.21"]); + + const interceptor = npmInterceptorForUrl(url); + const result = await interceptor.handleRequest(url); + + assert.equal(result.blockResponse, undefined); + }); }); describe("npmInterceptor with custom registries", async () => { diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js b/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js index fa256d4..5e5248e 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js @@ -5,12 +5,16 @@ */ export function parseNpmPackageUrl(url, registry) { let packageName, version; - if (!registry || !url.endsWith(".tgz")) { + const urlWithoutParams = url.split("?")[0].split("#")[0]; + + if (!registry || !urlWithoutParams.endsWith(".tgz")) { return { packageName, version }; } - const registryIndex = url.indexOf(registry); - const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash + const registryIndex = urlWithoutParams.indexOf(registry); + const afterRegistry = urlWithoutParams.substring( + registryIndex + registry.length + 1 + ); // +1 to skip the slash const separatorIndex = afterRegistry.indexOf("/-/"); if (separatorIndex === -1) { diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index 2de776e..e67bab0 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -10,11 +10,16 @@ import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js"; const SERVER_STOP_TIMEOUT_MS = 1000; /** - * @type {{port: number | null, blockedRequests: {packageName: string, version: string, url: string}[]}} + * @type {{ + * port: number | null, + * blockedRequests: {packageName: string, version: string, url: string}[], + * blockedMinimumAgeRequests: {packageName: string, version: string, url: string}[] + * }} */ const state = { port: null, blockedRequests: [], + blockedMinimumAgeRequests: [], }; export function createSafeChainProxy() { @@ -24,6 +29,7 @@ export function createSafeChainProxy() { startServer: () => startServer(server), stopServer: () => stopServer(server), verifyNoMaliciousPackages, + verifyNoMinimumAgeBlockedRequests, hasSuppressedVersions: getHasSuppressedVersions, }; } @@ -151,6 +157,18 @@ function handleConnect(req, clientSocket, head) { onMalwareBlocked(event.packageName, event.version, event.targetUrl); } ); + interceptor.on( + "minimumAgeRequestBlocked", + ( + /** @type {import("./interceptors/interceptorBuilder.js").MinimumAgeRequestBlockedEvent} */ event + ) => { + onMinimumAgeRequestBlocked( + event.packageName, + event.version, + event.targetUrl + ); + } + ); mitmConnect(req, clientSocket, interceptor); } else { @@ -170,6 +188,16 @@ function onMalwareBlocked(packageName, version, url) { state.blockedRequests.push({ packageName, version, url }); } +/** + * + * @param {string} packageName + * @param {string} version + * @param {string} url + */ +function onMinimumAgeRequestBlocked(packageName, version, url) { + state.blockedMinimumAgeRequests.push({ packageName, version, url }); +} + function verifyNoMaliciousPackages() { if (state.blockedRequests.length === 0) { // No malicious packages were blocked, so nothing to block @@ -194,3 +222,35 @@ function verifyNoMaliciousPackages() { return false; } + +function verifyNoMinimumAgeBlockedRequests() { + if (state.blockedMinimumAgeRequests.length === 0) { + return true; + } + + ui.emptyLine(); + + ui.writeInformation( + `Safe-chain: ${chalk.bold( + `blocked ${state.blockedMinimumAgeRequests.length} package downloads due to minimum age` + )}:` + ); + + for (const req of state.blockedMinimumAgeRequests) { + ui.writeInformation(` - ${req.packageName}@${req.version} (${req.url})`); + } + + ui.writeInformation( + ` To disable this check, use: ${chalk.cyan( + "--safe-chain-skip-minimum-package-age" + )}` + ); + + ui.emptyLine(); + ui.writeError( + "Safe-chain: Exiting without installing packages blocked by minimum age." + ); + ui.emptyLine(); + + return false; +} diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.js b/packages/safe-chain/src/scanning/newPackagesDatabase.js index 31afb7d..b587cdd 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabase.js +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.js @@ -20,6 +20,7 @@ import { /** @type {NewPackagesDatabase | null} */ let cachedNewPackagesDatabase = null; +let hasWarnedAboutUnavailableNewPackagesDatabase = false; /** * Returns the source identifier used in the feed for the current ecosystem. @@ -42,7 +43,22 @@ export async function openNewPackagesDatabase() { return cachedNewPackagesDatabase; } - const newPackagesList = await getNewPackagesList(); + /** @type {import("../api/aikido.js").NewPackageEntry[]} */ + let newPackagesList; + + try { + newPackagesList = await getNewPackagesList(); + } catch (/** @type {any} */ error) { + if (!hasWarnedAboutUnavailableNewPackagesDatabase) { + ui.writeWarning( + `Failed to load the new packages list. Continuing without tarball minimum age fallback. ${error.message}` + ); + hasWarnedAboutUnavailableNewPackagesDatabase = true; + } + + cachedNewPackagesDatabase = { isNewlyReleasedPackage: () => false }; + return cachedNewPackagesDatabase; + } /** * @param {string} name diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js index 60a806f..3b2a20f 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js @@ -85,6 +85,10 @@ describe("newPackagesDatabase", async () => { return module.openNewPackagesDatabase(); } + async function loadNewPackagesDatabaseModule() { + return import(`./newPackagesDatabase.js?test_case=${importCounter++}`); + } + function hoursAgo(hours) { return Math.floor((Date.now() - hours * 3600 * 1000) / 1000); } @@ -226,5 +230,22 @@ describe("newPackagesDatabase", async () => { assert.strictEqual(writeWarningCalls.length, 1); assert.ok(writeWarningCalls[0].includes("could not be cached")); }); + + it("fails open and only warns once when the new packages list cannot be loaded", async () => { + fetchListError = new Error("feed unavailable"); + + const module = await loadNewPackagesDatabaseModule(); + const db1 = await module.openNewPackagesDatabase(); + const db2 = await module.openNewPackagesDatabase(); + + assert.strictEqual(db1.isNewlyReleasedPackage("foo", "1.0.0"), false); + assert.strictEqual(db2.isNewlyReleasedPackage("foo", "1.0.0"), false); + assert.strictEqual(writeWarningCalls.length, 1); + assert.ok( + writeWarningCalls[0].includes( + "Continuing without tarball minimum age fallback" + ) + ); + }); }); }); From 07e315a382611eab263dd3514799e4a68b3bba7f Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 19 Mar 2026 16:07:31 -0700 Subject: [PATCH 194/360] Adapt doc --- README.md | 12 +++++++++++- packages/safe-chain/src/main.js | 2 +- .../safe-chain/src/registryProxy/registryProxy.js | 4 ++-- .../safe-chain/src/scanning/newPackagesDatabase.js | 6 +++--- .../src/scanning/newPackagesDatabase.spec.js | 2 +- 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 4daf1d2..6d0e875 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,12 @@ The Aikido Safe Chain works by running a lightweight proxy server that intercept ### Minimum package age (npm only) -For npm packages, Safe Chain temporarily suppresses packages published within the last 24 hours (by default) until they have been validated against malware. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can configure this threshold or bypass this protection entirely - see the [Minimum Package Age Configuration](#minimum-package-age) section below. +For npm packages, Safe Chain applies minimum package age checks in two ways: + +- During normal package resolution, Safe Chain suppresses versions that are newer than the configured minimum age from the package metadata returned by the registry. +- For direct package download requests that bypass that metadata flow, Safe Chain can block the request itself using a cached list of newly released packages. + +By default, the minimum package age is 24 hours. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can configure this threshold or bypass this protection entirely - see the [Minimum Package Age Configuration](#minimum-package-age) section below. ⚠️ This feature **only applies to npm-based package managers** (npm, npx, yarn, pnpm, pnpx, bun, bunx) and does not apply to Python package managers (uv, pip, pip3, poetry, pipx). @@ -185,6 +190,11 @@ You can set the logging level through multiple sources (in order of priority): You can configure how long packages must exist before Safe Chain allows their installation. By default, packages must be at least 24 hours old before they can be installed through npm-based package managers. +For npm-based package managers, this check currently has two enforcement modes: + +- Safe Chain suppresses too-young versions from package metadata during normal dependency resolution. +- Safe Chain blocks direct package download requests when they are matched against the cached newly released packages list. + ### Configuration Options You can set the minimum package age through multiple sources (in order of priority): diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index 9d5c031..d9e5417 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -85,7 +85,7 @@ export async function main(args) { ui.writeInformation( `${chalk.yellow( "ℹ", - )} Safe-chain: Some package versions were suppressed due to minimum age requirement.`, + )} Safe-chain: Some package versions were suppressed during package metadata resolution due to minimum package age.`, ); ui.writeInformation( ` To disable this check, use: ${chalk.cyan( diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index e67bab0..4adba61 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -232,7 +232,7 @@ function verifyNoMinimumAgeBlockedRequests() { ui.writeInformation( `Safe-chain: ${chalk.bold( - `blocked ${state.blockedMinimumAgeRequests.length} package downloads due to minimum age` + `blocked ${state.blockedMinimumAgeRequests.length} direct package download request(s) due to minimum package age` )}:` ); @@ -248,7 +248,7 @@ function verifyNoMinimumAgeBlockedRequests() { ui.emptyLine(); ui.writeError( - "Safe-chain: Exiting without installing packages blocked by minimum age." + "Safe-chain: Exiting without installing packages blocked by the direct download minimum package age check." ); ui.emptyLine(); diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.js b/packages/safe-chain/src/scanning/newPackagesDatabase.js index b587cdd..fb99164 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabase.js +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.js @@ -51,7 +51,7 @@ export async function openNewPackagesDatabase() { } catch (/** @type {any} */ error) { if (!hasWarnedAboutUnavailableNewPackagesDatabase) { ui.writeWarning( - `Failed to load the new packages list. Continuing without tarball minimum age fallback. ${error.message}` + `Failed to load the new packages list used for direct package download request blocking. Continuing with metadata-based minimum age checks only. ${error.message}` ); hasWarnedAboutUnavailableNewPackagesDatabase = true; } @@ -112,14 +112,14 @@ async function getNewPackagesList() { return newPackagesList; } else { ui.writeWarning( - "The new packages list was downloaded, but could not be cached due to a missing version." + "The new packages list for direct package download request blocking was downloaded, but could not be cached due to a missing version." ); return newPackagesList; } } catch (/** @type {any} */ error) { if (cachedList) { ui.writeWarning( - "Failed to fetch the latest new packages list. Using cached version." + "Failed to fetch the latest new packages list for direct package download request blocking. Using cached version." ); return cachedList; } diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js index 3b2a20f..e2c88f7 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js @@ -243,7 +243,7 @@ describe("newPackagesDatabase", async () => { assert.strictEqual(writeWarningCalls.length, 1); assert.ok( writeWarningCalls[0].includes( - "Continuing without tarball minimum age fallback" + "Continuing with metadata-based minimum age checks only" ) ); }); From ac09534070efb2e34b76fd4650b1675044198c53 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 20 Mar 2026 09:11:02 -0700 Subject: [PATCH 195/360] Adapt per latest core --- packages/safe-chain/src/api/aikido.js | 8 ++--- packages/safe-chain/src/api/aikido.spec.js | 5 ++-- .../src/scanning/newPackagesDatabase.js | 19 +++++++++--- .../src/scanning/newPackagesDatabase.spec.js | 29 +++++++++---------- 4 files changed, 35 insertions(+), 26 deletions(-) diff --git a/packages/safe-chain/src/api/aikido.js b/packages/safe-chain/src/api/aikido.js index fb01f42..5248e0f 100644 --- a/packages/safe-chain/src/api/aikido.js +++ b/packages/safe-chain/src/api/aikido.js @@ -11,9 +11,9 @@ const malwareDatabaseUrls = { [ECOSYSTEM_PY]: "https://malware-list.aikido.dev/malware_pypi.json", }; -// TODO: replace with the real CDN URL once core publishes the S3 endpoint const newPackagesListUrls = { - [ECOSYSTEM_JS]: "https://new-packages.aikido.dev/js_packages.json", + [ECOSYSTEM_JS]: "https://malware-list.aikido.dev/releases_npm.json", + [ECOSYSTEM_PY]: "https://malware-list.aikido.dev/releases_pypi.json", }; const DEFAULT_FETCH_RETRY_ATTEMPTS = 4; @@ -27,8 +27,8 @@ const DEFAULT_FETCH_RETRY_ATTEMPTS = 4; /** * @typedef {Object} NewPackageEntry - * @property {string} source - * @property {string} name + * @property {string} [source] + * @property {string} package_name * @property {string} version * @property {number} released_on - Unix timestamp (seconds) * @property {number} scraped_on - Unix timestamp (seconds) diff --git a/packages/safe-chain/src/api/aikido.spec.js b/packages/safe-chain/src/api/aikido.spec.js index b2d25c2..d70f7e2 100644 --- a/packages/safe-chain/src/api/aikido.spec.js +++ b/packages/safe-chain/src/api/aikido.spec.js @@ -141,8 +141,7 @@ describe("aikido API", async () => { it("should succeed immediately when fetch succeeds on first try", async () => { const releases = [ { - source: "NPM", - name: "fresh-pkg", + package_name: "fresh-pkg", version: "1.0.0", released_on: 123, scraped_on: 456, @@ -174,7 +173,7 @@ describe("aikido API", async () => { }); it("should return an empty list without fetching for unsupported ecosystems", async () => { - ecosystem = "py"; + ecosystem = "ruby"; const result = await fetchNewPackagesList(); diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.js b/packages/safe-chain/src/scanning/newPackagesDatabase.js index fb99164..b480dab 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabase.js +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.js @@ -11,6 +11,7 @@ import { getMinimumPackageAgeHours, getEcoSystem, ECOSYSTEM_JS, + ECOSYSTEM_PY, } from "../config/settings.js"; /** @@ -23,11 +24,21 @@ let cachedNewPackagesDatabase = null; let hasWarnedAboutUnavailableNewPackagesDatabase = false; /** - * Returns the source identifier used in the feed for the current ecosystem. + * Returns the ecosystem identifier expected in upstream/core release feeds. * @returns {string} */ function getCurrentFeedSource() { - return getEcoSystem(); + const ecosystem = getEcoSystem(); + + if (ecosystem === ECOSYSTEM_JS) { + return "npm"; + } + + if (ecosystem === ECOSYSTEM_PY) { + return "pypi"; + } + + return ecosystem; } /** @@ -73,8 +84,8 @@ export async function openNewPackagesDatabase() { const entry = newPackagesList.find( (pkg) => - pkg.source?.toLowerCase() === expectedSource && - pkg.name === name && + (!pkg.source || pkg.source.toLowerCase() === expectedSource) && + pkg.package_name === name && pkg.version === version ); diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js index e2c88f7..58c9a74 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js @@ -96,7 +96,7 @@ describe("newPackagesDatabase", async () => { describe("isNewlyReleasedPackage", () => { it("returns true for a package released within the age threshold", async () => { fetchedList = [ - { source: "js", name: "foo", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + { package_name: "foo", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, ]; const db = await openNewPackagesDatabase(); @@ -105,7 +105,7 @@ describe("newPackagesDatabase", async () => { it("returns false for a package released outside the age threshold", async () => { fetchedList = [ - { source: "js", name: "foo", version: "1.0.0", released_on: hoursAgo(48), scraped_on: hoursAgo(48) }, + { package_name: "foo", version: "1.0.0", released_on: hoursAgo(48), scraped_on: hoursAgo(48) }, ]; const db = await openNewPackagesDatabase(); @@ -121,25 +121,25 @@ describe("newPackagesDatabase", async () => { it("returns false for a known package but different version", async () => { fetchedList = [ - { source: "js", name: "foo", version: "2.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + { package_name: "foo", version: "2.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, ]; const db = await openNewPackagesDatabase(); assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false); }); - it("ignores entries from a different source in a mixed feed", async () => { + it("matches the current feed ecosystem when source metadata is present", async () => { fetchedList = [ { - source: "npm", - name: "foo", + source: "pypi", + package_name: "foo", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1), }, { - source: "js", - name: "bar", + source: "npm", + package_name: "bar", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1), @@ -155,7 +155,7 @@ describe("newPackagesDatabase", async () => { it("respects a custom minimumPackageAgeHours threshold", async () => { minimumPackageAgeHours = 168; // 7 days fetchedList = [ - { source: "js", name: "foo", version: "1.0.0", released_on: hoursAgo(100), scraped_on: hoursAgo(100) }, + { package_name: "foo", version: "1.0.0", released_on: hoursAgo(100), scraped_on: hoursAgo(100) }, ]; const db = await openNewPackagesDatabase(); @@ -172,7 +172,7 @@ describe("newPackagesDatabase", async () => { describe("caching behaviour", () => { it("uses local cache when etag matches", async () => { cachedList = [ - { source: "js", name: "cached-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + { package_name: "cached-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, ]; cachedVersion = "etag-1"; fetchVersionResult = "etag-1"; @@ -185,12 +185,12 @@ describe("newPackagesDatabase", async () => { it("fetches fresh list when etag does not match", async () => { cachedList = [ - { source: "js", name: "stale-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + { package_name: "stale-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, ]; cachedVersion = "etag-old"; fetchVersionResult = "etag-new"; fetchedList = [ - { source: "js", name: "fresh-pkg", version: "2.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + { package_name: "fresh-pkg", version: "2.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, ]; const db = await openNewPackagesDatabase(); @@ -201,8 +201,7 @@ describe("newPackagesDatabase", async () => { it("falls back to local cache when fetch fails", async () => { cachedList = [ { - source: "js", - name: "cached-pkg", + package_name: "cached-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1), @@ -221,7 +220,7 @@ describe("newPackagesDatabase", async () => { it("emits a warning when list has no version (cannot be cached)", async () => { fetchedList = [ - { source: "js", name: "foo", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + { package_name: "foo", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, ]; fetchedVersion = undefined; From 16c51c2720497179e9dc1d2ea4663455a289f41b Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 20 Mar 2026 10:28:46 -0700 Subject: [PATCH 196/360] Add e2e test skeleton --- ...imum-package-age-request-block.e2e.spec.js | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 test/e2e/minimum-package-age-request-block.e2e.spec.js diff --git a/test/e2e/minimum-package-age-request-block.e2e.spec.js b/test/e2e/minimum-package-age-request-block.e2e.spec.js new file mode 100644 index 0000000..5dd147c --- /dev/null +++ b/test/e2e/minimum-package-age-request-block.e2e.spec.js @@ -0,0 +1,161 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +describe.skip( + "E2E: minimum package age direct request fallback", + () => { + let container; + + before(async () => { + DockerTestContainer.buildImage(); + }); + + beforeEach(async () => { + container = new DockerTestContainer(); + await container.start(); + + const installationShell = await container.openShell("zsh"); + await installationShell.runCommand("safe-chain setup-ci"); + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc" + ); + }); + + afterEach(async () => { + if (container) { + await container.stop(); + container = null; + } + }); + + it("blocks npm ci when a lockfile resolves to a recently released package", async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand( + "npm init -y && npm pkg set dependencies.axios=1.8.4" + ); + await shell.runCommand("npm install --package-lock-only"); + await shell.runCommand("rm -rf node_modules"); + await seedNewPackagesListCache(shell, [ + { + package_name: "axios", + version: "1.8.4", + released_on: unixHoursAgo(1), + scraped_on: unixHoursAgo(1), + }, + ]); + + const result = await shell.runCommand( + "npm ci --safe-chain-minimum-package-age-hours=168 --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes( + "blocked 1 direct package download request(s) due to minimum package age" + ), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("- axios@1.8.4"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes( + "Exiting without installing packages blocked by the direct download minimum package age check." + ), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it("blocks yarn frozen-lockfile installs when the cached recent releases list marks the tarball as too young", async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand( + "npm init -y && npm pkg set dependencies.axios=1.8.4" + ); + await shell.runCommand("yarn install"); + await shell.runCommand("rm -rf node_modules"); + await seedNewPackagesListCache(shell, [ + { + package_name: "axios", + version: "1.8.4", + released_on: unixHoursAgo(1), + scraped_on: unixHoursAgo(1), + }, + ]); + + const result = await shell.runCommand( + "yarn install --frozen-lockfile --safe-chain-minimum-package-age-hours=168 --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes( + "blocked 1 direct package download request(s) due to minimum package age" + ), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("- axios@1.8.4"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it("allows the same lockfile-driven install when minimum age checks are skipped", async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand( + "npm init -y && npm pkg set dependencies.axios=1.8.4" + ); + await shell.runCommand("npm install --package-lock-only"); + await shell.runCommand("rm -rf node_modules"); + await seedNewPackagesListCache(shell, [ + { + package_name: "axios", + version: "1.8.4", + released_on: unixHoursAgo(1), + scraped_on: unixHoursAgo(1), + }, + ]); + + const result = await shell.runCommand( + "npm ci --safe-chain-minimum-package-age-hours=168 --safe-chain-skip-minimum-package-age --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + !result.output.includes( + "direct package download request(s) due to minimum package age" + ), + `Output unexpectedly contained a direct request block. Output was:\n${result.output}` + ); + }); + } +); + +/** + * @param {{ runCommand: (command: string) => Promise<{output: string}> }} shell + * @param {Array<{package_name: string, version: string, released_on: number, scraped_on: number}>} entries + */ +async function seedNewPackagesListCache(shell, entries) { + const payload = JSON.stringify(entries).replace(/"/g, '\\"'); + + await shell.runCommand("mkdir -p ~/.safe-chain"); + await shell.runCommand( + `printf "%s" "${payload}" > ~/.safe-chain/newPackagesList_js.json` + ); + await shell.runCommand( + 'printf "%s" "test-etag" > ~/.safe-chain/newPackagesList_version_js.txt' + ); +} + +/** + * @param {number} hours + * @returns {number} + */ +function unixHoursAgo(hours) { + return Math.floor((Date.now() - hours * 3600 * 1000) / 1000); +} From cc5a7d9a0bbd3e77f293f0cc862eafefbf830ded Mon Sep 17 00:00:00 2001 From: "aikido-autofix[bot]" <119856028+aikido-autofix[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:57:05 +0000 Subject: [PATCH 197/360] fix(security): autofix Template Injection in GitHub Workflows Action --- .github/workflows/create-artifact.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/create-artifact.yml b/.github/workflows/create-artifact.yml index 90b9745..4fee730 100644 --- a/.github/workflows/create-artifact.yml +++ b/.github/workflows/create-artifact.yml @@ -78,7 +78,9 @@ jobs: - name: Set the version in safe-chain package if: inputs.version != '' - run: npm --no-git-tag-version version ${{ inputs.version }} --workspace=packages/safe-chain --ignore-scripts + env: + VERSION: ${{ inputs.version }} + run: npm --no-git-tag-version version $VERSION --workspace=packages/safe-chain --ignore-scripts - name: Create binary run: | From e9f941e3d0ee2b7bc4af13271eddee52cc096dfd Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 25 Mar 2026 09:53:42 +0100 Subject: [PATCH 198/360] Use runner with static ip for releases --- .github/workflows/build-and-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index bab932c..d6c810a 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -12,7 +12,7 @@ permissions: jobs: set-version: name: Set version number - runs-on: ubuntu-latest + runs-on: standard-runner-no-rights-public-ip outputs: version: ${{ steps.get_version.outputs.tag }} is_prerelease: ${{ steps.check_prerelease.outputs.is_prerelease }} @@ -44,7 +44,7 @@ jobs: publish-binaries: name: Publish to GitHub release needs: [set-version, create-binaries] - runs-on: ubuntu-latest + runs-on: standard-runner-no-rights-public-ip steps: - name: Checkout code From d113ca3061c6abc0ee6baf249020af23e5ef78dc Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 25 Mar 2026 16:19:15 +0100 Subject: [PATCH 199/360] Increase default min package age to 48 hours --- README.md | 6 +++--- packages/safe-chain/src/config/settings.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4daf1d2..a391cdd 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - ✅ **Block malware on developer laptops and CI/CD** - ✅ **Supports npm and PyPI** more package managers coming -- ✅ **Blocks packages newer than 24 hours** without breaking your build +- ✅ **Blocks packages newer than 48 hours** without breaking your build - ✅ **Tokenless, free, no build data shared** Aikido Safe Chain supports the following package managers: @@ -113,7 +113,7 @@ The Aikido Safe Chain works by running a lightweight proxy server that intercept ### Minimum package age (npm only) -For npm packages, Safe Chain temporarily suppresses packages published within the last 24 hours (by default) until they have been validated against malware. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can configure this threshold or bypass this protection entirely - see the [Minimum Package Age Configuration](#minimum-package-age) section below. +For npm packages, Safe Chain temporarily suppresses packages published within the last 48 hours (by default) until they have been validated against malware. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can configure this threshold or bypass this protection entirely - see the [Minimum Package Age Configuration](#minimum-package-age) section below. ⚠️ This feature **only applies to npm-based package managers** (npm, npx, yarn, pnpm, pnpx, bun, bunx) and does not apply to Python package managers (uv, pip, pip3, poetry, pipx). @@ -183,7 +183,7 @@ You can set the logging level through multiple sources (in order of priority): ## Minimum Package Age -You can configure how long packages must exist before Safe Chain allows their installation. By default, packages must be at least 24 hours old before they can be installed through npm-based package managers. +You can configure how long packages must exist before Safe Chain allows their installation. By default, packages must be at least 48 hours old before they can be installed through npm-based package managers. ### Configuration Options diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index b9243b0..7919d87 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -45,7 +45,7 @@ export function setEcoSystem(setting) { ecosystemSettings.ecoSystem = setting; } -const defaultMinimumPackageAge = 24; +const defaultMinimumPackageAge = 48; /** @returns {number} */ export function getMinimumPackageAgeHours() { // Priority 1: CLI argument From 33f50ba5804e2c8543dd7e0c65224bcd752811bd Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Wed, 25 Mar 2026 11:04:05 -0700 Subject: [PATCH 200/360] Change runner to open-source-releaser in workflow --- .github/workflows/build-and-release.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index d6c810a..1e593a3 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -12,7 +12,7 @@ permissions: jobs: set-version: name: Set version number - runs-on: standard-runner-no-rights-public-ip + runs-on: open-source-releaser outputs: version: ${{ steps.get_version.outputs.tag }} is_prerelease: ${{ steps.check_prerelease.outputs.is_prerelease }} @@ -44,8 +44,7 @@ jobs: publish-binaries: name: Publish to GitHub release needs: [set-version, create-binaries] - runs-on: standard-runner-no-rights-public-ip - + runs-on: open-source-releaser steps: - name: Checkout code uses: actions/checkout@v3 From 7433e97c4a2c437a06e9abbc239c96efac737ae5 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 25 Mar 2026 12:58:35 -0700 Subject: [PATCH 201/360] Fix yml --- .github/workflows/build-and-release.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 1e593a3..d156d59 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -28,12 +28,15 @@ jobs: - name: Check if pre-release id: check_prerelease - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - IS_PRERELEASE=$(gh release view ${{ steps.get_version.outputs.tag }} --json isPrerelease --jq '.isPrerelease') + TAG="${{ steps.get_version.outputs.tag }}" + if echo "$TAG" | grep -Eq '(^|[.-])(alpha|beta|rc|pre)([.-]?[0-9]+)?$'; then + IS_PRERELEASE=true + else + IS_PRERELEASE=false + fi echo "is_prerelease=$IS_PRERELEASE" >> $GITHUB_OUTPUT - echo "Release ${{ steps.get_version.outputs.tag }} is pre-release: $IS_PRERELEASE" + echo "Tag $TAG is pre-release: $IS_PRERELEASE" create-binaries: needs: set-version From 306c727832762e9037804c345bda048f2dd773d7 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 25 Mar 2026 13:03:48 -0700 Subject: [PATCH 202/360] Fix test --- .../src/installation/downloadAgent.spec.js | 57 ++++++++++++------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/packages/safe-chain/src/installation/downloadAgent.spec.js b/packages/safe-chain/src/installation/downloadAgent.spec.js index 17aecb9..48d2fe8 100644 --- a/packages/safe-chain/src/installation/downloadAgent.spec.js +++ b/packages/safe-chain/src/installation/downloadAgent.spec.js @@ -2,18 +2,19 @@ import { describe, it, after } from "node:test"; import assert from "node:assert"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { unlinkSync } from "node:fs"; +import { unlinkSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; import { DOWNLOAD_URLS, - downloadFile, + getAgentDownloadUrl, verifyChecksum, } from "./downloadAgent.js"; -describe("downloadAgent checksums", { timeout: 120_000 }, () => { - const downloadedFiles = []; +describe("downloadAgent", () => { + const tempFiles = []; after(() => { - for (const file of downloadedFiles) { + for (const file of tempFiles) { try { unlinkSync(file); } catch { @@ -24,22 +25,40 @@ describe("downloadAgent checksums", { timeout: 120_000 }, () => { for (const [platform, architectures] of Object.entries(DOWNLOAD_URLS)) { for (const [arch, { url, checksum }] of Object.entries(architectures)) { - it(`${platform}/${arch} checksum matches`, async () => { - const destPath = join( - tmpdir(), - `safe-chain-test-${platform}-${arch}-${Date.now()}` - ); - downloadedFiles.push(destPath); - - await downloadFile(url, destPath); - - const isValid = await verifyChecksum(destPath, checksum); - assert.strictEqual( - isValid, - true, - `Checksum mismatch for ${platform}/${arch} (${url})` + it(`${platform}/${arch} has a valid download definition`, () => { + assert.match( + url, + /^https:\/\/github\.com\/AikidoSec\/safechain-internals\/releases\/download\/v\d+\.\d+\.\d+\/.+/, ); + assert.match(checksum, /^sha256:[a-f0-9]{64}$/); }); } } + + it("builds agent download URLs from the current version", () => { + assert.equal( + getAgentDownloadUrl("SafeChainUltimate.pkg"), + "https://github.com/AikidoSec/safechain-internals/releases/download/v1.0.0/SafeChainUltimate.pkg", + ); + }); + + it("verifies checksum for a local file", async () => { + const destPath = join(tmpdir(), `safe-chain-test-${Date.now()}`); + tempFiles.push(destPath); + + writeFileSync(destPath, "safe-chain-test"); + + const expectedHash = createHash("sha256") + .update("safe-chain-test") + .digest("hex"); + + assert.equal( + await verifyChecksum(destPath, `sha256:${expectedHash}`), + true, + ); + assert.equal( + await verifyChecksum(destPath, `sha256:${"0".repeat(64)}`), + false, + ); + }); }); From de33ceab417708495f9bb2a73d4b5baf70db13bb Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 25 Mar 2026 13:06:14 -0700 Subject: [PATCH 203/360] Another fix --- .github/workflows/build-and-release.yml | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index d156d59..1e593a3 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -28,15 +28,12 @@ jobs: - name: Check if pre-release id: check_prerelease + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - TAG="${{ steps.get_version.outputs.tag }}" - if echo "$TAG" | grep -Eq '(^|[.-])(alpha|beta|rc|pre)([.-]?[0-9]+)?$'; then - IS_PRERELEASE=true - else - IS_PRERELEASE=false - fi + IS_PRERELEASE=$(gh release view ${{ steps.get_version.outputs.tag }} --json isPrerelease --jq '.isPrerelease') echo "is_prerelease=$IS_PRERELEASE" >> $GITHUB_OUTPUT - echo "Tag $TAG is pre-release: $IS_PRERELEASE" + echo "Release ${{ steps.get_version.outputs.tag }} is pre-release: $IS_PRERELEASE" create-binaries: needs: set-version From 9f3cd1b4da08e37e6fa2a5750ec76e73ea485692 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 25 Mar 2026 13:16:42 -0700 Subject: [PATCH 204/360] Don't rely on hardcoded URL --- .../safe-chain/src/installation/downloadAgent.spec.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/safe-chain/src/installation/downloadAgent.spec.js b/packages/safe-chain/src/installation/downloadAgent.spec.js index 48d2fe8..44e53c0 100644 --- a/packages/safe-chain/src/installation/downloadAgent.spec.js +++ b/packages/safe-chain/src/installation/downloadAgent.spec.js @@ -6,7 +6,6 @@ import { unlinkSync, writeFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { DOWNLOAD_URLS, - getAgentDownloadUrl, verifyChecksum, } from "./downloadAgent.js"; @@ -35,13 +34,6 @@ describe("downloadAgent", () => { } } - it("builds agent download URLs from the current version", () => { - assert.equal( - getAgentDownloadUrl("SafeChainUltimate.pkg"), - "https://github.com/AikidoSec/safechain-internals/releases/download/v1.0.0/SafeChainUltimate.pkg", - ); - }); - it("verifies checksum for a local file", async () => { const destPath = join(tmpdir(), `safe-chain-test-${Date.now()}`); tempFiles.push(destPath); From 50a931cf4dc235ca7ca54824aac27b6e0a496b00 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 26 Mar 2026 13:36:20 +0100 Subject: [PATCH 205/360] Add manual setup and teardown instructions on failure --- .../safe-chain/src/shell-integration/setup.js | 10 +++++++--- .../src/shell-integration/shellDetection.js | 2 ++ .../shell-integration/supported-shells/bash.js | 18 ++++++++++++++++++ .../shell-integration/supported-shells/fish.js | 18 ++++++++++++++++++ .../supported-shells/powershell.js | 18 ++++++++++++++++++ .../supported-shells/windowsPowershell.js | 18 ++++++++++++++++++ .../shell-integration/supported-shells/zsh.js | 18 ++++++++++++++++++ .../src/shell-integration/teardown.js | 8 +++++++- 8 files changed, 106 insertions(+), 4 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/setup.js b/packages/safe-chain/src/shell-integration/setup.js index 4138db6..66c6533 100644 --- a/packages/safe-chain/src/shell-integration/setup.js +++ b/packages/safe-chain/src/shell-integration/setup.js @@ -91,9 +91,7 @@ async function setupShell(shell) { ); } else { ui.writeError( - `${chalk.bold("- " + shell.name + ":")} ${chalk.red( - "Setup failed", - )}. Please check your ${shell.name} configuration.`, + `${chalk.bold("- " + shell.name + ":")} ${chalk.red("Setup failed")}`, ); if (error) { let message = ` Error: ${error.message}`; @@ -102,6 +100,12 @@ async function setupShell(shell) { } ui.writeError(message); } + ui.emptyLine(); + ui.writeInformation(` ${chalk.bold("To set up manually:")}`); + for (const instruction of shell.getManualSetupInstructions()) { + ui.writeInformation(` ${instruction}`); + } + ui.emptyLine(); } return success; diff --git a/packages/safe-chain/src/shell-integration/shellDetection.js b/packages/safe-chain/src/shell-integration/shellDetection.js index 996125c..c471244 100644 --- a/packages/safe-chain/src/shell-integration/shellDetection.js +++ b/packages/safe-chain/src/shell-integration/shellDetection.js @@ -11,6 +11,8 @@ import { ui } from "../environment/userInteraction.js"; * @property {() => boolean} isInstalled * @property {(tools: import("./helpers.js").AikidoTool[]) => boolean|Promise} setup * @property {(tools: import("./helpers.js").AikidoTool[]) => boolean} teardown + * @property {() => string[]} getManualSetupInstructions + * @property {() => string[]} getManualTeardownInstructions */ /** diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.js index 07d89cb..cc50223 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.js @@ -123,6 +123,22 @@ function cygpathw(path) { } } +function getManualTeardownInstructions() { + return [ + `Remove the following line from your ~/.bashrc file:`, + ` source ~/.safe-chain/scripts/init-posix.sh`, + `Then restart your terminal or run: source ~/.bashrc`, + ]; +} + +function getManualSetupInstructions() { + return [ + `Add the following line to your ~/.bashrc file:`, + ` source ~/.safe-chain/scripts/init-posix.sh`, + `Then restart your terminal or run: source ~/.bashrc`, + ]; +} + /** * @type {import("../shellDetection.js").Shell} */ @@ -131,4 +147,6 @@ export default { isInstalled, setup, teardown, + getManualSetupInstructions, + getManualTeardownInstructions, }; diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.js index 0af6ae3..a623d0b 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.js @@ -66,6 +66,22 @@ function getStartupFile() { } } +function getManualTeardownInstructions() { + return [ + `Remove the following line from your ~/.config/fish/config.fish file:`, + ` source ~/.safe-chain/scripts/init-fish.fish`, + `Then restart your terminal or run: source ~/.config/fish/config.fish`, + ]; +} + +function getManualSetupInstructions() { + return [ + `Add the following line to your ~/.config/fish/config.fish file:`, + ` source ~/.safe-chain/scripts/init-fish.fish`, + `Then restart your terminal or run: source ~/.config/fish/config.fish`, + ]; +} + /** * @type {import("../shellDetection.js").Shell} */ @@ -74,4 +90,6 @@ export default { isInstalled, setup, teardown, + getManualSetupInstructions, + getManualTeardownInstructions, }; diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js index 96eb219..4bbc332 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js @@ -71,6 +71,22 @@ function getStartupFile() { } } +function getManualTeardownInstructions() { + return [ + `Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`, + ` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`, + `Then restart your terminal or run: . $PROFILE`, + ]; +} + +function getManualSetupInstructions() { + return [ + `Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`, + ` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`, + `Then restart your terminal or run: . $PROFILE`, + ]; +} + /** * @type {import("../shellDetection.js").Shell} */ @@ -79,4 +95,6 @@ export default { isInstalled, setup, teardown, + getManualSetupInstructions, + getManualTeardownInstructions, }; diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js index 2740456..3e81da7 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -71,6 +71,22 @@ function getStartupFile() { } } +function getManualTeardownInstructions() { + return [ + `Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`, + ` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`, + `Then restart your terminal or run: . $PROFILE`, + ]; +} + +function getManualSetupInstructions() { + return [ + `Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`, + ` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`, + `Then restart your terminal or run: . $PROFILE`, + ]; +} + /** * @type {import("../shellDetection.js").Shell} */ @@ -79,4 +95,6 @@ export default { isInstalled, setup, teardown, + getManualSetupInstructions, + getManualTeardownInstructions, }; diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js index 6086095..f187af3 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js @@ -66,9 +66,27 @@ function getStartupFile() { } } +function getManualTeardownInstructions() { + return [ + `Remove the following line from your ~/.zshrc file:`, + ` source ~/.safe-chain/scripts/init-posix.sh`, + `Then restart your terminal or run: source ~/.zshrc`, + ]; +} + +function getManualSetupInstructions() { + return [ + `Add the following line to your ~/.zshrc file:`, + ` source ~/.safe-chain/scripts/init-posix.sh`, + `Then restart your terminal or run: source ~/.zshrc`, + ]; +} + export default { name: shellName, isInstalled, setup, teardown, + getManualSetupInstructions, + getManualTeardownInstructions, }; diff --git a/packages/safe-chain/src/shell-integration/teardown.js b/packages/safe-chain/src/shell-integration/teardown.js index de3fbd7..bcf6346 100644 --- a/packages/safe-chain/src/shell-integration/teardown.js +++ b/packages/safe-chain/src/shell-integration/teardown.js @@ -47,8 +47,14 @@ export async function teardown() { ui.writeError( `${chalk.bold("- " + shell.name + ":")} ${chalk.red( "Teardown failed" - )}. Please check your ${shell.name} configuration.` + )}` ); + ui.emptyLine(); + ui.writeInformation(` ${chalk.bold("To tear down manually:")}`); + for (const instruction of shell.getManualTeardownInstructions()) { + ui.writeInformation(` ${instruction}`); + } + ui.emptyLine(); } } From edf6a1694f93503b000b359f3e6b7d8aac662c83 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 27 Mar 2026 10:35:41 -0700 Subject: [PATCH 206/360] Some cleanups --- packages/safe-chain/src/api/aikido.js | 4 +- packages/safe-chain/src/api/aikido.spec.js | 11 ++ .../interceptors/npm/npmInterceptor.js | 2 +- ...imum-package-age-request-block.e2e.spec.js | 161 ------------------ 4 files changed, 14 insertions(+), 164 deletions(-) delete mode 100644 test/e2e/minimum-package-age-request-block.e2e.spec.js diff --git a/packages/safe-chain/src/api/aikido.js b/packages/safe-chain/src/api/aikido.js index 5248e0f..0ceec21 100644 --- a/packages/safe-chain/src/api/aikido.js +++ b/packages/safe-chain/src/api/aikido.js @@ -12,8 +12,8 @@ const malwareDatabaseUrls = { }; const newPackagesListUrls = { - [ECOSYSTEM_JS]: "https://malware-list.aikido.dev/releases_npm.json", - [ECOSYSTEM_PY]: "https://malware-list.aikido.dev/releases_pypi.json", + [ECOSYSTEM_JS]: "https://malware-list.aikido.dev/releases/npm.json", + [ECOSYSTEM_PY]: "https://malware-list.aikido.dev/releases/pypi.json", }; const DEFAULT_FETCH_RETRY_ATTEMPTS = 4; diff --git a/packages/safe-chain/src/api/aikido.spec.js b/packages/safe-chain/src/api/aikido.spec.js index d70f7e2..0d3a964 100644 --- a/packages/safe-chain/src/api/aikido.spec.js +++ b/packages/safe-chain/src/api/aikido.spec.js @@ -156,6 +156,10 @@ describe("aikido API", async () => { const result = await fetchNewPackagesList(); assert.strictEqual(mockFetch.mock.calls.length, 1); + assert.strictEqual( + mockFetch.mock.calls[0].arguments[0], + "https://malware-list.aikido.dev/releases/npm.json" + ); assert.deepStrictEqual(result.newPackagesList, releases); assert.strictEqual(result.version, '"etag-new-packages"'); }); @@ -193,6 +197,13 @@ describe("aikido API", async () => { const result = await fetchNewPackagesListVersion(); assert.strictEqual(mockFetch.mock.calls.length, 1); + assert.strictEqual( + mockFetch.mock.calls[0].arguments[0], + "https://malware-list.aikido.dev/releases/npm.json" + ); + assert.deepStrictEqual(mockFetch.mock.calls[0].arguments[1], { + method: "HEAD", + }); assert.strictEqual(result, '"new-packages-etag"'); }); diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js index b912977..c1310bd 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js @@ -73,7 +73,7 @@ function buildNpmInterceptor(registry) { reqContext.blockMinimumAgeRequest( packageName, version, - `Forbidden - blocked by safe-chain minimum package age (${packageName}@${version})` + `Forbidden - blocked by safe-chain direct download minimum package age (${packageName}@${version})` ); } } diff --git a/test/e2e/minimum-package-age-request-block.e2e.spec.js b/test/e2e/minimum-package-age-request-block.e2e.spec.js deleted file mode 100644 index 5dd147c..0000000 --- a/test/e2e/minimum-package-age-request-block.e2e.spec.js +++ /dev/null @@ -1,161 +0,0 @@ -import { describe, it, before, beforeEach, afterEach } from "node:test"; -import { DockerTestContainer } from "./DockerTestContainer.js"; -import assert from "node:assert"; - -describe.skip( - "E2E: minimum package age direct request fallback", - () => { - let container; - - before(async () => { - DockerTestContainer.buildImage(); - }); - - beforeEach(async () => { - container = new DockerTestContainer(); - await container.start(); - - const installationShell = await container.openShell("zsh"); - await installationShell.runCommand("safe-chain setup-ci"); - await installationShell.runCommand( - "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc" - ); - }); - - afterEach(async () => { - if (container) { - await container.stop(); - container = null; - } - }); - - it("blocks npm ci when a lockfile resolves to a recently released package", async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand( - "npm init -y && npm pkg set dependencies.axios=1.8.4" - ); - await shell.runCommand("npm install --package-lock-only"); - await shell.runCommand("rm -rf node_modules"); - await seedNewPackagesListCache(shell, [ - { - package_name: "axios", - version: "1.8.4", - released_on: unixHoursAgo(1), - scraped_on: unixHoursAgo(1), - }, - ]); - - const result = await shell.runCommand( - "npm ci --safe-chain-minimum-package-age-hours=168 --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes( - "blocked 1 direct package download request(s) due to minimum package age" - ), - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("- axios@1.8.4"), - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes( - "Exiting without installing packages blocked by the direct download minimum package age check." - ), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it("blocks yarn frozen-lockfile installs when the cached recent releases list marks the tarball as too young", async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand( - "npm init -y && npm pkg set dependencies.axios=1.8.4" - ); - await shell.runCommand("yarn install"); - await shell.runCommand("rm -rf node_modules"); - await seedNewPackagesListCache(shell, [ - { - package_name: "axios", - version: "1.8.4", - released_on: unixHoursAgo(1), - scraped_on: unixHoursAgo(1), - }, - ]); - - const result = await shell.runCommand( - "yarn install --frozen-lockfile --safe-chain-minimum-package-age-hours=168 --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes( - "blocked 1 direct package download request(s) due to minimum package age" - ), - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("- axios@1.8.4"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - - it("allows the same lockfile-driven install when minimum age checks are skipped", async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand( - "npm init -y && npm pkg set dependencies.axios=1.8.4" - ); - await shell.runCommand("npm install --package-lock-only"); - await shell.runCommand("rm -rf node_modules"); - await seedNewPackagesListCache(shell, [ - { - package_name: "axios", - version: "1.8.4", - released_on: unixHoursAgo(1), - scraped_on: unixHoursAgo(1), - }, - ]); - - const result = await shell.runCommand( - "npm ci --safe-chain-minimum-package-age-hours=168 --safe-chain-skip-minimum-package-age --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - !result.output.includes( - "direct package download request(s) due to minimum package age" - ), - `Output unexpectedly contained a direct request block. Output was:\n${result.output}` - ); - }); - } -); - -/** - * @param {{ runCommand: (command: string) => Promise<{output: string}> }} shell - * @param {Array<{package_name: string, version: string, released_on: number, scraped_on: number}>} entries - */ -async function seedNewPackagesListCache(shell, entries) { - const payload = JSON.stringify(entries).replace(/"/g, '\\"'); - - await shell.runCommand("mkdir -p ~/.safe-chain"); - await shell.runCommand( - `printf "%s" "${payload}" > ~/.safe-chain/newPackagesList_js.json` - ); - await shell.runCommand( - 'printf "%s" "test-etag" > ~/.safe-chain/newPackagesList_version_js.txt' - ); -} - -/** - * @param {number} hours - * @returns {number} - */ -function unixHoursAgo(hours) { - return Math.floor((Date.now() - hours * 3600 * 1000) / 1000); -} From db31fa9f416f2a43849289b9cc0326ae645e7c57 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 27 Mar 2026 10:37:47 -0700 Subject: [PATCH 207/360] Fix unit test --- .../interceptors/npm/npmInterceptor.minPackageAge.spec.js | 2 +- .../interceptors/npm/npmInterceptor.packageDownload.spec.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js index 2e43119..45d3ceb 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js @@ -398,7 +398,7 @@ describe("npmInterceptor minimum package age", async () => { assert.equal(requestHandler.blockResponse.statusCode, 403); assert.equal( requestHandler.blockResponse.message, - "Forbidden - blocked by safe-chain minimum package age (lodash@4.17.21)" + "Forbidden - blocked by safe-chain direct download minimum package age (lodash@4.17.21)" ); }); diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js index 839605b..f376e1b 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js @@ -211,7 +211,7 @@ describe("npmInterceptor", async () => { assert.equal(result.blockResponse.statusCode, 403); assert.equal( result.blockResponse.message, - "Forbidden - blocked by safe-chain minimum package age (lodash@4.17.21)" + "Forbidden - blocked by safe-chain direct download minimum package age (lodash@4.17.21)" ); }); From a53fc736e9e63b87c23ce3a3658bed94d70af3f9 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 27 Mar 2026 11:45:26 -0700 Subject: [PATCH 208/360] Fix yarn URL issue --- .../interceptors/npm/npmInterceptor.packageDownload.spec.js | 4 ++++ .../src/registryProxy/interceptors/npm/parseNpmPackageUrl.js | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js index f376e1b..0c4b377 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js @@ -127,6 +127,10 @@ describe("npmInterceptor", async () => { url: "https://registry.yarnpkg.com/@babel/core/-/core-7.21.4.tgz", expected: { packageName: "@babel/core", version: "7.21.4" }, }, + { + url: "https://registry.yarnpkg.com/@music-i18n%2fverovio/-/verovio-1.4.1.tgz", + expected: { packageName: "@music-i18n/verovio", version: "1.4.1" }, + }, // URL to get package info, not tarball { url: "https://registry.npmjs.org/lodash", diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js b/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js index 5e5248e..5d12c0e 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js @@ -12,9 +12,9 @@ export function parseNpmPackageUrl(url, registry) { } const registryIndex = urlWithoutParams.indexOf(registry); - const afterRegistry = urlWithoutParams.substring( + const afterRegistry = decodeURIComponent(urlWithoutParams.substring( registryIndex + registry.length + 1 - ); // +1 to skip the slash + )); // +1 to skip the slash const separatorIndex = afterRegistry.indexOf("/-/"); if (separatorIndex === -1) { From 8353f353ae9e3858b8ec15195b39c7b32e9ec554 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 27 Mar 2026 11:52:55 -0700 Subject: [PATCH 209/360] Fix per review comment --- packages/safe-chain/src/scanning/newPackagesDatabase.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.js b/packages/safe-chain/src/scanning/newPackagesDatabase.js index b480dab..acda1e9 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabase.js +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.js @@ -19,6 +19,7 @@ import { * @property {function(string, string): boolean} isNewlyReleasedPackage */ +// Shared per-process cache to avoid rebuilding the same feed-backed database on each request. /** @type {NewPackagesDatabase | null} */ let cachedNewPackagesDatabase = null; let hasWarnedAboutUnavailableNewPackagesDatabase = false; From 2df8ce463c19c50cd5996ad897590c1ae2f4ad65 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 27 Mar 2026 13:17:58 -0700 Subject: [PATCH 210/360] Adapt per review --- packages/safe-chain/src/config/configFile.js | 84 +++++-------------- packages/safe-chain/src/main.js | 4 +- .../interceptors/npm/parseNpmPackageUrl.js | 25 ++++-- .../src/registryProxy/registryProxy.js | 17 ++-- .../src/scanning/newPackagesDatabase.js | 51 ++++++++++- .../src/scanning/newPackagesDatabase.spec.js | 53 +++++++----- 6 files changed, 127 insertions(+), 107 deletions(-) diff --git a/packages/safe-chain/src/config/configFile.js b/packages/safe-chain/src/config/configFile.js index 0246fa9..b421fde 100644 --- a/packages/safe-chain/src/config/configFile.js +++ b/packages/safe-chain/src/config/configFile.js @@ -203,70 +203,6 @@ export function readDatabaseFromLocalCache() { } } -/** - * @param {import("../api/aikido.js").NewPackageEntry[]} data - * @param {string | number} version - * - * @returns {void} - */ -export function writeNewPackagesListToLocalCache(data, version) { - try { - const listPath = getNewPackagesListPath(); - const versionPath = getNewPackagesListVersionPath(); - - fs.writeFileSync(listPath, JSON.stringify(data)); - fs.writeFileSync(versionPath, version.toString()); - } catch { - ui.writeWarning( - "Failed to write new packages list to local cache, next time the list will be fetched from the server again." - ); - } -} - -/** - * @returns {{newPackagesList: import("../api/aikido.js").NewPackageEntry[] | null, version: string | null}} - */ -export function readNewPackagesListFromLocalCache() { - try { - const listPath = getNewPackagesListPath(); - if (!fs.existsSync(listPath)) { - return { newPackagesList: null, version: null }; - } - - const data = fs.readFileSync(listPath, "utf8"); - const newPackagesList = JSON.parse(data); - const versionPath = getNewPackagesListVersionPath(); - let version = null; - if (fs.existsSync(versionPath)) { - version = fs.readFileSync(versionPath, "utf8").trim(); - } - return { newPackagesList, version }; - } catch { - ui.writeWarning( - "Failed to read new packages list from local cache. Continuing without local cache." - ); - return { newPackagesList: null, version: null }; - } -} - -/** - * @returns {string} - */ -function getNewPackagesListPath() { - const safeChainDir = getSafeChainDirectory(); - const ecosystem = getEcoSystem(); - return path.join(safeChainDir, `newPackagesList_${ecosystem}.json`); -} - -/** - * @returns {string} - */ -function getNewPackagesListVersionPath() { - const safeChainDir = getSafeChainDirectory(); - const ecosystem = getEcoSystem(); - return path.join(safeChainDir, `newPackagesList_version_${ecosystem}.txt`); -} - /** * @returns {SafeChainConfig} */ @@ -312,6 +248,24 @@ function getDatabaseVersionPath() { return path.join(aikidoDir, `version_${ecosystem}.txt`); } +/** + * @returns {string} + */ +export function getNewPackagesListPath() { + const safeChainDir = getSafeChainDirectory(); + const ecosystem = getEcoSystem(); + return path.join(safeChainDir, `newPackagesList_${ecosystem}.json`); +} + +/** + * @returns {string} + */ +export function getNewPackagesListVersionPath() { + const safeChainDir = getSafeChainDirectory(); + const ecosystem = getEcoSystem(); + return path.join(safeChainDir, `newPackagesList_version_${ecosystem}.txt`); +} + /** * @returns {string} */ @@ -332,7 +286,7 @@ function getConfigFilePath() { /** * @returns {string} */ -function getSafeChainDirectory() { +export function getSafeChainDirectory() { const homeDir = os.homedir(); const safeChainDir = path.join(homeDir, ".safe-chain"); diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index d9e5417..74f8a25 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -64,11 +64,11 @@ export async function main(args) { // Write all buffered logs ui.writeBufferedLogsAndStopBuffering(); - if (!proxy.verifyNoMaliciousPackages()) { + if (proxy.hasBlockedMaliciousPackages()) { return 1; } - if (!proxy.verifyNoMinimumAgeBlockedRequests()) { + if (proxy.hasBlockedMinimumAgeRequests()) { return 1; } diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js b/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js index 5d12c0e..13cb99a 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js @@ -5,16 +5,29 @@ */ export function parseNpmPackageUrl(url, registry) { let packageName, version; - const urlWithoutParams = url.split("?")[0].split("#")[0]; + let parsedUrl; - if (!registry || !urlWithoutParams.endsWith(".tgz")) { + try { + parsedUrl = new URL(url); + } catch { return { packageName, version }; } - const registryIndex = urlWithoutParams.indexOf(registry); - const afterRegistry = decodeURIComponent(urlWithoutParams.substring( - registryIndex + registry.length + 1 - )); // +1 to skip the slash + const pathname = parsedUrl.pathname; + + if (!registry || !pathname.endsWith(".tgz")) { + return { packageName, version }; + } + + const registryPrefix = `${registry}/`; + const urlAfterProtocol = `${parsedUrl.host}${pathname}`; + if (!urlAfterProtocol.startsWith(registryPrefix)) { + return { packageName, version }; + } + + const afterRegistry = decodeURIComponent( + urlAfterProtocol.substring(registryPrefix.length) + ); const separatorIndex = afterRegistry.indexOf("/-/"); if (separatorIndex === -1) { diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index 4adba61..81b265d 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -28,8 +28,8 @@ export function createSafeChainProxy() { return { startServer: () => startServer(server), stopServer: () => stopServer(server), - verifyNoMaliciousPackages, - verifyNoMinimumAgeBlockedRequests, + hasBlockedMaliciousPackages, + hasBlockedMinimumAgeRequests, hasSuppressedVersions: getHasSuppressedVersions, }; } @@ -198,10 +198,9 @@ function onMinimumAgeRequestBlocked(packageName, version, url) { state.blockedMinimumAgeRequests.push({ packageName, version, url }); } -function verifyNoMaliciousPackages() { +function hasBlockedMaliciousPackages() { if (state.blockedRequests.length === 0) { - // No malicious packages were blocked, so nothing to block - return true; + return false; } ui.emptyLine(); @@ -220,12 +219,12 @@ function verifyNoMaliciousPackages() { ui.writeExitWithoutInstallingMaliciousPackages(); ui.emptyLine(); - return false; + return true; } -function verifyNoMinimumAgeBlockedRequests() { +function hasBlockedMinimumAgeRequests() { if (state.blockedMinimumAgeRequests.length === 0) { - return true; + return false; } ui.emptyLine(); @@ -252,5 +251,5 @@ function verifyNoMinimumAgeBlockedRequests() { ); ui.emptyLine(); - return false; + return true; } diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.js b/packages/safe-chain/src/scanning/newPackagesDatabase.js index acda1e9..6a74656 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabase.js +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.js @@ -1,10 +1,11 @@ +import fs from "fs"; import { fetchNewPackagesList, fetchNewPackagesListVersion, } from "../api/aikido.js"; import { - readNewPackagesListFromLocalCache, - writeNewPackagesListToLocalCache, + getNewPackagesListPath, + getNewPackagesListVersionPath, } from "../config/configFile.js"; import { ui } from "../environment/userInteraction.js"; import { @@ -138,3 +139,49 @@ async function getNewPackagesList() { throw error; } } + +/** + * @param {import("../api/aikido.js").NewPackageEntry[]} data + * @param {string | number} version + * + * @returns {void} + */ +export function writeNewPackagesListToLocalCache(data, version) { + try { + const listPath = getNewPackagesListPath(); + const versionPath = getNewPackagesListVersionPath(); + + fs.writeFileSync(listPath, JSON.stringify(data)); + fs.writeFileSync(versionPath, version.toString()); + } catch { + ui.writeWarning( + "Failed to write new packages list to local cache, next time the list will be fetched from the server again." + ); + } +} + +/** + * @returns {{newPackagesList: import("../api/aikido.js").NewPackageEntry[] | null, version: string | null}} + */ +export function readNewPackagesListFromLocalCache() { + try { + const listPath = getNewPackagesListPath(); + if (!fs.existsSync(listPath)) { + return { newPackagesList: null, version: null }; + } + + const data = fs.readFileSync(listPath, "utf8"); + const newPackagesList = JSON.parse(data); + const versionPath = getNewPackagesListVersionPath(); + let version = null; + if (fs.existsSync(versionPath)) { + version = fs.readFileSync(versionPath, "utf8").trim(); + } + return { newPackagesList, version }; + } catch { + ui.writeWarning( + "Failed to read new packages list from local cache. Continuing without local cache." + ); + return { newPackagesList: null, version: null }; + } +} diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js index 58c9a74..29f04d5 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js @@ -1,9 +1,10 @@ import { describe, it, mock, beforeEach } from "node:test"; import assert from "node:assert"; +import fs from "fs"; +import path from "path"; +import os from "os"; // --- shared mutable state for mocks --- -let cachedList = null; -let cachedVersion = null; let fetchedList = []; let fetchedVersion = "etag-1"; let fetchVersionResult = "etag-1"; @@ -13,6 +14,7 @@ let writeWarningCalls = []; let fetchListError = null; let fetchVersionError = null; let importCounter = 0; +let testHomeDir = ""; mock.module("../api/aikido.js", { namedExports: { @@ -36,16 +38,6 @@ mock.module("../api/aikido.js", { }, }); -mock.module("../config/configFile.js", { - namedExports: { - readNewPackagesListFromLocalCache: () => ({ - newPackagesList: cachedList, - version: cachedVersion, - }), - writeNewPackagesListToLocalCache: () => {}, - }, -}); - mock.module("../environment/userInteraction.js", { namedExports: { ui: { @@ -66,8 +58,6 @@ mock.module("../config/settings.js", { describe("newPackagesDatabase", async () => { beforeEach(() => { - cachedList = null; - cachedVersion = null; fetchedList = []; fetchedVersion = "etag-1"; fetchVersionResult = "etag-1"; @@ -76,6 +66,13 @@ describe("newPackagesDatabase", async () => { writeWarningCalls = []; fetchListError = null; fetchVersionError = null; + testHomeDir = path.join( + os.tmpdir(), + `safe-chain-new-packages-db-${process.pid}-${importCounter}` + ); + fs.rmSync(testHomeDir, { recursive: true, force: true }); + fs.mkdirSync(testHomeDir, { recursive: true }); + process.env.HOME = testHomeDir; }); async function openNewPackagesDatabase() { @@ -93,6 +90,19 @@ describe("newPackagesDatabase", async () => { return Math.floor((Date.now() - hours * 3600 * 1000) / 1000); } + function writeCachedList(list, version) { + const safeChainDir = path.join(testHomeDir, ".safe-chain"); + fs.mkdirSync(safeChainDir, { recursive: true }); + fs.writeFileSync( + path.join(safeChainDir, `newPackagesList_${ecosystem}.json`), + JSON.stringify(list) + ); + fs.writeFileSync( + path.join(safeChainDir, `newPackagesList_version_${ecosystem}.txt`), + version + ); + } + describe("isNewlyReleasedPackage", () => { it("returns true for a package released within the age threshold", async () => { fetchedList = [ @@ -171,10 +181,9 @@ describe("newPackagesDatabase", async () => { describe("caching behaviour", () => { it("uses local cache when etag matches", async () => { - cachedList = [ + writeCachedList([ { package_name: "cached-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, - ]; - cachedVersion = "etag-1"; + ], "etag-1"); fetchVersionResult = "etag-1"; // fetchedList is empty — if we used the remote list, the lookup would return false fetchedList = []; @@ -184,10 +193,9 @@ describe("newPackagesDatabase", async () => { }); it("fetches fresh list when etag does not match", async () => { - cachedList = [ + writeCachedList([ { package_name: "stale-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, - ]; - cachedVersion = "etag-old"; + ], "etag-old"); fetchVersionResult = "etag-new"; fetchedList = [ { package_name: "fresh-pkg", version: "2.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, @@ -199,15 +207,14 @@ describe("newPackagesDatabase", async () => { }); it("falls back to local cache when fetch fails", async () => { - cachedList = [ + writeCachedList([ { package_name: "cached-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1), }, - ]; - cachedVersion = "etag-old"; + ], "etag-old"); fetchVersionResult = "etag-new"; fetchListError = new Error("Network error"); From 8a4f759a78c8d608e9d61f4c8a55eab6e7124f28 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 27 Mar 2026 14:25:58 -0700 Subject: [PATCH 211/360] Some cleanup --- .../interceptors/npm/modifyNpmInfo.js | 12 +-- .../interceptors/npm/npmInterceptor.js | 83 +++++++++++++++---- 2 files changed, 67 insertions(+), 28 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js index dfab97b..d8468d6 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js @@ -1,4 +1,4 @@ -import { getMinimumPackageAgeHours, getNpmMinimumPackageAgeExclusions } from "../../../config/settings.js"; +import { getMinimumPackageAgeHours } from "../../../config/settings.js"; import { ui } from "../../../environment/userInteraction.js"; import { getHeaderValueAsString } from "../../http-utils.js"; @@ -65,16 +65,6 @@ export function modifyNpmInfoResponse(body, headers) { return body; } - // Check if this package is excluded from minimum age filtering - const packageName = bodyJson.name; - const exclusions = getNpmMinimumPackageAgeExclusions(); - if (packageName && exclusions.some((pattern) => matchesExclusionPattern(packageName, pattern))) { - ui.writeVerbose( - `Safe-chain: ${packageName} is excluded from minimum package age filtering (minimumPackageAgeExclusions setting).` - ); - return body; - } - const cutOff = new Date( new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000 ); diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js index c1310bd..8a6d7eb 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js @@ -46,37 +46,86 @@ function buildNpmInterceptor(registry) { reqContext.targetUrl, registry ); + const minimumAgeChecksEnabled = !skipMinimumPackageAge(); + const packageIsExcludedFromMinimumAgeChecks = + packageName && isExcludedFromMinimumPackageAge(packageName); if (await isMalwarePackage(packageName, version)) { reqContext.blockMalware(packageName, version); return; } - if (!skipMinimumPackageAge() && isPackageInfoUrl(reqContext.targetUrl)) { + if (minimumAgeChecksEnabled && isPackageInfoUrl(reqContext.targetUrl)) { reqContext.modifyRequestHeaders(modifyNpmInfoRequestHeaders); - reqContext.modifyBody(modifyNpmInfoResponse); + reqContext.modifyBody((body, headers) => { + const metadataPackageName = getPackageNameFromMetadataResponse( + body, + headers + ); + + if ( + metadataPackageName && + isExcludedFromMinimumPackageAge(metadataPackageName) + ) { + return body; + } + + return modifyNpmInfoResponse(body, headers); + }); return; } // For tarball requests the metadata check above is skipped, so we check the // new packages list as a fallback (covers e.g. frozen-lockfile installs). - if (!skipMinimumPackageAge() && packageName && version) { - const exclusions = getNpmMinimumPackageAgeExclusions(); - const isExcluded = exclusions.some((pattern) => - matchesExclusionPattern(packageName, pattern) - ); + if ( + minimumAgeChecksEnabled && + packageName && + version && + !packageIsExcludedFromMinimumAgeChecks + ) { + const newPackagesDatabase = await openNewPackagesDatabase(); - if (!isExcluded) { - const newPackagesDatabase = await openNewPackagesDatabase(); - - if (newPackagesDatabase.isNewlyReleasedPackage(packageName, version)) { - reqContext.blockMinimumAgeRequest( - packageName, - version, - `Forbidden - blocked by safe-chain direct download minimum package age (${packageName}@${version})` - ); - } + if (newPackagesDatabase.isNewlyReleasedPackage(packageName, version)) { + reqContext.blockMinimumAgeRequest( + packageName, + version, + `Forbidden - blocked by safe-chain direct download minimum package age (${packageName}@${version})` + ); } } }); } + +/** + * @param {string} packageName + * @returns {boolean} + */ +function isExcludedFromMinimumPackageAge(packageName) { + const exclusions = getNpmMinimumPackageAgeExclusions(); + return exclusions.some((pattern) => + matchesExclusionPattern(packageName, pattern) + ); +} + +/** + * @param {Buffer} body + * @param {NodeJS.Dict | undefined} headers + * @returns {string | undefined} + */ +function getPackageNameFromMetadataResponse(body, headers) { + try { + const contentType = headers?.["content-type"]; + const normalizedContentType = Array.isArray(contentType) + ? contentType.join(",") + : contentType; + + if (!normalizedContentType?.toLowerCase().includes("application/json")) { + return undefined; + } + + const bodyJson = JSON.parse(body.toString("utf8")); + return typeof bodyJson.name === "string" ? bodyJson.name : undefined; + } catch { + return undefined; + } +} From 8133f0c97016fd00ae5544c08ae6d1bd6047ad68 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 27 Mar 2026 14:38:41 -0700 Subject: [PATCH 212/360] Some more cleanup --- .../interceptors/npm/modifyNpmInfo.js | 19 +++++++++++++ .../interceptors/npm/npmInterceptor.js | 28 ++----------------- 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js index d8468d6..a9a8c41 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js @@ -178,6 +178,25 @@ export function getHasSuppressedVersions() { return state.hasSuppressedVersions; } +/** + * @param {Buffer} body + * @param {NodeJS.Dict | undefined} headers + * @returns {string | undefined} + */ +export function getPackageNameFromMetadataResponse(body, headers) { + try { + const contentType = getHeaderValueAsString(headers, "content-type"); + if (!contentType?.toLowerCase().includes("application/json")) { + return undefined; + } + + const bodyJson = JSON.parse(body.toString("utf8")); + return typeof bodyJson.name === "string" ? bodyJson.name : undefined; + } catch { + return undefined; + } +} + /** * Checks if a package name matches an exclusion pattern. * Supports trailing wildcard (*) for prefix matching. diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js index 8a6d7eb..57e5b93 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js @@ -6,6 +6,7 @@ import { import { isMalwarePackage } from "../../../scanning/audit/index.js"; import { interceptRequests } from "../interceptorBuilder.js"; import { + getPackageNameFromMetadataResponse, isPackageInfoUrl, matchesExclusionPattern, modifyNpmInfoRequestHeaders, @@ -47,8 +48,6 @@ function buildNpmInterceptor(registry) { registry ); const minimumAgeChecksEnabled = !skipMinimumPackageAge(); - const packageIsExcludedFromMinimumAgeChecks = - packageName && isExcludedFromMinimumPackageAge(packageName); if (await isMalwarePackage(packageName, version)) { reqContext.blockMalware(packageName, version); @@ -81,7 +80,7 @@ function buildNpmInterceptor(registry) { minimumAgeChecksEnabled && packageName && version && - !packageIsExcludedFromMinimumAgeChecks + !isExcludedFromMinimumPackageAge(packageName) ) { const newPackagesDatabase = await openNewPackagesDatabase(); @@ -106,26 +105,3 @@ function isExcludedFromMinimumPackageAge(packageName) { matchesExclusionPattern(packageName, pattern) ); } - -/** - * @param {Buffer} body - * @param {NodeJS.Dict | undefined} headers - * @returns {string | undefined} - */ -function getPackageNameFromMetadataResponse(body, headers) { - try { - const contentType = headers?.["content-type"]; - const normalizedContentType = Array.isArray(contentType) - ? contentType.join(",") - : contentType; - - if (!normalizedContentType?.toLowerCase().includes("application/json")) { - return undefined; - } - - const bodyJson = JSON.parse(body.toString("utf8")); - return typeof bodyJson.name === "string" ? bodyJson.name : undefined; - } catch { - return undefined; - } -} From 3a01a92f03605ee55e2aa0fe173c8e22ce587476 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 27 Mar 2026 15:14:13 -0700 Subject: [PATCH 213/360] Code Quality --- .../interceptors/npm/npmInterceptor.js | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js index 57e5b93..2a41524 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js @@ -56,21 +56,7 @@ function buildNpmInterceptor(registry) { if (minimumAgeChecksEnabled && isPackageInfoUrl(reqContext.targetUrl)) { reqContext.modifyRequestHeaders(modifyNpmInfoRequestHeaders); - reqContext.modifyBody((body, headers) => { - const metadataPackageName = getPackageNameFromMetadataResponse( - body, - headers - ); - - if ( - metadataPackageName && - isExcludedFromMinimumPackageAge(metadataPackageName) - ) { - return body; - } - - return modifyNpmInfoResponse(body, headers); - }); + reqContext.modifyBody(modifyNpmInfoResponseUnlessExcluded); return; } @@ -105,3 +91,21 @@ function isExcludedFromMinimumPackageAge(packageName) { matchesExclusionPattern(packageName, pattern) ); } + +/** + * @param {Buffer} body + * @param {NodeJS.Dict | undefined} headers + * @returns {Buffer} + */ +function modifyNpmInfoResponseUnlessExcluded(body, headers) { + const metadataPackageName = getPackageNameFromMetadataResponse(body, headers); + + if ( + metadataPackageName && + isExcludedFromMinimumPackageAge(metadataPackageName) + ) { + return body; + } + + return modifyNpmInfoResponse(body, headers); +} From 5b1cd7e8da858e4d661e4656621f12e2a303a83d Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Fri, 27 Mar 2026 15:52:07 -0700 Subject: [PATCH 214/360] Split up newPackagesDatabse into builder, warnigns, cache --- .../interceptors/npm/npmInterceptor.js | 2 +- .../npm/npmInterceptor.minPackageAge.spec.js | 2 +- .../npmInterceptor.packageDownload.spec.js | 2 +- .../src/scanning/newPackagesDatabase.spec.js | 10 +- .../scanning/newPackagesDatabaseBuilder.js | 63 +++++++ .../newPackagesDatabaseBuilder.spec.js | 100 ++++++++++ .../scanning/newPackagesDatabaseWarnings.js | 16 ++ .../newPackagesDatabaseWarnings.spec.js | 63 +++++++ ...gesDatabase.js => newPackagesListCache.js} | 67 +------ .../src/scanning/newPackagesListCache.spec.js | 175 ++++++++++++++++++ 10 files changed, 434 insertions(+), 66 deletions(-) create mode 100644 packages/safe-chain/src/scanning/newPackagesDatabaseBuilder.js create mode 100644 packages/safe-chain/src/scanning/newPackagesDatabaseBuilder.spec.js create mode 100644 packages/safe-chain/src/scanning/newPackagesDatabaseWarnings.js create mode 100644 packages/safe-chain/src/scanning/newPackagesDatabaseWarnings.spec.js rename packages/safe-chain/src/scanning/{newPackagesDatabase.js => newPackagesListCache.js} (68%) create mode 100644 packages/safe-chain/src/scanning/newPackagesListCache.spec.js diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js index 2a41524..f4e4e1b 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js @@ -13,7 +13,7 @@ import { modifyNpmInfoResponse, } from "./modifyNpmInfo.js"; import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js"; -import { openNewPackagesDatabase } from "../../../scanning/newPackagesDatabase.js"; +import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache.js"; const knownJsRegistries = [ "registry.npmjs.org", diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js index 45d3ceb..de7acc6 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js @@ -18,7 +18,7 @@ describe("npmInterceptor minimum package age", async () => { getEcoSystem: () => "js", }, }); - mock.module("../../../scanning/newPackagesDatabase.js", { + mock.module("../../../scanning/newPackagesListCache.js", { namedExports: { openNewPackagesDatabase: async () => ({ isNewlyReleasedPackage: (name, version) => diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js index 0c4b377..e361275 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js @@ -32,7 +32,7 @@ mock.module("../../../config/settings.js", { skipMinimumPackageAge: () => skipMinimumPackageAgeSetting, }, }); -mock.module("../../../scanning/newPackagesDatabase.js", { +mock.module("../../../scanning/newPackagesListCache.js", { namedExports: { openNewPackagesDatabase: async () => ({ isNewlyReleasedPackage: (name, version) => diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js index 29f04d5..4aad9ef 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js @@ -56,6 +56,11 @@ mock.module("../config/settings.js", { }, }); +// Import the warnings module so we can reset its state between tests. +// The state (hasWarnedAboutUnavailableNewPackagesDatabase) lives in a separate +// module and is not reset by the dynamic-import cache-buster trick used below. +const { resetWarningState } = await import("./newPackagesDatabaseWarnings.js"); + describe("newPackagesDatabase", async () => { beforeEach(() => { fetchedList = []; @@ -66,6 +71,7 @@ describe("newPackagesDatabase", async () => { writeWarningCalls = []; fetchListError = null; fetchVersionError = null; + resetWarningState(); testHomeDir = path.join( os.tmpdir(), `safe-chain-new-packages-db-${process.pid}-${importCounter}` @@ -77,13 +83,13 @@ describe("newPackagesDatabase", async () => { async function openNewPackagesDatabase() { const module = await import( - `./newPackagesDatabase.js?test_case=${importCounter++}` + `./newPackagesListCache.js?test_case=${importCounter++}` ); return module.openNewPackagesDatabase(); } async function loadNewPackagesDatabaseModule() { - return import(`./newPackagesDatabase.js?test_case=${importCounter++}`); + return import(`./newPackagesListCache.js?test_case=${importCounter++}`); } function hoursAgo(hours) { diff --git a/packages/safe-chain/src/scanning/newPackagesDatabaseBuilder.js b/packages/safe-chain/src/scanning/newPackagesDatabaseBuilder.js new file mode 100644 index 0000000..6db4a66 --- /dev/null +++ b/packages/safe-chain/src/scanning/newPackagesDatabaseBuilder.js @@ -0,0 +1,63 @@ +import { + getMinimumPackageAgeHours, + getEcoSystem, + ECOSYSTEM_JS, + ECOSYSTEM_PY, +} from "../config/settings.js"; + +/** + * @typedef {Object} NewPackagesDatabase + * @property {function(string, string): boolean} isNewlyReleasedPackage + */ + +/** + * Returns the ecosystem identifier expected in upstream/core release feeds. + * @returns {string} + */ +function getCurrentFeedSource() { + const ecosystem = getEcoSystem(); + + if (ecosystem === ECOSYSTEM_JS) { + return "npm"; + } + + if (ecosystem === ECOSYSTEM_PY) { + return "pypi"; + } + + return ecosystem; +} + +/** + * @param {import("../api/aikido.js").NewPackageEntry[]} newPackagesList + * @returns {NewPackagesDatabase} + */ +export function buildNewPackagesDatabase(newPackagesList) { + /** + * @param {string} name + * @param {string} version + * @returns {boolean} + */ + function isNewlyReleasedPackage(name, version) { + const cutOff = new Date( + new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000 + ); + const expectedSource = getCurrentFeedSource(); + + const entry = newPackagesList.find( + (pkg) => + (!pkg.source || pkg.source.toLowerCase() === expectedSource) && + pkg.package_name === name && + pkg.version === version + ); + + if (!entry) { + return false; + } + + const releasedOn = new Date(entry.released_on * 1000); + return releasedOn > cutOff; + } + + return { isNewlyReleasedPackage }; +} diff --git a/packages/safe-chain/src/scanning/newPackagesDatabaseBuilder.spec.js b/packages/safe-chain/src/scanning/newPackagesDatabaseBuilder.spec.js new file mode 100644 index 0000000..0c2fb84 --- /dev/null +++ b/packages/safe-chain/src/scanning/newPackagesDatabaseBuilder.spec.js @@ -0,0 +1,100 @@ +import { describe, it, mock } from "node:test"; +import assert from "node:assert"; + +let minimumPackageAgeHours = 24; +let ecosystem = "js"; + +mock.module("../config/settings.js", { + namedExports: { + getMinimumPackageAgeHours: () => minimumPackageAgeHours, + getEcoSystem: () => ecosystem, + ECOSYSTEM_JS: "js", + ECOSYSTEM_PY: "py", + }, +}); + +const { buildNewPackagesDatabase } = await import( + "./newPackagesDatabaseBuilder.js" +); + +function hoursAgo(hours) { + return Math.floor((Date.now() - hours * 3600 * 1000) / 1000); +} + +describe("buildNewPackagesDatabase", () => { + it("returns an object with isNewlyReleasedPackage", () => { + const db = buildNewPackagesDatabase([]); + assert.strictEqual(typeof db.isNewlyReleasedPackage, "function"); + }); + + describe("isNewlyReleasedPackage", () => { + it("returns true for a package released within the age threshold", () => { + const db = buildNewPackagesDatabase([ + { package_name: "foo", version: "1.0.0", released_on: hoursAgo(1) }, + ]); + + assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true); + }); + + it("returns false for a package released outside the age threshold", () => { + const db = buildNewPackagesDatabase([ + { package_name: "foo", version: "1.0.0", released_on: hoursAgo(48) }, + ]); + + assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false); + }); + + it("returns false for a package not in the list", () => { + const db = buildNewPackagesDatabase([]); + + assert.strictEqual(db.isNewlyReleasedPackage("not-there", "1.0.0"), false); + }); + + it("returns false for a known package but different version", () => { + const db = buildNewPackagesDatabase([ + { package_name: "foo", version: "2.0.0", released_on: hoursAgo(1) }, + ]); + + assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false); + }); + + it("filters by source when source metadata is present", () => { + const db = buildNewPackagesDatabase([ + { source: "pypi", package_name: "foo", version: "1.0.0", released_on: hoursAgo(1) }, + { source: "npm", package_name: "bar", version: "1.0.0", released_on: hoursAgo(1) }, + ]); + + // ecosystem is "js" → feed source is "npm" + assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false); + assert.strictEqual(db.isNewlyReleasedPackage("bar", "1.0.0"), true); + }); + + it("matches regardless of source case", () => { + const db = buildNewPackagesDatabase([ + { source: "NPM", package_name: "foo", version: "1.0.0", released_on: hoursAgo(1) }, + ]); + + assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true); + }); + + it("matches entries with no source field", () => { + const db = buildNewPackagesDatabase([ + { package_name: "foo", version: "1.0.0", released_on: hoursAgo(1) }, + ]); + + assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true); + }); + + it("respects a custom minimumPackageAgeHours threshold", () => { + minimumPackageAgeHours = 168; // 7 days + + const db = buildNewPackagesDatabase([ + { package_name: "foo", version: "1.0.0", released_on: hoursAgo(100) }, + ]); + + assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true); + + minimumPackageAgeHours = 24; // reset + }); + }); +}); diff --git a/packages/safe-chain/src/scanning/newPackagesDatabaseWarnings.js b/packages/safe-chain/src/scanning/newPackagesDatabaseWarnings.js new file mode 100644 index 0000000..684177b --- /dev/null +++ b/packages/safe-chain/src/scanning/newPackagesDatabaseWarnings.js @@ -0,0 +1,16 @@ +import { ui } from "../environment/userInteraction.js"; + +let hasWarnedAboutUnavailableNewPackagesDatabase = false; + +export function warnOnceAboutUnavailableDatabase(error) { + if (!hasWarnedAboutUnavailableNewPackagesDatabase) { + ui.writeWarning( + `Failed to load the new packages list used for direct package download request blocking. Continuing with metadata-based minimum age checks only. ${error.message}` + ); + hasWarnedAboutUnavailableNewPackagesDatabase = true; + } +} + +export function resetWarningState() { + hasWarnedAboutUnavailableNewPackagesDatabase = false; +} diff --git a/packages/safe-chain/src/scanning/newPackagesDatabaseWarnings.spec.js b/packages/safe-chain/src/scanning/newPackagesDatabaseWarnings.spec.js new file mode 100644 index 0000000..d36d5df --- /dev/null +++ b/packages/safe-chain/src/scanning/newPackagesDatabaseWarnings.spec.js @@ -0,0 +1,63 @@ +import { describe, it, mock, beforeEach } from "node:test"; +import assert from "node:assert"; + +let writeWarningCalls = []; + +mock.module("../environment/userInteraction.js", { + namedExports: { + ui: { + writeWarning: (msg) => writeWarningCalls.push(msg), + }, + }, +}); + +const { warnOnceAboutUnavailableDatabase, resetWarningState } = await import( + "./newPackagesDatabaseWarnings.js" +); + +describe("newPackagesDatabaseWarnings", () => { + beforeEach(() => { + writeWarningCalls = []; + resetWarningState(); + }); + + describe("warnOnceAboutUnavailableDatabase", () => { + it("emits a warning containing the error message", () => { + warnOnceAboutUnavailableDatabase(new Error("feed unavailable")); + + assert.strictEqual(writeWarningCalls.length, 1); + assert.ok(writeWarningCalls[0].includes("feed unavailable")); + }); + + it("mentions fallback to metadata-based checks in the warning", () => { + warnOnceAboutUnavailableDatabase(new Error("timeout")); + + assert.ok( + writeWarningCalls[0].includes( + "Continuing with metadata-based minimum age checks only" + ) + ); + }); + + it("only emits once even when called multiple times", () => { + warnOnceAboutUnavailableDatabase(new Error("first")); + warnOnceAboutUnavailableDatabase(new Error("second")); + warnOnceAboutUnavailableDatabase(new Error("third")); + + assert.strictEqual(writeWarningCalls.length, 1); + }); + }); + + describe("resetWarningState", () => { + it("allows the warning to fire again after reset", () => { + warnOnceAboutUnavailableDatabase(new Error("first")); + assert.strictEqual(writeWarningCalls.length, 1); + + resetWarningState(); + writeWarningCalls = []; + + warnOnceAboutUnavailableDatabase(new Error("second")); + assert.strictEqual(writeWarningCalls.length, 1); + }); + }); +}); diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.js b/packages/safe-chain/src/scanning/newPackagesListCache.js similarity index 68% rename from packages/safe-chain/src/scanning/newPackagesDatabase.js rename to packages/safe-chain/src/scanning/newPackagesListCache.js index 6a74656..f7496b6 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabase.js +++ b/packages/safe-chain/src/scanning/newPackagesListCache.js @@ -8,40 +8,17 @@ import { getNewPackagesListVersionPath, } from "../config/configFile.js"; import { ui } from "../environment/userInteraction.js"; -import { - getMinimumPackageAgeHours, - getEcoSystem, - ECOSYSTEM_JS, - ECOSYSTEM_PY, -} from "../config/settings.js"; +import { getEcoSystem, ECOSYSTEM_JS } from "../config/settings.js"; +import { buildNewPackagesDatabase } from "./newPackagesDatabaseBuilder.js"; +import { warnOnceAboutUnavailableDatabase } from "./newPackagesDatabaseWarnings.js"; /** - * @typedef {Object} NewPackagesDatabase - * @property {function(string, string): boolean} isNewlyReleasedPackage + * @typedef {import("./newPackagesDatabaseBuilder.js").NewPackagesDatabase} NewPackagesDatabase */ // Shared per-process cache to avoid rebuilding the same feed-backed database on each request. /** @type {NewPackagesDatabase | null} */ let cachedNewPackagesDatabase = null; -let hasWarnedAboutUnavailableNewPackagesDatabase = false; - -/** - * Returns the ecosystem identifier expected in upstream/core release feeds. - * @returns {string} - */ -function getCurrentFeedSource() { - const ecosystem = getEcoSystem(); - - if (ecosystem === ECOSYSTEM_JS) { - return "npm"; - } - - if (ecosystem === ECOSYSTEM_PY) { - return "pypi"; - } - - return ecosystem; -} /** * @returns {Promise} @@ -62,44 +39,12 @@ export async function openNewPackagesDatabase() { try { newPackagesList = await getNewPackagesList(); } catch (/** @type {any} */ error) { - if (!hasWarnedAboutUnavailableNewPackagesDatabase) { - ui.writeWarning( - `Failed to load the new packages list used for direct package download request blocking. Continuing with metadata-based minimum age checks only. ${error.message}` - ); - hasWarnedAboutUnavailableNewPackagesDatabase = true; - } - + warnOnceAboutUnavailableDatabase(error); cachedNewPackagesDatabase = { isNewlyReleasedPackage: () => false }; return cachedNewPackagesDatabase; } - /** - * @param {string} name - * @param {string} version - * @returns {boolean} - */ - function isNewlyReleasedPackage(name, version) { - const cutOff = new Date( - new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000 - ); - const expectedSource = getCurrentFeedSource(); - - const entry = newPackagesList.find( - (pkg) => - (!pkg.source || pkg.source.toLowerCase() === expectedSource) && - pkg.package_name === name && - pkg.version === version - ); - - if (!entry) { - return false; - } - - const releasedOn = new Date(entry.released_on * 1000); - return releasedOn > cutOff; - } - - cachedNewPackagesDatabase = { isNewlyReleasedPackage }; + cachedNewPackagesDatabase = buildNewPackagesDatabase(newPackagesList); return cachedNewPackagesDatabase; } diff --git a/packages/safe-chain/src/scanning/newPackagesListCache.spec.js b/packages/safe-chain/src/scanning/newPackagesListCache.spec.js new file mode 100644 index 0000000..12e375d --- /dev/null +++ b/packages/safe-chain/src/scanning/newPackagesListCache.spec.js @@ -0,0 +1,175 @@ +import { describe, it, mock, beforeEach } from "node:test"; +import assert from "node:assert"; +import fs from "fs"; +import path from "path"; +import os from "os"; + +let writeWarningCalls = []; +let ecosystem = "js"; +let testHomeDir = ""; + +mock.module("../environment/userInteraction.js", { + namedExports: { + ui: { + writeWarning: (msg) => writeWarningCalls.push(msg), + }, + }, +}); + +mock.module("../config/settings.js", { + namedExports: { + getEcoSystem: () => ecosystem, + getMinimumPackageAgeHours: () => 24, + ECOSYSTEM_JS: "js", + ECOSYSTEM_PY: "py", + }, +}); + +const { readNewPackagesListFromLocalCache, writeNewPackagesListToLocalCache } = + await import("./newPackagesListCache.js"); + +describe("newPackagesListCache", () => { + beforeEach(() => { + writeWarningCalls = []; + ecosystem = "js"; + testHomeDir = path.join( + os.tmpdir(), + `safe-chain-list-cache-${process.pid}-${Date.now()}` + ); + fs.rmSync(testHomeDir, { recursive: true, force: true }); + fs.mkdirSync(testHomeDir, { recursive: true }); + process.env.HOME = testHomeDir; + }); + + describe("readNewPackagesListFromLocalCache", () => { + it("returns null for both fields when no cache file exists", () => { + const result = readNewPackagesListFromLocalCache(); + + assert.deepStrictEqual(result, { newPackagesList: null, version: null }); + }); + + it("returns the list and version when both files exist", () => { + const list = [{ package_name: "foo", version: "1.0.0" }]; + const safeChainDir = path.join(testHomeDir, ".safe-chain"); + fs.mkdirSync(safeChainDir, { recursive: true }); + fs.writeFileSync( + path.join(safeChainDir, "newPackagesList_js.json"), + JSON.stringify(list) + ); + fs.writeFileSync( + path.join(safeChainDir, "newPackagesList_version_js.txt"), + "etag-42" + ); + + const result = readNewPackagesListFromLocalCache(); + + assert.deepStrictEqual(result.newPackagesList, list); + assert.strictEqual(result.version, "etag-42"); + }); + + it("returns null version when version file is missing", () => { + const list = [{ package_name: "foo", version: "1.0.0" }]; + const safeChainDir = path.join(testHomeDir, ".safe-chain"); + fs.mkdirSync(safeChainDir, { recursive: true }); + fs.writeFileSync( + path.join(safeChainDir, "newPackagesList_js.json"), + JSON.stringify(list) + ); + + const result = readNewPackagesListFromLocalCache(); + + assert.deepStrictEqual(result.newPackagesList, list); + assert.strictEqual(result.version, null); + }); + + it("trims whitespace from the version string", () => { + const safeChainDir = path.join(testHomeDir, ".safe-chain"); + fs.mkdirSync(safeChainDir, { recursive: true }); + fs.writeFileSync( + path.join(safeChainDir, "newPackagesList_js.json"), + JSON.stringify([]) + ); + fs.writeFileSync( + path.join(safeChainDir, "newPackagesList_version_js.txt"), + " etag-trimmed \n" + ); + + const { version } = readNewPackagesListFromLocalCache(); + + assert.strictEqual(version, "etag-trimmed"); + }); + + it("uses the ecosystem name in the file path", () => { + ecosystem = "py"; + const safeChainDir = path.join(testHomeDir, ".safe-chain"); + fs.mkdirSync(safeChainDir, { recursive: true }); + fs.writeFileSync( + path.join(safeChainDir, "newPackagesList_py.json"), + JSON.stringify([{ package_name: "requests", version: "2.0.0" }]) + ); + + const result = readNewPackagesListFromLocalCache(); + + assert.ok(result.newPackagesList !== null); + }); + + it("warns and returns nulls when the list file contains invalid JSON", () => { + const safeChainDir = path.join(testHomeDir, ".safe-chain"); + fs.mkdirSync(safeChainDir, { recursive: true }); + fs.writeFileSync( + path.join(safeChainDir, "newPackagesList_js.json"), + "not-valid-json" + ); + + const result = readNewPackagesListFromLocalCache(); + + assert.deepStrictEqual(result, { newPackagesList: null, version: null }); + assert.strictEqual(writeWarningCalls.length, 1); + assert.ok(writeWarningCalls[0].includes("local cache")); + }); + }); + + describe("writeNewPackagesListToLocalCache", () => { + it("writes the list and version to disk", () => { + const safeChainDir = path.join(testHomeDir, ".safe-chain"); + fs.mkdirSync(safeChainDir, { recursive: true }); + + const list = [{ package_name: "foo", version: "1.0.0" }]; + writeNewPackagesListToLocalCache(list, "etag-99"); + + const writtenList = JSON.parse( + fs.readFileSync(path.join(safeChainDir, "newPackagesList_js.json"), "utf8") + ); + const writtenVersion = fs.readFileSync( + path.join(safeChainDir, "newPackagesList_version_js.txt"), + "utf8" + ); + + assert.deepStrictEqual(writtenList, list); + assert.strictEqual(writtenVersion, "etag-99"); + }); + + it("converts a numeric version to a string", () => { + const safeChainDir = path.join(testHomeDir, ".safe-chain"); + fs.mkdirSync(safeChainDir, { recursive: true }); + + writeNewPackagesListToLocalCache([], 42); + + const written = fs.readFileSync( + path.join(safeChainDir, "newPackagesList_version_js.txt"), + "utf8" + ); + assert.strictEqual(written, "42"); + }); + + it("warns when writing fails", () => { + // Point HOME at a non-existent path so the write will fail + process.env.HOME = path.join(testHomeDir, "does-not-exist"); + + writeNewPackagesListToLocalCache([], "etag-fail"); + + assert.strictEqual(writeWarningCalls.length, 1); + assert.ok(writeWarningCalls[0].includes("local cache")); + }); + }); +}); From faf0ba898cb5cb7aea74275721b9ea80d730e249 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Fri, 27 Mar 2026 15:54:30 -0700 Subject: [PATCH 215/360] Apply suggestions from code review Co-authored-by: bitterpanda --- packages/safe-chain/src/scanning/newPackagesDatabase.spec.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js index 4aad9ef..c3c475f 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js @@ -57,8 +57,6 @@ mock.module("../config/settings.js", { }); // Import the warnings module so we can reset its state between tests. -// The state (hasWarnedAboutUnavailableNewPackagesDatabase) lives in a separate -// module and is not reset by the dynamic-import cache-buster trick used below. const { resetWarningState } = await import("./newPackagesDatabaseWarnings.js"); describe("newPackagesDatabase", async () => { From 10c078a9930239070f1be8d53e8692fc8cb6db8d Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Fri, 27 Mar 2026 16:09:04 -0700 Subject: [PATCH 216/360] fix broken test case for newPackagesListCache --- .../safe-chain/src/scanning/newPackagesListCache.spec.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/safe-chain/src/scanning/newPackagesListCache.spec.js b/packages/safe-chain/src/scanning/newPackagesListCache.spec.js index 12e375d..8616876 100644 --- a/packages/safe-chain/src/scanning/newPackagesListCache.spec.js +++ b/packages/safe-chain/src/scanning/newPackagesListCache.spec.js @@ -163,8 +163,10 @@ describe("newPackagesListCache", () => { }); it("warns when writing fails", () => { - // Point HOME at a non-existent path so the write will fail - process.env.HOME = path.join(testHomeDir, "does-not-exist"); + // Place a regular file at the .safe-chain path so getSafeChainDirectory + // returns it as-is (existsSync is true) but writing a child path fails. + const safeChainPath = path.join(testHomeDir, ".safe-chain"); + fs.writeFileSync(safeChainPath, "not-a-directory"); writeNewPackagesListToLocalCache([], "etag-fail"); From 77659efe1fc4aebaa38f7b5ac7a804049a3925e3 Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Fri, 27 Mar 2026 16:10:18 -0700 Subject: [PATCH 217/360] remove mentions of scraped_on field from types & test cases --- packages/safe-chain/src/api/aikido.spec.js | 1 - .../src/scanning/newPackagesDatabase.spec.js | 19 ++++++++----------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/safe-chain/src/api/aikido.spec.js b/packages/safe-chain/src/api/aikido.spec.js index 0d3a964..0c6c7d9 100644 --- a/packages/safe-chain/src/api/aikido.spec.js +++ b/packages/safe-chain/src/api/aikido.spec.js @@ -144,7 +144,6 @@ describe("aikido API", async () => { package_name: "fresh-pkg", version: "1.0.0", released_on: 123, - scraped_on: 456, }, ]; mockFetch.mock.mockImplementationOnce(() => ({ diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js index 29f04d5..e83df62 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js @@ -106,7 +106,7 @@ describe("newPackagesDatabase", async () => { describe("isNewlyReleasedPackage", () => { it("returns true for a package released within the age threshold", async () => { fetchedList = [ - { package_name: "foo", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + { package_name: "foo", version: "1.0.0", released_on: hoursAgo(1) }, ]; const db = await openNewPackagesDatabase(); @@ -115,7 +115,7 @@ describe("newPackagesDatabase", async () => { it("returns false for a package released outside the age threshold", async () => { fetchedList = [ - { package_name: "foo", version: "1.0.0", released_on: hoursAgo(48), scraped_on: hoursAgo(48) }, + { package_name: "foo", version: "1.0.0", released_on: hoursAgo(48) }, ]; const db = await openNewPackagesDatabase(); @@ -131,7 +131,7 @@ describe("newPackagesDatabase", async () => { it("returns false for a known package but different version", async () => { fetchedList = [ - { package_name: "foo", version: "2.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + { package_name: "foo", version: "2.0.0", released_on: hoursAgo(1) }, ]; const db = await openNewPackagesDatabase(); @@ -145,14 +145,12 @@ describe("newPackagesDatabase", async () => { package_name: "foo", version: "1.0.0", released_on: hoursAgo(1), - scraped_on: hoursAgo(1), }, { source: "npm", package_name: "bar", version: "1.0.0", released_on: hoursAgo(1), - scraped_on: hoursAgo(1), }, ]; @@ -165,7 +163,7 @@ describe("newPackagesDatabase", async () => { it("respects a custom minimumPackageAgeHours threshold", async () => { minimumPackageAgeHours = 168; // 7 days fetchedList = [ - { package_name: "foo", version: "1.0.0", released_on: hoursAgo(100), scraped_on: hoursAgo(100) }, + { package_name: "foo", version: "1.0.0", released_on: hoursAgo(100) }, ]; const db = await openNewPackagesDatabase(); @@ -182,7 +180,7 @@ describe("newPackagesDatabase", async () => { describe("caching behaviour", () => { it("uses local cache when etag matches", async () => { writeCachedList([ - { package_name: "cached-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + { package_name: "cached-pkg", version: "1.0.0", released_on: hoursAgo(1) }, ], "etag-1"); fetchVersionResult = "etag-1"; // fetchedList is empty — if we used the remote list, the lookup would return false @@ -194,11 +192,11 @@ describe("newPackagesDatabase", async () => { it("fetches fresh list when etag does not match", async () => { writeCachedList([ - { package_name: "stale-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + { package_name: "stale-pkg", version: "1.0.0", released_on: hoursAgo(1) }, ], "etag-old"); fetchVersionResult = "etag-new"; fetchedList = [ - { package_name: "fresh-pkg", version: "2.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + { package_name: "fresh-pkg", version: "2.0.0", released_on: hoursAgo(1) }, ]; const db = await openNewPackagesDatabase(); @@ -212,7 +210,6 @@ describe("newPackagesDatabase", async () => { package_name: "cached-pkg", version: "1.0.0", released_on: hoursAgo(1), - scraped_on: hoursAgo(1), }, ], "etag-old"); fetchVersionResult = "etag-new"; @@ -227,7 +224,7 @@ describe("newPackagesDatabase", async () => { it("emits a warning when list has no version (cannot be cached)", async () => { fetchedList = [ - { package_name: "foo", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, + { package_name: "foo", version: "1.0.0", released_on: hoursAgo(1) }, ]; fetchedVersion = undefined; From 4b21ba27099d93c13ec278556fec6a060b775910 Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Fri, 27 Mar 2026 16:12:15 -0700 Subject: [PATCH 218/360] Fix ts error --- packages/safe-chain/src/scanning/newPackagesDatabaseWarnings.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/safe-chain/src/scanning/newPackagesDatabaseWarnings.js b/packages/safe-chain/src/scanning/newPackagesDatabaseWarnings.js index 684177b..fd742bb 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabaseWarnings.js +++ b/packages/safe-chain/src/scanning/newPackagesDatabaseWarnings.js @@ -2,6 +2,7 @@ import { ui } from "../environment/userInteraction.js"; let hasWarnedAboutUnavailableNewPackagesDatabase = false; +/** @param {Error} error */ export function warnOnceAboutUnavailableDatabase(error) { if (!hasWarnedAboutUnavailableNewPackagesDatabase) { ui.writeWarning( From fd6fb456b47751149ab47d4e2f586d6b7c1f3a6a Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Sat, 28 Mar 2026 10:15:13 -0700 Subject: [PATCH 219/360] Add minimum package age check for pypi --- README.md | 24 ++-- packages/safe-chain/src/config/configFile.js | 13 +- .../src/config/environmentVariables.js | 5 +- packages/safe-chain/src/config/settings.js | 6 +- .../safe-chain/src/config/settings.spec.js | 62 ++++++-- .../createInterceptorForEcoSystem.js | 2 +- .../minimumPackageAgeExclusions.js | 33 +++++ .../interceptors/npm/modifyNpmInfo.js | 14 -- .../interceptors/npm/npmInterceptor.js | 16 +-- .../npm/npmInterceptor.minPackageAge.spec.js | 2 +- .../npmInterceptor.packageDownload.spec.js | 2 +- .../interceptors/pip/parsePipPackageUrl.js | 64 +++++++++ .../pipInterceptor.customRegistries.spec.js} | 70 ++++------ .../interceptors/pip/pipInterceptor.js | 80 +++++++++++ .../pip/pipInterceptor.minPackageAge.spec.js | 103 ++++++++++++++ .../pipInterceptor.packageDownload.spec.js} | 51 +++---- .../interceptors/pipInterceptor.js | 132 ------------------ .../src/scanning/newPackagesDatabase.spec.js | 12 +- .../scanning/newPackagesDatabaseBuilder.js | 16 ++- .../newPackagesDatabaseBuilder.spec.js | 58 ++++++++ .../src/scanning/newPackagesListCache.js | 6 - .../src/scanning/packageNameVariants.js | 18 +++ 22 files changed, 516 insertions(+), 273 deletions(-) create mode 100644 packages/safe-chain/src/registryProxy/interceptors/minimumPackageAgeExclusions.js create mode 100644 packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js rename packages/safe-chain/src/registryProxy/interceptors/{pipInterceptor.pipCustomRegistries.spec.js => pip/pipInterceptor.customRegistries.spec.js} (75%) create mode 100644 packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js create mode 100644 packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.minPackageAge.spec.js rename packages/safe-chain/src/registryProxy/interceptors/{pipInterceptor.spec.js => pip/pipInterceptor.packageDownload.spec.js} (83%) delete mode 100644 packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js create mode 100644 packages/safe-chain/src/scanning/packageNameVariants.js diff --git a/README.md b/README.md index 9b1b04e..e173b66 100644 --- a/README.md +++ b/README.md @@ -111,17 +111,20 @@ safe-chain --version The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, uv, poetry or pipx commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. -### Minimum package age (npm only) +### Minimum package age -For npm packages, Safe Chain applies minimum package age checks in two ways: +Safe Chain applies minimum package age checks to supported ecosystems. -- During normal package resolution, Safe Chain suppresses versions that are newer than the configured minimum age from the package metadata returned by the registry. -- For direct package download requests that bypass that metadata flow, Safe Chain can block the request itself using a cached list of newly released packages. +Current enforcement differs by ecosystem: + +- npm-based package managers: + - during normal package resolution, Safe Chain suppresses versions that are newer than the configured minimum age from the package metadata returned by the registry + - for direct package download requests that bypass that metadata flow, Safe Chain can block the request itself using a cached list of newly released packages +- Python package managers: + - Safe Chain blocks direct package download requests using a cached list of newly released packages By default, the minimum package age is 48 hours. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can configure this threshold or bypass this protection entirely - see the [Minimum Package Age Configuration](#minimum-package-age) section below. -⚠️ This feature **only applies to npm-based package managers** (npm, npx, yarn, pnpm, pnpx, bun, bunx) and does not apply to Python package managers (uv, pip, pip3, poetry, pipx). - ### Shell Integration The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and Python package managers (pip, uv, poetry, pipx). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support: @@ -188,13 +191,15 @@ You can set the logging level through multiple sources (in order of priority): ## Minimum Package Age -You can configure how long packages must exist before Safe Chain allows their installation. By default, packages must be at least 48 hours old before they can be installed through npm-based package managers. +You can configure how long packages must exist before Safe Chain allows their installation. By default, packages must be at least 48 hours old before they can be installed. For npm-based package managers, this check currently has two enforcement modes: - Safe Chain suppresses too-young versions from package metadata during normal dependency resolution. - Safe Chain blocks direct package download requests when they are matched against the cached newly released packages list. +For Python package managers, Safe Chain currently enforces minimum package age by blocking direct package download requests when they are matched against the cached newly released packages list. + ### Configuration Options You can set the minimum package age through multiple sources (in order of priority): @@ -225,13 +230,16 @@ You can set the minimum package age through multiple sources (in order of priori Exclude trusted packages from minimum age filtering via environment variable or config file (both are merged). Use `@scope/*` to trust all packages from an organization: ```shell -export SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS="@aikidosec/*" +export SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS="@aikidosec/*" ``` ```json { "npm": { "minimumPackageAgeExclusions": ["@aikidosec/*"] + }, + "pip": { + "minimumPackageAgeExclusions": ["requests"] } } ``` diff --git a/packages/safe-chain/src/config/configFile.js b/packages/safe-chain/src/config/configFile.js index b421fde..e132c90 100644 --- a/packages/safe-chain/src/config/configFile.js +++ b/packages/safe-chain/src/config/configFile.js @@ -129,18 +129,21 @@ export function getPipCustomRegistries() { } /** - * Gets the minimum package age exclusions from the config file + * Gets the minimum package age exclusions from the config file for the current ecosystem * @returns {string[]} */ -export function getNpmMinimumPackageAgeExclusions() { +export function getMinimumPackageAgeExclusions() { const config = readConfigFile(); + const ecosystem = getEcoSystem(); + const registryConfig = ecosystem === "py" ? config.pip : config.npm; - if (!config || !config.npm) { + if (!config || !registryConfig) { return []; } - const npmConfig = /** @type {SafeChainRegistryConfiguration} */ (config.npm); - const exclusions = npmConfig.minimumPackageAgeExclusions; + const typedRegistryConfig = + /** @type {SafeChainRegistryConfiguration} */ (registryConfig); + const exclusions = typedRegistryConfig.minimumPackageAgeExclusions; if (!Array.isArray(exclusions)) { return []; diff --git a/packages/safe-chain/src/config/environmentVariables.js b/packages/safe-chain/src/config/environmentVariables.js index 8a44841..6ed041f 100644 --- a/packages/safe-chain/src/config/environmentVariables.js +++ b/packages/safe-chain/src/config/environmentVariables.js @@ -41,6 +41,7 @@ export function getLoggingLevel() { * Example: "react,@aikidosec/safe-chain,lodash" * @returns {string | undefined} */ -export function getNpmMinimumPackageAgeExclusions() { - return process.env.SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS; +export function getMinimumPackageAgeExclusions() { + return process.env.SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS || + process.env.SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS; } diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index 7919d87..b864bf9 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -188,11 +188,11 @@ function parseExclusionsFromEnv(envValue) { * Gets the minimum package age exclusions from both environment variable and config file (merged) * @returns {string[]} */ -export function getNpmMinimumPackageAgeExclusions() { +export function getMinimumPackageAgeExclusions() { const envExclusions = parseExclusionsFromEnv( - environmentVariables.getNpmMinimumPackageAgeExclusions() + environmentVariables.getMinimumPackageAgeExclusions() ); - const configExclusions = configFile.getNpmMinimumPackageAgeExclusions(); + const configExclusions = configFile.getMinimumPackageAgeExclusions(); // Merge both sources and remove duplicates const allExclusions = [...envExclusions, ...configExclusions]; diff --git a/packages/safe-chain/src/config/settings.spec.js b/packages/safe-chain/src/config/settings.spec.js index 8db5b83..18b5156 100644 --- a/packages/safe-chain/src/config/settings.spec.js +++ b/packages/safe-chain/src/config/settings.spec.js @@ -14,7 +14,10 @@ mock.module("fs", { const { getNpmCustomRegistries, getPipCustomRegistries, - getNpmMinimumPackageAgeExclusions, + getMinimumPackageAgeExclusions, + setEcoSystem, + ECOSYSTEM_JS, + ECOSYSTEM_PY, getLoggingLevel, LOGGING_SILENT, LOGGING_NORMAL, @@ -367,13 +370,18 @@ describe("getLoggingLevel", () => { }); }); -describe("getNpmMinimumPackageAgeExclusions", () => { +describe("getMinimumPackageAgeExclusions", () => { let originalEnv; - const envVarName = "SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS"; + let originalLegacyEnv; + const envVarName = "SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS"; + const legacyEnvVarName = "SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS"; beforeEach(() => { originalEnv = process.env[envVarName]; + originalLegacyEnv = process.env[legacyEnvVarName]; delete process.env[envVarName]; + delete process.env[legacyEnvVarName]; + setEcoSystem(ECOSYSTEM_JS); }); afterEach(() => { @@ -382,13 +390,18 @@ describe("getNpmMinimumPackageAgeExclusions", () => { } else { delete process.env[envVarName]; } + if (originalLegacyEnv !== undefined) { + process.env[legacyEnvVarName] = originalLegacyEnv; + } else { + delete process.env[legacyEnvVarName]; + } configFileContent = undefined; }); it("should return empty array when no exclusions configured", () => { configFileContent = undefined; - const exclusions = getNpmMinimumPackageAgeExclusions(); + const exclusions = getMinimumPackageAgeExclusions(); assert.deepStrictEqual(exclusions, []); }); @@ -400,7 +413,7 @@ describe("getNpmMinimumPackageAgeExclusions", () => { }, }); - const exclusions = getNpmMinimumPackageAgeExclusions(); + const exclusions = getMinimumPackageAgeExclusions(); assert.deepStrictEqual(exclusions, ["react", "@aikidosec/safe-chain"]); }); @@ -409,7 +422,7 @@ describe("getNpmMinimumPackageAgeExclusions", () => { process.env[envVarName] = "lodash,express,@types/node"; configFileContent = undefined; - const exclusions = getNpmMinimumPackageAgeExclusions(); + const exclusions = getMinimumPackageAgeExclusions(); assert.deepStrictEqual(exclusions, ["lodash", "express", "@types/node"]); }); @@ -422,7 +435,7 @@ describe("getNpmMinimumPackageAgeExclusions", () => { }, }); - const exclusions = getNpmMinimumPackageAgeExclusions(); + const exclusions = getMinimumPackageAgeExclusions(); assert.deepStrictEqual(exclusions, ["lodash", "react"]); }); @@ -435,7 +448,7 @@ describe("getNpmMinimumPackageAgeExclusions", () => { }, }); - const exclusions = getNpmMinimumPackageAgeExclusions(); + const exclusions = getMinimumPackageAgeExclusions(); assert.deepStrictEqual(exclusions, ["lodash", "react", "express"]); }); @@ -444,7 +457,7 @@ describe("getNpmMinimumPackageAgeExclusions", () => { process.env[envVarName] = " lodash , react "; configFileContent = undefined; - const exclusions = getNpmMinimumPackageAgeExclusions(); + const exclusions = getMinimumPackageAgeExclusions(); assert.deepStrictEqual(exclusions, ["lodash", "react"]); }); @@ -456,7 +469,7 @@ describe("getNpmMinimumPackageAgeExclusions", () => { }, }); - const exclusions = getNpmMinimumPackageAgeExclusions(); + const exclusions = getMinimumPackageAgeExclusions(); assert.deepStrictEqual(exclusions, ["@babel/core", "@types/react"]); }); @@ -465,7 +478,7 @@ describe("getNpmMinimumPackageAgeExclusions", () => { process.env[envVarName] = "lodash,,react,"; configFileContent = undefined; - const exclusions = getNpmMinimumPackageAgeExclusions(); + const exclusions = getMinimumPackageAgeExclusions(); assert.deepStrictEqual(exclusions, ["lodash", "react"]); }); @@ -474,7 +487,7 @@ describe("getNpmMinimumPackageAgeExclusions", () => { process.env[envVarName] = ""; configFileContent = undefined; - const exclusions = getNpmMinimumPackageAgeExclusions(); + const exclusions = getMinimumPackageAgeExclusions(); assert.deepStrictEqual(exclusions, []); }); @@ -483,7 +496,7 @@ describe("getNpmMinimumPackageAgeExclusions", () => { process.env[envVarName] = " , , "; configFileContent = undefined; - const exclusions = getNpmMinimumPackageAgeExclusions(); + const exclusions = getMinimumPackageAgeExclusions(); assert.deepStrictEqual(exclusions, []); }); @@ -495,8 +508,29 @@ describe("getNpmMinimumPackageAgeExclusions", () => { }, }); - const exclusions = getNpmMinimumPackageAgeExclusions(); + const exclusions = getMinimumPackageAgeExclusions(); assert.deepStrictEqual(exclusions, ["react", "lodash"]); }); + + it("should fall back to the legacy npm environment variable", () => { + process.env[legacyEnvVarName] = "lodash,react"; + + const exclusions = getMinimumPackageAgeExclusions(); + + assert.deepStrictEqual(exclusions, ["lodash", "react"]); + }); + + it("should read exclusions from the python config when the current ecosystem is py", () => { + setEcoSystem(ECOSYSTEM_PY); + configFileContent = JSON.stringify({ + pip: { + minimumPackageAgeExclusions: ["requests", "urllib3"], + }, + }); + + const exclusions = getMinimumPackageAgeExclusions(); + + assert.deepStrictEqual(exclusions, ["requests", "urllib3"]); + }); }); diff --git a/packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js b/packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js index 79b5200..869af81 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js +++ b/packages/safe-chain/src/registryProxy/interceptors/createInterceptorForEcoSystem.js @@ -4,7 +4,7 @@ import { getEcoSystem, } from "../../config/settings.js"; import { npmInterceptorForUrl } from "./npm/npmInterceptor.js"; -import { pipInterceptorForUrl } from "./pipInterceptor.js"; +import { pipInterceptorForUrl } from "./pip/pipInterceptor.js"; /** * @param {string} url diff --git a/packages/safe-chain/src/registryProxy/interceptors/minimumPackageAgeExclusions.js b/packages/safe-chain/src/registryProxy/interceptors/minimumPackageAgeExclusions.js new file mode 100644 index 0000000..05a86ea --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/minimumPackageAgeExclusions.js @@ -0,0 +1,33 @@ +import { getMinimumPackageAgeExclusions, getEcoSystem } from "../../config/settings.js"; +import { getEquivalentPackageNames } from "../../scanning/packageNameVariants.js"; + +/** + * Checks if a package name matches an exclusion pattern. + * Supports trailing wildcard (*) for prefix matching. + * @param {string} packageName + * @param {string} pattern + * @returns {boolean} + */ +export function matchesExclusionPattern(packageName, pattern) { + if (pattern.endsWith("/*")) { + return packageName.startsWith(pattern.slice(0, -1)); + } + return packageName === pattern; +} + +/** + * @param {string | undefined} packageName + * @returns {boolean} + */ +export function isExcludedFromMinimumPackageAge(packageName) { + if (!packageName) { + return false; + } + + const exclusions = getMinimumPackageAgeExclusions(); + const candidateNames = getEquivalentPackageNames(packageName, getEcoSystem()); + + return exclusions.some((pattern) => + candidateNames.some((name) => matchesExclusionPattern(name, pattern)) + ); +} diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js index a9a8c41..1743f82 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js @@ -196,17 +196,3 @@ export function getPackageNameFromMetadataResponse(body, headers) { return undefined; } } - -/** - * Checks if a package name matches an exclusion pattern. - * Supports trailing wildcard (*) for prefix matching. - * @param {string} packageName - * @param {string} pattern - * @returns {boolean} - */ -export function matchesExclusionPattern(packageName, pattern) { - if (pattern.endsWith("/*")) { - return packageName.startsWith(pattern.slice(0, -1)); - } - return packageName === pattern; -} diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js index f4e4e1b..8caae84 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js @@ -1,6 +1,5 @@ import { getNpmCustomRegistries, - getNpmMinimumPackageAgeExclusions, skipMinimumPackageAge, } from "../../../config/settings.js"; import { isMalwarePackage } from "../../../scanning/audit/index.js"; @@ -8,12 +7,14 @@ import { interceptRequests } from "../interceptorBuilder.js"; import { getPackageNameFromMetadataResponse, isPackageInfoUrl, - matchesExclusionPattern, modifyNpmInfoRequestHeaders, modifyNpmInfoResponse, } from "./modifyNpmInfo.js"; import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js"; import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache.js"; +import { + isExcludedFromMinimumPackageAge, +} from "../minimumPackageAgeExclusions.js"; const knownJsRegistries = [ "registry.npmjs.org", @@ -81,17 +82,6 @@ function buildNpmInterceptor(registry) { }); } -/** - * @param {string} packageName - * @returns {boolean} - */ -function isExcludedFromMinimumPackageAge(packageName) { - const exclusions = getNpmMinimumPackageAgeExclusions(); - return exclusions.some((pattern) => - matchesExclusionPattern(packageName, pattern) - ); -} - /** * @param {Buffer} body * @param {NodeJS.Dict | undefined} headers diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js index de7acc6..cdd38ef 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js @@ -14,7 +14,7 @@ describe("npmInterceptor minimum package age", async () => { getMinimumPackageAgeHours: () => minimumPackageAgeSettings, skipMinimumPackageAge: () => skipMinimumPackageAgeSetting, getNpmCustomRegistries: () => [], - getNpmMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting, + getMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting, getEcoSystem: () => "js", }, }); diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js index e361275..769b6e1 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js @@ -28,7 +28,7 @@ mock.module("../../../config/settings.js", { setEcoSystem: () => {}, getMinimumPackageAgeHours: () => 24, getNpmCustomRegistries: () => customRegistries, - getNpmMinimumPackageAgeExclusions: () => [], + getMinimumPackageAgeExclusions: () => [], skipMinimumPackageAge: () => skipMinimumPackageAgeSetting, }, }); diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js b/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js new file mode 100644 index 0000000..30c3c25 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js @@ -0,0 +1,64 @@ +/** + * @param {string} url + * @param {string} registry + * @returns {{packageName: string | undefined, version: string | undefined}} + */ +export function parsePipPackageFromUrl(url, registry) { + let packageName, version; + + if (!registry || typeof url !== "string") { + return { packageName, version }; + } + + let urlObj; + try { + urlObj = new URL(url); + } catch { + return { packageName, version }; + } + + const lastSegment = urlObj.pathname.split("/").filter(Boolean).pop(); + if (!lastSegment) { + return { packageName, version }; + } + + const filename = decodeURIComponent(lastSegment); + + const wheelExtRe = /\.whl(?:\.metadata)?$/; + if (wheelExtRe.test(filename)) { + const base = filename.replace(wheelExtRe, ""); + const firstDash = base.indexOf("-"); + if (firstDash > 0) { + const dist = base.slice(0, firstDash); + const rest = base.slice(firstDash + 1); + const secondDash = rest.indexOf("-"); + const rawVersion = secondDash >= 0 ? rest.slice(0, secondDash) : rest; + packageName = dist; + version = rawVersion; + + if (version === "latest" || !packageName || !version) { + return { packageName: undefined, version: undefined }; + } + + return { packageName, version }; + } + } + + const sdistExtWithMetadataRe = /\.(tar\.gz|zip|tar\.bz2|tar\.xz)(\.metadata)?$/i; + if (sdistExtWithMetadataRe.test(filename)) { + const base = filename.replace(sdistExtWithMetadataRe, ""); + const lastDash = base.lastIndexOf("-"); + if (lastDash > 0 && lastDash < base.length - 1) { + packageName = base.slice(0, lastDash); + version = base.slice(lastDash + 1); + + if (version === "latest" || !packageName || !version) { + return { packageName: undefined, version: undefined }; + } + + return { packageName, version }; + } + } + + return { packageName: undefined, version: undefined }; +} diff --git a/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.pipCustomRegistries.spec.js b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.customRegistries.spec.js similarity index 75% rename from packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.pipCustomRegistries.spec.js rename to packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.customRegistries.spec.js index fc9c91e..9a5cd91 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.pipCustomRegistries.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.customRegistries.spec.js @@ -6,13 +6,25 @@ describe("pipInterceptor custom registries", async () => { let malwareResponse = false; let customRegistries = []; - mock.module("../../config/settings.js", { + mock.module("../../../config/settings.js", { namedExports: { + ECOSYSTEM_PY: "py", + getEcoSystem: () => "py", + getMinimumPackageAgeExclusions: () => [], getPipCustomRegistries: () => customRegistries, + skipMinimumPackageAge: () => false, }, }); - mock.module("../../scanning/audit/index.js", { + mock.module("../../../scanning/newPackagesListCache.js", { + namedExports: { + openNewPackagesDatabase: async () => ({ + isNewlyReleasedPackage: () => false, + }), + }, + }); + + mock.module("../../../scanning/audit/index.js", { namedExports: { isMalwarePackage: async (packageName, version) => { lastPackage = { packageName, version }; @@ -30,10 +42,7 @@ describe("pipInterceptor custom registries", async () => { const interceptor = pipInterceptorForUrl(url); - assert.ok( - interceptor, - "Interceptor should be created for custom registry" - ); + assert.ok(interceptor); }); it("should parse package from custom registry URL", async () => { @@ -42,7 +51,7 @@ describe("pipInterceptor custom registries", async () => { "https://my-custom-registry.example.com/packages/xx/yy/foobar-1.2.3.tar.gz"; const interceptor = pipInterceptorForUrl(url); - assert.ok(interceptor, "Interceptor should be created"); + assert.ok(interceptor); await interceptor.handleRequest(url); @@ -58,7 +67,7 @@ describe("pipInterceptor custom registries", async () => { "https://private-pypi.internal.com/packages/foo_bar-2.0.0-py3-none-any.whl"; const interceptor = pipInterceptorForUrl(url); - assert.ok(interceptor, "Interceptor should be created"); + assert.ok(interceptor); await interceptor.handleRequest(url); @@ -82,11 +91,8 @@ describe("pipInterceptor custom registries", async () => { const interceptor1 = pipInterceptorForUrl(url1); const interceptor2 = pipInterceptorForUrl(url2); - assert.ok(interceptor1, "Interceptor should be created for first registry"); - assert.ok( - interceptor2, - "Interceptor should be created for second registry" - ); + assert.ok(interceptor1); + assert.ok(interceptor2); }); it("should block malicious package from custom registry", async () => { @@ -97,21 +103,13 @@ describe("pipInterceptor custom registries", async () => { "https://my-custom-registry.example.com/packages/malicious_package-1.0.0.tar.gz"; const interceptor = pipInterceptorForUrl(url); - assert.ok(interceptor, "Interceptor should be created"); + assert.ok(interceptor); const result = await interceptor.handleRequest(url); - assert.ok(result.blockResponse, "Should contain a blockResponse"); - assert.equal( - result.blockResponse.statusCode, - 403, - "Block response should have status code 403" - ); - assert.equal( - result.blockResponse.message, - "Forbidden - blocked by safe-chain", - "Block response should have correct status message" - ); + assert.ok(result.blockResponse); + assert.equal(result.blockResponse.statusCode, 403); + assert.equal(result.blockResponse.message, "Forbidden - blocked by safe-chain"); malwareResponse = false; }); @@ -124,10 +122,7 @@ describe("pipInterceptor custom registries", async () => { const interceptor = pipInterceptorForUrl(url); - assert.ok( - interceptor, - "Interceptor should be created for known registry even with custom registries set" - ); + assert.ok(interceptor); await interceptor.handleRequest(url); @@ -143,11 +138,7 @@ describe("pipInterceptor custom registries", async () => { const interceptor = pipInterceptorForUrl(url); - assert.equal( - interceptor, - undefined, - "Interceptor should be undefined for unknown registry" - ); + assert.equal(interceptor, undefined); }); it("should handle empty custom registries array", () => { @@ -157,11 +148,7 @@ describe("pipInterceptor custom registries", async () => { const interceptor = pipInterceptorForUrl(url); - assert.equal( - interceptor, - undefined, - "Interceptor should be undefined when no custom registries are configured" - ); + assert.equal(interceptor, undefined); }); it("should parse .whl.metadata from custom registry", async () => { @@ -170,7 +157,7 @@ describe("pipInterceptor custom registries", async () => { "https://private-pypi.internal.com/packages/foo_bar-2.0.0-py3-none-any.whl.metadata"; const interceptor = pipInterceptorForUrl(url); - assert.ok(interceptor, "Interceptor should be created"); + assert.ok(interceptor); await interceptor.handleRequest(url); @@ -186,7 +173,7 @@ describe("pipInterceptor custom registries", async () => { "https://private-pypi.internal.com/packages/foo_bar-2.0.0.tar.gz.metadata"; const interceptor = pipInterceptorForUrl(url); - assert.ok(interceptor, "Interceptor should be created"); + assert.ok(interceptor); await interceptor.handleRequest(url); @@ -196,4 +183,3 @@ describe("pipInterceptor custom registries", async () => { }); }); }); - diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js new file mode 100644 index 0000000..c26b746 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js @@ -0,0 +1,80 @@ +import { + getPipCustomRegistries, + skipMinimumPackageAge, +} from "../../../config/settings.js"; +import { isMalwarePackage } from "../../../scanning/audit/index.js"; +import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache.js"; +import { interceptRequests } from "../interceptorBuilder.js"; +import { isExcludedFromMinimumPackageAge } from "../minimumPackageAgeExclusions.js"; +import { parsePipPackageFromUrl } from "./parsePipPackageUrl.js"; + +const knownPipRegistries = [ + "files.pythonhosted.org", + "pypi.org", + "pypi.python.org", + "pythonhosted.org", +]; + +/** + * @param {string} url + * @returns {import("../interceptorBuilder.js").Interceptor | undefined} + */ +export function pipInterceptorForUrl(url) { + const customRegistries = getPipCustomRegistries(); + const registries = [...knownPipRegistries, ...customRegistries]; + const registry = registries.find((reg) => url.includes(reg)); + + if (registry) { + return buildPipInterceptor(registry); + } + + return undefined; +} + +/** + * @param {string} registry + * @returns {import("../interceptorBuilder.js").Interceptor | undefined} + */ +function buildPipInterceptor(registry) { + return interceptRequests(async (reqContext) => { + const { packageName, version } = parsePipPackageFromUrl( + reqContext.targetUrl, + registry + ); + + // PyPI treats hyphens and underscores as equivalent distribution names. + const hyphenName = packageName?.includes("_") + ? packageName.replace(/_/g, "-") + : packageName; + + const isMalicious = + await isMalwarePackage(packageName, version) || + await isMalwarePackage(hyphenName, version); + + if (isMalicious) { + reqContext.blockMalware(packageName, version); + return; + } + + if ( + packageName && + version && + !skipMinimumPackageAge() && + !isExcludedFromMinimumPackageAge(packageName) + ) { + const newPackagesDatabase = await openNewPackagesDatabase(); + const isNewlyReleased = newPackagesDatabase.isNewlyReleasedPackage( + packageName, + version + ); + + if (isNewlyReleased) { + reqContext.blockMinimumAgeRequest( + packageName, + version, + `Forbidden - blocked by safe-chain direct download minimum package age (${packageName}@${version})` + ); + } + } + }); +} diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.minPackageAge.spec.js b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.minPackageAge.spec.js new file mode 100644 index 0000000..8a5b189 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.minPackageAge.spec.js @@ -0,0 +1,103 @@ +import { describe, it, mock } from "node:test"; +import assert from "node:assert"; + +describe("pipInterceptor minimum package age", async () => { + let skipMinimumPackageAgeSetting = false; + let newlyReleasedPackageResponse = false; + let minimumPackageAgeExclusionsSetting = []; + + mock.module("../../../scanning/audit/index.js", { + namedExports: { + isMalwarePackage: async () => false, + }, + }); + + mock.module("../../../scanning/newPackagesListCache.js", { + namedExports: { + openNewPackagesDatabase: async () => ({ + isNewlyReleasedPackage: (packageName, version) => { + return newlyReleasedPackageResponse && + (packageName === "foo-bar" || + packageName === "foo_bar" || + packageName === "foo.bar") && + version === "2.0.0"; + }, + }), + }, + }); + + mock.module("../../../config/settings.js", { + namedExports: { + ECOSYSTEM_PY: "py", + getEcoSystem: () => "py", + getMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting, + getPipCustomRegistries: () => [], + skipMinimumPackageAge: () => skipMinimumPackageAgeSetting, + }, + }); + + const { pipInterceptorForUrl } = await import("./pipInterceptor.js"); + + it("should block newly released package downloads", async () => { + const url = + "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl"; + newlyReleasedPackageResponse = true; + + const interceptor = pipInterceptorForUrl(url); + const result = await interceptor.handleRequest(url); + + assert.ok(result.blockResponse); + assert.equal(result.blockResponse.statusCode, 403); + assert.equal( + result.blockResponse.message, + "Forbidden - blocked by safe-chain direct download minimum package age (foo_bar@2.0.0)" + ); + + newlyReleasedPackageResponse = false; + }); + + it("should not block newly released package downloads when skipMinimumPackageAge is enabled", async () => { + const url = + "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl"; + newlyReleasedPackageResponse = true; + skipMinimumPackageAgeSetting = true; + + const interceptor = pipInterceptorForUrl(url); + const result = await interceptor.handleRequest(url); + + assert.equal(result.blockResponse, undefined); + + skipMinimumPackageAgeSetting = false; + newlyReleasedPackageResponse = false; + }); + + it("should not block newly released package downloads when the package is excluded", async () => { + const url = + "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl"; + newlyReleasedPackageResponse = true; + minimumPackageAgeExclusionsSetting = ["foo-bar"]; + + const interceptor = pipInterceptorForUrl(url); + const result = await interceptor.handleRequest(url); + + assert.equal(result.blockResponse, undefined); + + minimumPackageAgeExclusionsSetting = []; + newlyReleasedPackageResponse = false; + }); + + it("should not block newly released package downloads when a dot-name package matches a hyphen exclusion", async () => { + const url = + "https://files.pythonhosted.org/packages/xx/yy/foo.bar-2.0.0.tar.gz"; + newlyReleasedPackageResponse = true; + minimumPackageAgeExclusionsSetting = ["foo-bar"]; + + const interceptor = pipInterceptorForUrl(url); + const result = await interceptor.handleRequest(url); + + assert.equal(result.blockResponse, undefined); + + minimumPackageAgeExclusionsSetting = []; + newlyReleasedPackageResponse = false; + }); +}); diff --git a/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.spec.js b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.packageDownload.spec.js similarity index 83% rename from packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.spec.js rename to packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.packageDownload.spec.js index 482a800..61f279e 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.packageDownload.spec.js @@ -5,7 +5,7 @@ describe("pipInterceptor", async () => { let lastPackage; let malwareResponse = false; - mock.module("../../scanning/audit/index.js", { + mock.module("../../../scanning/audit/index.js", { namedExports: { isMalwarePackage: async (packageName, version) => { lastPackage = { packageName, version }; @@ -14,10 +14,27 @@ describe("pipInterceptor", async () => { }, }); + mock.module("../../../scanning/newPackagesListCache.js", { + namedExports: { + openNewPackagesDatabase: async () => ({ + isNewlyReleasedPackage: () => false, + }), + }, + }); + + mock.module("../../../config/settings.js", { + namedExports: { + ECOSYSTEM_PY: "py", + getEcoSystem: () => "py", + getMinimumPackageAgeExclusions: () => [], + getPipCustomRegistries: () => [], + skipMinimumPackageAge: () => false, + }, + }); + const { pipInterceptorForUrl } = await import("./pipInterceptor.js"); const parserCases = [ - // Valid pip URLs { url: "https://files.pythonhosted.org/packages/xx/yy/foobar-1.2.3.tar.gz", expected: { packageName: "foobar", version: "1.2.3" }, @@ -35,7 +52,6 @@ describe("pipInterceptor", async () => { expected: { packageName: "foo-bar", version: "2.0.0" }, }, { - // Poetry preflight metadata alongside wheel (.whl.metadata) url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl.metadata", expected: { packageName: "foo-bar", version: "2.0.0" }, }, @@ -52,7 +68,6 @@ describe("pipInterceptor", async () => { expected: { packageName: "foo-bar", version: "2.0.0b1" }, }, { - // sdist with metadata sidecar (.tar.gz.metadata) url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0.tar.gz.metadata", expected: { packageName: "foo-bar", version: "2.0.0" }, }, @@ -76,7 +91,6 @@ describe("pipInterceptor", async () => { url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0-cp38-cp38-manylinux1_x86_64.whl", expected: { packageName: "foo-bar", version: "2.0.0" }, }, - // Invalid pip URLs { url: "https://pypi.org/simple/", expected: { packageName: undefined, version: undefined }, @@ -98,10 +112,7 @@ describe("pipInterceptor", async () => { parserCases.forEach(({ url, expected }, index) => { it(`should parse URL ${index + 1}: ${url}`, async () => { const interceptor = pipInterceptorForUrl(url); - assert.ok( - interceptor, - "Interceptor should be created for known npm registry" - ); + assert.ok(interceptor, "Interceptor should be created for known pip registry"); await interceptor.handleRequest(url); @@ -111,14 +122,8 @@ describe("pipInterceptor", async () => { it("should not create interceptor for unknown registry", () => { const url = "https://example.com/packages/xx/yy/foobar-1.2.3.tar.gz"; - const interceptor = pipInterceptorForUrl(url); - - assert.equal( - interceptor, - undefined, - "Interceptor should be undefined for unknown registry" - ); + assert.equal(interceptor, undefined); }); it("should block malicious package", async () => { @@ -127,19 +132,15 @@ describe("pipInterceptor", async () => { malwareResponse = true; const interceptor = pipInterceptorForUrl(url); - const result = await interceptor.handleRequest(url); - assert.ok(result.blockResponse, "Should contain a blockResponse"); - assert.equal( - result.blockResponse.statusCode, - 403, - "Block response should have status code 403" - ); + assert.ok(result.blockResponse); + assert.equal(result.blockResponse.statusCode, 403); assert.equal( result.blockResponse.message, - "Forbidden - blocked by safe-chain", - "Block response should have correct status message" + "Forbidden - blocked by safe-chain" ); + + malwareResponse = false; }); }); diff --git a/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js deleted file mode 100644 index e781e30..0000000 --- a/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js +++ /dev/null @@ -1,132 +0,0 @@ -import { getPipCustomRegistries } from "../../config/settings.js"; -import { isMalwarePackage } from "../../scanning/audit/index.js"; -import { interceptRequests } from "./interceptorBuilder.js"; - -const knownPipRegistries = [ - "files.pythonhosted.org", - "pypi.org", - "pypi.python.org", - "pythonhosted.org", -]; - -/** - * @param {string} url - * @returns {import("./interceptorBuilder.js").Interceptor | undefined} - */ -export function pipInterceptorForUrl(url) { - const customRegistries = getPipCustomRegistries(); - const registries = [...knownPipRegistries, ...customRegistries]; - const registry = registries.find((reg) => url.includes(reg)); - - if (registry) { - return buildPipInterceptor(registry); - } - - return undefined; -} - -/** - * @param {string} registry - * @returns {import("./interceptorBuilder.js").Interceptor | undefined} - */ -function buildPipInterceptor(registry) { - return interceptRequests(async (reqContext) => { - const { packageName, version } = parsePipPackageFromUrl( - reqContext.targetUrl, - registry - ); - - // Normalize underscores to hyphens for DB matching, as PyPI allows underscores in distribution names. - // Per python, packages that differ only by hyphen vs underscore are considered the same. - const hyphenName = packageName?.includes("_") ? packageName.replace(/_/g, "-") : packageName; - - const isMalicious = - await isMalwarePackage(packageName, version) - || await isMalwarePackage(hyphenName, version); - - if (isMalicious) { - reqContext.blockMalware(packageName, version); - } - }); -} - -/** - * @param {string} url - * @param {string} registry - * @returns {{packageName: string | undefined, version: string | undefined}} - */ -function parsePipPackageFromUrl(url, registry) { - let packageName, version; - - // Basic validation - if (!registry || typeof url !== "string") { - return { packageName, version }; - } - - // Quick sanity check on the URL + parse - let urlObj; - try { - urlObj = new URL(url); - } catch { - return { packageName, version }; - } - - // Get the last path segment (filename) and decode it (strip query & fragment automatically) - const lastSegment = urlObj.pathname.split("/").filter(Boolean).pop(); - if (!lastSegment) { - return { packageName, version }; - } - - const filename = decodeURIComponent(lastSegment); - - // Parse Python package downloads from PyPI/pythonhosted.org - // Example wheel: https://files.pythonhosted.org/packages/xx/yy/requests-2.28.1-py3-none-any.whl - // Example sdist: https://files.pythonhosted.org/packages/xx/yy/requests-2.28.1.tar.gz - - // Wheel (.whl) and Poetry's preflight metadata (.whl.metadata) - // Examples: - // foo_bar-2.0.0-py3-none-any.whl - // foo_bar-2.0.0-py3-none-any.whl.metadata - const wheelExtRe = /\.whl(?:\.metadata)?$/; - const wheelExtMatch = filename.match(wheelExtRe); - if (wheelExtMatch) { - const base = filename.replace(wheelExtRe, ""); - const firstDash = base.indexOf("-"); - if (firstDash > 0) { - const dist = base.slice(0, firstDash); // may contain underscores - const rest = base.slice(firstDash + 1); // version + the rest of tags - const secondDash = rest.indexOf("-"); - const rawVersion = secondDash >= 0 ? rest.slice(0, secondDash) : rest; - packageName = dist; - version = rawVersion; - // Reject "latest" as it's a placeholder, not a real version - // When version is "latest", this signals the URL doesn't contain actual version info - // Returning undefined allows the request (see registryProxy.js isAllowedUrl) - if (version === "latest" || !packageName || !version) { - return { packageName: undefined, version: undefined }; - } - return { packageName, version }; - } - } - - // Source dist (sdist) and potential metadata sidecars (e.g., .tar.gz.metadata) - const sdistExtWithMetadataRe = /\.(tar\.gz|zip|tar\.bz2|tar\.xz)(\.metadata)?$/i; - const sdistExtMatch = filename.match(sdistExtWithMetadataRe); - if (sdistExtMatch) { - const base = filename.replace(sdistExtWithMetadataRe, ""); - const lastDash = base.lastIndexOf("-"); - if (lastDash > 0 && lastDash < base.length - 1) { - packageName = base.slice(0, lastDash); - version = base.slice(lastDash + 1); - // Reject "latest" as it's a placeholder, not a real version - // When version is "latest", this signals the URL doesn't contain actual version info - // Returning undefined allows the request (see registryProxy.js isAllowedUrl) - if (version === "latest" || !packageName || !version) { - return { packageName: undefined, version: undefined }; - } - return { packageName, version }; - } - } - // Unknown file type or invalid - return { packageName: undefined, version: undefined }; -} diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js index 902f705..f363f27 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js @@ -174,10 +174,18 @@ describe("newPackagesDatabase", async () => { assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true); }); - it("returns false for all packages when ecosystem is not JS", async () => { + it("supports package checks for the python ecosystem", async () => { ecosystem = "py"; + fetchedList = [ + { + source: "pypi", + package_name: "foo", + version: "1.0.0", + released_on: hoursAgo(1), + }, + ]; const db = await openNewPackagesDatabase(); - assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false); + assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true); }); }); diff --git a/packages/safe-chain/src/scanning/newPackagesDatabaseBuilder.js b/packages/safe-chain/src/scanning/newPackagesDatabaseBuilder.js index 6db4a66..d09f42c 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabaseBuilder.js +++ b/packages/safe-chain/src/scanning/newPackagesDatabaseBuilder.js @@ -4,10 +4,11 @@ import { ECOSYSTEM_JS, ECOSYSTEM_PY, } from "../config/settings.js"; +import { getEquivalentPackageNames } from "./packageNameVariants.js"; /** * @typedef {Object} NewPackagesDatabase - * @property {function(string, string): boolean} isNewlyReleasedPackage + * @property {function(string | undefined, string | undefined): boolean} isNewlyReleasedPackage */ /** @@ -33,21 +34,28 @@ function getCurrentFeedSource() { * @returns {NewPackagesDatabase} */ export function buildNewPackagesDatabase(newPackagesList) { + const ecosystem = getEcoSystem(); + /** - * @param {string} name - * @param {string} version + * @param {string | undefined} name + * @param {string | undefined} version * @returns {boolean} */ function isNewlyReleasedPackage(name, version) { + if (!name || !version) { + return false; + } + const cutOff = new Date( new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000 ); const expectedSource = getCurrentFeedSource(); + const candidateNames = getEquivalentPackageNames(name, ecosystem); const entry = newPackagesList.find( (pkg) => (!pkg.source || pkg.source.toLowerCase() === expectedSource) && - pkg.package_name === name && + candidateNames.includes(pkg.package_name) && pkg.version === version ); diff --git a/packages/safe-chain/src/scanning/newPackagesDatabaseBuilder.spec.js b/packages/safe-chain/src/scanning/newPackagesDatabaseBuilder.spec.js index 0c2fb84..9670a9e 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabaseBuilder.spec.js +++ b/packages/safe-chain/src/scanning/newPackagesDatabaseBuilder.spec.js @@ -50,6 +50,15 @@ describe("buildNewPackagesDatabase", () => { assert.strictEqual(db.isNewlyReleasedPackage("not-there", "1.0.0"), false); }); + it("returns false when name or version is undefined", () => { + const db = buildNewPackagesDatabase([ + { package_name: "foo", version: "1.0.0", released_on: hoursAgo(1) }, + ]); + + assert.strictEqual(db.isNewlyReleasedPackage(undefined, "1.0.0"), false); + assert.strictEqual(db.isNewlyReleasedPackage("foo", undefined), false); + }); + it("returns false for a known package but different version", () => { const db = buildNewPackagesDatabase([ { package_name: "foo", version: "2.0.0", released_on: hoursAgo(1) }, @@ -96,5 +105,54 @@ describe("buildNewPackagesDatabase", () => { minimumPackageAgeHours = 24; // reset }); + + it("matches underscore request names against hyphen feed names for python", () => { + ecosystem = "py"; + + const db = buildNewPackagesDatabase([ + { source: "pypi", package_name: "foo-bar", version: "1.0.0", released_on: hoursAgo(1) }, + ]); + + assert.strictEqual(db.isNewlyReleasedPackage("foo_bar", "1.0.0"), true); + + ecosystem = "js"; + }); + + it("matches hyphen request names against underscore feed names for python", () => { + ecosystem = "py"; + + const db = buildNewPackagesDatabase([ + { source: "pypi", package_name: "foo_bar", version: "1.0.0", released_on: hoursAgo(1) }, + ]); + + assert.strictEqual(db.isNewlyReleasedPackage("foo-bar", "1.0.0"), true); + + ecosystem = "js"; + }); + + it("matches dot request names against hyphen feed names for python", () => { + ecosystem = "py"; + + const db = buildNewPackagesDatabase([ + { source: "pypi", package_name: "foo-bar", version: "1.0.0", released_on: hoursAgo(1) }, + ]); + + assert.strictEqual(db.isNewlyReleasedPackage("foo.bar", "1.0.0"), true); + + ecosystem = "js"; + }); + + it("matches underscore request names against dot feed names for python", () => { + ecosystem = "py"; + + const db = buildNewPackagesDatabase([ + { source: "pypi", package_name: "foo.bar", version: "1.0.0", released_on: hoursAgo(1) }, + ]); + + assert.strictEqual(db.isNewlyReleasedPackage("foo_bar", "1.0.0"), true); + + ecosystem = "js"; + }); + }); }); diff --git a/packages/safe-chain/src/scanning/newPackagesListCache.js b/packages/safe-chain/src/scanning/newPackagesListCache.js index f7496b6..dfac247 100644 --- a/packages/safe-chain/src/scanning/newPackagesListCache.js +++ b/packages/safe-chain/src/scanning/newPackagesListCache.js @@ -8,7 +8,6 @@ import { getNewPackagesListVersionPath, } from "../config/configFile.js"; import { ui } from "../environment/userInteraction.js"; -import { getEcoSystem, ECOSYSTEM_JS } from "../config/settings.js"; import { buildNewPackagesDatabase } from "./newPackagesDatabaseBuilder.js"; import { warnOnceAboutUnavailableDatabase } from "./newPackagesDatabaseWarnings.js"; @@ -28,11 +27,6 @@ export async function openNewPackagesDatabase() { return cachedNewPackagesDatabase; } - if (getEcoSystem() !== ECOSYSTEM_JS) { - cachedNewPackagesDatabase = { isNewlyReleasedPackage: () => false }; - return cachedNewPackagesDatabase; - } - /** @type {import("../api/aikido.js").NewPackageEntry[]} */ let newPackagesList; diff --git a/packages/safe-chain/src/scanning/packageNameVariants.js b/packages/safe-chain/src/scanning/packageNameVariants.js new file mode 100644 index 0000000..f8fb080 --- /dev/null +++ b/packages/safe-chain/src/scanning/packageNameVariants.js @@ -0,0 +1,18 @@ +import { ECOSYSTEM_PY } from "../config/settings.js"; + +/** + * @param {string} packageName + * @param {string} ecosystem + * @returns {string[]} + */ +export function getEquivalentPackageNames(packageName, ecosystem) { + if (ecosystem !== ECOSYSTEM_PY) { + return [packageName]; + } + + const hyphenName = packageName.replaceAll(/[_.-]/g, "-"); + const underscoreName = packageName.replaceAll(/[._-]/g, "_"); + const dotName = packageName.replaceAll(/[_.-]/g, "."); + + return [...new Set([packageName, hyphenName, underscoreName, dotName])]; +} From aa7bbbd4e99bf9a7edaba572948e0ce876d6f009 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Sat, 28 Mar 2026 11:39:02 -0700 Subject: [PATCH 220/360] Code Quality --- .../interceptors/pip/parsePipPackageUrl.js | 88 +++++++++++-------- .../interceptors/pip/pipInterceptor.js | 12 ++- .../src/scanning/packageNameVariants.js | 8 +- 3 files changed, 66 insertions(+), 42 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js b/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js index 30c3c25..e96664a 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js @@ -4,61 +4,79 @@ * @returns {{packageName: string | undefined, version: string | undefined}} */ export function parsePipPackageFromUrl(url, registry) { - let packageName, version; - if (!registry || typeof url !== "string") { - return { packageName, version }; + return { packageName: undefined, version: undefined }; } let urlObj; try { urlObj = new URL(url); } catch { - return { packageName, version }; + return { packageName: undefined, version: undefined }; } const lastSegment = urlObj.pathname.split("/").filter(Boolean).pop(); if (!lastSegment) { - return { packageName, version }; + return { packageName: undefined, version: undefined }; } const filename = decodeURIComponent(lastSegment); const wheelExtRe = /\.whl(?:\.metadata)?$/; if (wheelExtRe.test(filename)) { - const base = filename.replace(wheelExtRe, ""); - const firstDash = base.indexOf("-"); - if (firstDash > 0) { - const dist = base.slice(0, firstDash); - const rest = base.slice(firstDash + 1); - const secondDash = rest.indexOf("-"); - const rawVersion = secondDash >= 0 ? rest.slice(0, secondDash) : rest; - packageName = dist; - version = rawVersion; - - if (version === "latest" || !packageName || !version) { - return { packageName: undefined, version: undefined }; - } - - return { packageName, version }; - } + return parseWheelFilename(filename, wheelExtRe); } const sdistExtWithMetadataRe = /\.(tar\.gz|zip|tar\.bz2|tar\.xz)(\.metadata)?$/i; - if (sdistExtWithMetadataRe.test(filename)) { - const base = filename.replace(sdistExtWithMetadataRe, ""); - const lastDash = base.lastIndexOf("-"); - if (lastDash > 0 && lastDash < base.length - 1) { - packageName = base.slice(0, lastDash); - version = base.slice(lastDash + 1); - - if (version === "latest" || !packageName || !version) { - return { packageName: undefined, version: undefined }; - } - - return { packageName, version }; - } + if (!sdistExtWithMetadataRe.test(filename)) { + return { packageName: undefined, version: undefined }; } - return { packageName: undefined, version: undefined }; + return parseSdistFilename(filename, sdistExtWithMetadataRe); +} + +/** + * @param {string} filename + * @param {RegExp} wheelExtRe + * @returns {{packageName: string | undefined, version: string | undefined}} + */ +function parseWheelFilename(filename, wheelExtRe) { + const base = filename.replace(wheelExtRe, ""); + const firstDash = base.indexOf("-"); + if (firstDash <= 0) { + return { packageName: undefined, version: undefined }; + } + + const packageName = base.slice(0, firstDash); + const rest = base.slice(firstDash + 1); + const secondDash = rest.indexOf("-"); + const version = secondDash >= 0 ? rest.slice(0, secondDash) : rest; + + if (version === "latest" || !packageName || !version) { + return { packageName: undefined, version: undefined }; + } + + return { packageName, version }; +} + +/** + * @param {string} filename + * @param {RegExp} sdistExtWithMetadataRe + * @returns {{packageName: string | undefined, version: string | undefined}} + */ +function parseSdistFilename(filename, sdistExtWithMetadataRe) { + const base = filename.replace(sdistExtWithMetadataRe, ""); + const lastDash = base.lastIndexOf("-"); + if (lastDash <= 0 || lastDash >= base.length - 1) { + return { packageName: undefined, version: undefined }; + } + + const packageName = base.slice(0, lastDash); + const version = base.slice(lastDash + 1); + + if (version === "latest" || !packageName || !version) { + return { packageName: undefined, version: undefined }; + } + + return { packageName, version }; } diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js index c26b746..5194bec 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js @@ -36,7 +36,15 @@ export function pipInterceptorForUrl(url) { * @returns {import("../interceptorBuilder.js").Interceptor | undefined} */ function buildPipInterceptor(registry) { - return interceptRequests(async (reqContext) => { + return interceptRequests(createPipRequestHandler(registry)); +} + +/** + * @param {string} registry + * @returns {(reqContext: import("../interceptorBuilder.js").RequestInterceptionContext) => Promise} + */ +function createPipRequestHandler(registry) { + return async (reqContext) => { const { packageName, version } = parsePipPackageFromUrl( reqContext.targetUrl, registry @@ -76,5 +84,5 @@ function buildPipInterceptor(registry) { ); } } - }); + }; } diff --git a/packages/safe-chain/src/scanning/packageNameVariants.js b/packages/safe-chain/src/scanning/packageNameVariants.js index f8fb080..19c0c32 100644 --- a/packages/safe-chain/src/scanning/packageNameVariants.js +++ b/packages/safe-chain/src/scanning/packageNameVariants.js @@ -10,9 +10,7 @@ export function getEquivalentPackageNames(packageName, ecosystem) { return [packageName]; } - const hyphenName = packageName.replaceAll(/[_.-]/g, "-"); - const underscoreName = packageName.replaceAll(/[._-]/g, "_"); - const dotName = packageName.replaceAll(/[_.-]/g, "."); - - return [...new Set([packageName, hyphenName, underscoreName, dotName])]; + return [...new Set([packageName, ...["-", "_", "."].map((separator) => + packageName.replaceAll(/[._-]/g, separator) + )])]; } From d84270be8dd17bbc8b6feaffad7f9a8bd544bcbc Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Sat, 28 Mar 2026 16:51:33 -0700 Subject: [PATCH 221/360] Adapt per review --- .../pipInterceptor.customRegistries.spec.js | 60 ++++++++++++------- .../interceptors/pip/pipInterceptor.js | 24 +++++--- .../pipInterceptor.packageDownload.spec.js | 19 +++++- .../src/scanning/packageNameVariants.js | 9 ++- 4 files changed, 76 insertions(+), 36 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.customRegistries.spec.js b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.customRegistries.spec.js index 9a5cd91..c7ad597 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.customRegistries.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.customRegistries.spec.js @@ -2,7 +2,7 @@ import { describe, it, mock } from "node:test"; import assert from "node:assert"; describe("pipInterceptor custom registries", async () => { - let lastPackage; + let scannedPackages; let malwareResponse = false; let customRegistries = []; @@ -27,7 +27,7 @@ describe("pipInterceptor custom registries", async () => { mock.module("../../../scanning/audit/index.js", { namedExports: { isMalwarePackage: async (packageName, version) => { - lastPackage = { packageName, version }; + scannedPackages.push({ packageName, version }); return malwareResponse; }, }, @@ -46,6 +46,7 @@ describe("pipInterceptor custom registries", async () => { }); it("should parse package from custom registry URL", async () => { + scannedPackages = []; customRegistries = ["my-custom-registry.example.com"]; const url = "https://my-custom-registry.example.com/packages/xx/yy/foobar-1.2.3.tar.gz"; @@ -55,13 +56,16 @@ describe("pipInterceptor custom registries", async () => { await interceptor.handleRequest(url); - assert.deepEqual(lastPackage, { - packageName: "foobar", - version: "1.2.3", - }); + assert.ok( + scannedPackages.some( + ({ packageName, version }) => + packageName === "foobar" && version === "1.2.3" + ) + ); }); it("should parse wheel package from custom registry URL", async () => { + scannedPackages = []; customRegistries = ["private-pypi.internal.com"]; const url = "https://private-pypi.internal.com/packages/foo_bar-2.0.0-py3-none-any.whl"; @@ -71,10 +75,12 @@ describe("pipInterceptor custom registries", async () => { await interceptor.handleRequest(url); - assert.deepEqual(lastPackage, { - packageName: "foo-bar", - version: "2.0.0", - }); + assert.ok( + scannedPackages.some( + ({ packageName, version }) => + packageName === "foo-bar" && version === "2.0.0" + ) + ); }); it("should handle multiple custom registries", async () => { @@ -96,6 +102,7 @@ describe("pipInterceptor custom registries", async () => { }); it("should block malicious package from custom registry", async () => { + scannedPackages = []; customRegistries = ["my-custom-registry.example.com"]; malwareResponse = true; @@ -115,6 +122,7 @@ describe("pipInterceptor custom registries", async () => { }); it("should still work with known registries when custom registries are set", async () => { + scannedPackages = []; customRegistries = ["my-custom-registry.example.com"]; const url = @@ -126,10 +134,12 @@ describe("pipInterceptor custom registries", async () => { await interceptor.handleRequest(url); - assert.deepEqual(lastPackage, { - packageName: "foobar", - version: "1.2.3", - }); + assert.ok( + scannedPackages.some( + ({ packageName, version }) => + packageName === "foobar" && version === "1.2.3" + ) + ); }); it("should not create interceptor for unknown registry when custom registries are set", () => { @@ -152,6 +162,7 @@ describe("pipInterceptor custom registries", async () => { }); it("should parse .whl.metadata from custom registry", async () => { + scannedPackages = []; customRegistries = ["private-pypi.internal.com"]; const url = "https://private-pypi.internal.com/packages/foo_bar-2.0.0-py3-none-any.whl.metadata"; @@ -161,13 +172,16 @@ describe("pipInterceptor custom registries", async () => { await interceptor.handleRequest(url); - assert.deepEqual(lastPackage, { - packageName: "foo-bar", - version: "2.0.0", - }); + assert.ok( + scannedPackages.some( + ({ packageName, version }) => + packageName === "foo-bar" && version === "2.0.0" + ) + ); }); it("should parse .tar.gz.metadata from custom registry", async () => { + scannedPackages = []; customRegistries = ["private-pypi.internal.com"]; const url = "https://private-pypi.internal.com/packages/foo_bar-2.0.0.tar.gz.metadata"; @@ -177,9 +191,11 @@ describe("pipInterceptor custom registries", async () => { await interceptor.handleRequest(url); - assert.deepEqual(lastPackage, { - packageName: "foo-bar", - version: "2.0.0", - }); + assert.ok( + scannedPackages.some( + ({ packageName, version }) => + packageName === "foo-bar" && version === "2.0.0" + ) + ); }); }); diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js index 5194bec..abdda17 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js @@ -1,8 +1,10 @@ import { + ECOSYSTEM_PY, getPipCustomRegistries, skipMinimumPackageAge, } from "../../../config/settings.js"; import { isMalwarePackage } from "../../../scanning/audit/index.js"; +import { getEquivalentPackageNames } from "../../../scanning/packageNameVariants.js"; import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache.js"; import { interceptRequests } from "../interceptorBuilder.js"; import { isExcludedFromMinimumPackageAge } from "../minimumPackageAgeExclusions.js"; @@ -50,14 +52,21 @@ function createPipRequestHandler(registry) { registry ); - // PyPI treats hyphens and underscores as equivalent distribution names. - const hyphenName = packageName?.includes("_") - ? packageName.replace(/_/g, "-") - : packageName; + if (!packageName) { + return; + } - const isMalicious = - await isMalwarePackage(packageName, version) || - await isMalwarePackage(hyphenName, version); + const equivalentPackageNames = getEquivalentPackageNames( + packageName, + ECOSYSTEM_PY + ); + let isMalicious = false; + for (const equivalentPackageName of equivalentPackageNames) { + if (await isMalwarePackage(equivalentPackageName, version)) { + isMalicious = true; + break; + } + } if (isMalicious) { reqContext.blockMalware(packageName, version); @@ -65,7 +74,6 @@ function createPipRequestHandler(registry) { } if ( - packageName && version && !skipMinimumPackageAge() && !isExcludedFromMinimumPackageAge(packageName) diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.packageDownload.spec.js b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.packageDownload.spec.js index 61f279e..d6fdec6 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.packageDownload.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.packageDownload.spec.js @@ -2,13 +2,13 @@ import { describe, it, mock } from "node:test"; import assert from "node:assert"; describe("pipInterceptor", async () => { - let lastPackage; + let scannedPackages; let malwareResponse = false; mock.module("../../../scanning/audit/index.js", { namedExports: { isMalwarePackage: async (packageName, version) => { - lastPackage = { packageName, version }; + scannedPackages.push({ packageName, version }); return malwareResponse; }, }, @@ -111,12 +111,24 @@ describe("pipInterceptor", async () => { parserCases.forEach(({ url, expected }, index) => { it(`should parse URL ${index + 1}: ${url}`, async () => { + scannedPackages = []; const interceptor = pipInterceptorForUrl(url); assert.ok(interceptor, "Interceptor should be created for known pip registry"); await interceptor.handleRequest(url); - assert.deepEqual(lastPackage, expected); + if (expected.packageName === undefined) { + assert.deepEqual(scannedPackages, []); + return; + } + + assert.ok( + scannedPackages.some( + ({ packageName, version }) => + packageName === expected.packageName && + version === expected.version + ) + ); }); }); @@ -127,6 +139,7 @@ describe("pipInterceptor", async () => { }); it("should block malicious package", async () => { + scannedPackages = []; const url = "https://files.pythonhosted.org/packages/xx/yy/malicious_package-1.0.0.tar.gz"; malwareResponse = true; diff --git a/packages/safe-chain/src/scanning/packageNameVariants.js b/packages/safe-chain/src/scanning/packageNameVariants.js index 19c0c32..97db91b 100644 --- a/packages/safe-chain/src/scanning/packageNameVariants.js +++ b/packages/safe-chain/src/scanning/packageNameVariants.js @@ -10,7 +10,10 @@ export function getEquivalentPackageNames(packageName, ecosystem) { return [packageName]; } - return [...new Set([packageName, ...["-", "_", "."].map((separator) => - packageName.replaceAll(/[._-]/g, separator) - )])]; + const pythonSeparatorPattern = /[._-]/g; + const hyphenName = packageName.replaceAll(pythonSeparatorPattern, "-"); + const underscoreName = packageName.replaceAll(pythonSeparatorPattern, "_"); + const dotName = packageName.replaceAll(pythonSeparatorPattern, "."); + + return [...new Set([packageName, hyphenName, underscoreName, dotName])]; } From 99e822d5099fcc649c75736b86a9ffba6bb68a76 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 30 Mar 2026 12:03:36 +0200 Subject: [PATCH 222/360] Rename safe-chain ultimate to Aikido Endpoint --- install-scripts/install-endpoint-mac.sh | 14 +- install-scripts/install-endpoint-windows.ps1 | 14 +- install-scripts/uninstall-endpoint-mac.sh | 10 +- .../uninstall-endpoint-windows.ps1 | 12 +- .../src/installation/downloadAgent.js | 125 ----------- .../src/installation/downloadAgent.spec.js | 56 ----- .../src/installation/installOnMacOS.js | 155 ------------- .../src/installation/installOnWindows.js | 203 ------------------ .../src/installation/installUltimate.js | 35 --- 9 files changed, 25 insertions(+), 599 deletions(-) mode change 100644 => 100755 install-scripts/install-endpoint-mac.sh mode change 100644 => 100755 install-scripts/uninstall-endpoint-mac.sh delete mode 100644 packages/safe-chain/src/installation/downloadAgent.js delete mode 100644 packages/safe-chain/src/installation/downloadAgent.spec.js delete mode 100644 packages/safe-chain/src/installation/installOnMacOS.js delete mode 100644 packages/safe-chain/src/installation/installOnWindows.js delete mode 100644 packages/safe-chain/src/installation/installUltimate.js diff --git a/install-scripts/install-endpoint-mac.sh b/install-scripts/install-endpoint-mac.sh old mode 100644 new mode 100755 index 684a8a8..9f3b1c0 --- a/install-scripts/install-endpoint-mac.sh +++ b/install-scripts/install-endpoint-mac.sh @@ -1,14 +1,14 @@ #!/bin/sh -# Downloads and installs SafeChain Ultimate endpoint on macOS +# Downloads and installs Aikido Endpoint Protection on macOS # # Usage: curl -fsSL | sudo sh -s -- --token set -e # Exit on error # Configuration -INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.5/SafeChainUltimate.pkg" -DOWNLOAD_SHA256="abc2b0e6c6a4ca33cd893eeb16744f9f2da90013fb1abac301f5c00c2ad8bc30" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.7/EndpointProtection.pkg" +DOWNLOAD_SHA256="2c180c575b6fbeb1e33b69cf8357a2a7dbf6868b5f98cfb82b83243daccc0cf9" TOKEN_FILE="/tmp/aikido_endpoint_token.txt" # Colors for output @@ -111,10 +111,10 @@ main() { esac # 2. Download and verify checksum - PKG_FILE=$(mktemp /tmp/SafeChainUltimate.XXXXXX.pkg) + PKG_FILE=$(mktemp /tmp/AikidoEndpoint.XXXXXX.pkg) trap cleanup EXIT - info "Downloading SafeChain Ultimate..." + info "Downloading Aikido Endpoint Protection..." download "$INSTALL_URL" "$PKG_FILE" info "Verifying checksum..." @@ -124,10 +124,10 @@ main() { printf "%s" "$TOKEN" > "$TOKEN_FILE" # 4. Install the package - info "Installing SafeChain Ultimate..." + info "Installing Aikido Endpoint Protection..." installer -pkg "$PKG_FILE" -target / - info "SafeChain Ultimate installed successfully!" + info "Aikido Endpoint Protection installed successfully!" } main "$@" diff --git a/install-scripts/install-endpoint-windows.ps1 b/install-scripts/install-endpoint-windows.ps1 index f99d1ff..4407d83 100644 --- a/install-scripts/install-endpoint-windows.ps1 +++ b/install-scripts/install-endpoint-windows.ps1 @@ -1,4 +1,4 @@ -# Downloads and installs SafeChain Ultimate endpoint on Windows +# Downloads and installs Aikido Endpoint Protection on Windows # # Usage: iex "& { $(iwr '' -UseBasicParsing) } -token " @@ -7,8 +7,8 @@ param( ) # Configuration -$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.5/SafeChainUltimate.msi" -$DownloadSha256 = "c4d1be7bb2128473b8e955244dc186b5d3f091f668b43cdd3d810cff9d38193c" +$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.7/EndpointProtection.msi" +$DownloadSha256 = "7bad18d7df9e0654d2edd16a52aea34b0455c3c6d8fb407362d0a86a77cb7d4f" # Ensure TLS 1.2 is enabled for downloads [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 @@ -53,9 +53,9 @@ function Install-Endpoint { } # 2. Download the .msi - $msiFile = Join-Path $env:TEMP "SafeChainUltimate-$([System.Guid]::NewGuid().ToString('N')).msi" + $msiFile = Join-Path $env:TEMP "AikidoEndpoint-$([System.Guid]::NewGuid().ToString('N')).msi" - Write-Info "Downloading SafeChain Ultimate..." + Write-Info "Downloading Aikido Endpoint Protection..." try { $ProgressPreference = 'SilentlyContinue' Invoke-WebRequest -Uri $InstallUrl -OutFile $msiFile -UseBasicParsing @@ -75,13 +75,13 @@ function Install-Endpoint { Write-Info "Checksum verified successfully." # 3. Install the package with token passed as MSI property - Write-Info "Installing SafeChain Ultimate..." + Write-Info "Installing Aikido Endpoint Protection..." $process = Start-Process -FilePath "msiexec" -ArgumentList "/i", "`"$msiFile`"", "/qn", "/norestart", "AIKIDO_TOKEN=$token" -Wait -PassThru if ($process.ExitCode -ne 0) { Write-Error-Custom "MSI installer failed (exit code: $($process.ExitCode))." } - Write-Info "SafeChain Ultimate installed successfully!" + Write-Info "Aikido Endpoint Protection installed successfully!" } finally { # Cleanup diff --git a/install-scripts/uninstall-endpoint-mac.sh b/install-scripts/uninstall-endpoint-mac.sh old mode 100644 new mode 100755 index b1ba6e4..6da0f17 --- a/install-scripts/uninstall-endpoint-mac.sh +++ b/install-scripts/uninstall-endpoint-mac.sh @@ -1,13 +1,13 @@ #!/bin/sh -# Uninstalls SafeChain Ultimate endpoint on macOS +# Uninstalls Aikido Endpoint Protection on macOS # # Usage: curl -fsSL | sudo sh set -e # Exit on error # Configuration -UNINSTALL_SCRIPT="/Library/Application Support/AikidoSecurity/SafeChainUltimate/scripts/uninstall" +UNINSTALL_SCRIPT="/Library/Application Support/AikidoSecurity/EndpointProtection/scripts/uninstall" # Colors for output RED='\033[0;31m' @@ -38,13 +38,13 @@ main() { # Check if the uninstall script exists if [ ! -f "$UNINSTALL_SCRIPT" ]; then - error "SafeChain Ultimate does not appear to be installed (uninstall script not found)." + error "Aikido Endpoint Protection does not appear to be installed (uninstall script not found)." fi - info "Uninstalling SafeChain Ultimate..." + info "Uninstalling Aikido Endpoint Protection..." "$UNINSTALL_SCRIPT" - info "SafeChain Ultimate uninstalled successfully!" + info "Aikido Endpoint Protection uninstalled successfully!" } main "$@" diff --git a/install-scripts/uninstall-endpoint-windows.ps1 b/install-scripts/uninstall-endpoint-windows.ps1 index 5de5bfe..90741c7 100644 --- a/install-scripts/uninstall-endpoint-windows.ps1 +++ b/install-scripts/uninstall-endpoint-windows.ps1 @@ -1,9 +1,9 @@ -# Uninstalls SafeChain Ultimate endpoint on Windows +# Uninstalls Aikido Endpoint Protection endpoint on Windows # # Usage: iex (iwr '' -UseBasicParsing) # Configuration -$AppName = "SafeChain Ultimate" +$AppName = "Aikido Endpoint Protection" # Helper functions function Write-Info { @@ -32,22 +32,22 @@ function Uninstall-Endpoint { } # Find the installed product - Write-Info "Looking for SafeChain Ultimate installation..." + Write-Info "Looking for Aikido Endpoint Protection installation..." $app = Get-WmiObject -Class Win32_Product -Filter "Name='$AppName'" if (-not $app) { - Write-Error-Custom "SafeChain Ultimate does not appear to be installed." + Write-Error-Custom "Aikido Endpoint Protection does not appear to be installed." } $productCode = $app.IdentifyingNumber - Write-Info "Uninstalling SafeChain Ultimate..." + Write-Info "Uninstalling Aikido Endpoint Protection..." $process = Start-Process -FilePath "msiexec" -ArgumentList "/x", $productCode, "/qn", "/norestart" -Wait -PassThru if ($process.ExitCode -ne 0) { Write-Error-Custom "Uninstall failed (exit code: $($process.ExitCode))." } - Write-Info "SafeChain Ultimate uninstalled successfully!" + Write-Info "Aikido Endpoint Protection uninstalled successfully!" } # Run uninstallation diff --git a/packages/safe-chain/src/installation/downloadAgent.js b/packages/safe-chain/src/installation/downloadAgent.js deleted file mode 100644 index 297908a..0000000 --- a/packages/safe-chain/src/installation/downloadAgent.js +++ /dev/null @@ -1,125 +0,0 @@ -import { createWriteStream, createReadStream } from "fs"; -import { createHash } from "crypto"; -import { pipeline } from "stream/promises"; -import fetch from "make-fetch-happen"; - -const ULTIMATE_VERSION = "v1.0.0"; - -export const DOWNLOAD_URLS = { - win32: { - x64: { - url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-windows-amd64.msi`, - checksum: - "sha256:c6a36f9b8e55ab6b7e8742cbabc4469d85809237c0f5e6c21af20b36c416ee1d", - }, - arm64: { - url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-windows-arm64.msi`, - checksum: - "sha256:46acd1af6a9938ea194c8ee8b34ca9b47c8de22e088a0791f3c0751dd6239c90", - }, - }, - darwin: { - x64: { - url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-darwin-amd64.pkg`, - checksum: - "sha256:bb1829e8ca422e885baf37bef08dcbe7df7a30f248e2e89c4071564f7d4f3396", - }, - arm64: { - url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-darwin-arm64.pkg`, - checksum: - "sha256:7fe4a785709911cc366d8224b4c290677573b8c4833bd9054768299e55c5f0ed", - }, - }, -}; - -/** - * Builds the download URL for the SafeChain Agent installer. - * @param {string} fileName - */ -export function getAgentDownloadUrl(fileName) { - return `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/${fileName}`; -} - -/** - * Downloads a file from a URL to a local path. - * @param {string} url - * @param {string} destPath - */ -export async function downloadFile(url, destPath) { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Download failed: ${response.statusText}`); - } - await pipeline(response.body, createWriteStream(destPath)); -} - -/** - * Returns the current agent version. - */ -export function getAgentVersion() { - return ULTIMATE_VERSION; -} - -/** - * Returns download info (url, checksum) for the current OS and architecture. - * @returns {{ url: string, checksum: string } | null} - */ -export function getDownloadInfoForCurrentPlatform() { - const platform = process.platform; - const arch = process.arch; - - if (!Object.hasOwn(DOWNLOAD_URLS, platform)) { - return null; - } - const platformUrls = - DOWNLOAD_URLS[/** @type {keyof typeof DOWNLOAD_URLS} */ (platform)]; - - if (!Object.hasOwn(platformUrls, arch)) { - return null; - } - - return platformUrls[/** @type {keyof typeof platformUrls} */ (arch)]; -} - -/** - * Verifies the checksum of a file. - * @param {string} filePath - * @param {string} expectedChecksum - Format: "algorithm:hash" (e.g., "sha256:abc123...") - * @returns {Promise} - */ -export async function verifyChecksum(filePath, expectedChecksum) { - const [algorithm, expected] = expectedChecksum.split(":"); - - const hash = createHash(algorithm); - - if (filePath.includes("..")) throw new Error("Invalid file path"); - const stream = createReadStream(filePath); - - for await (const chunk of stream) { - hash.update(chunk); - } - - const actual = hash.digest("hex"); - return actual === expected; -} - -/** - * Downloads the SafeChain agent for the current OS/arch and verifies its checksum. - * @param {string} fileName - Destination file path - * @returns {Promise} The file path if successful, null if no download URL for current platform - */ -export async function downloadAgentToFile(fileName) { - const info = getDownloadInfoForCurrentPlatform(); - if (!info) { - return null; - } - - await downloadFile(info.url, fileName); - - const isValid = await verifyChecksum(fileName, info.checksum); - if (!isValid) { - throw new Error("Checksum verification failed"); - } - - return fileName; -} diff --git a/packages/safe-chain/src/installation/downloadAgent.spec.js b/packages/safe-chain/src/installation/downloadAgent.spec.js deleted file mode 100644 index 44e53c0..0000000 --- a/packages/safe-chain/src/installation/downloadAgent.spec.js +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, it, after } from "node:test"; -import assert from "node:assert"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { unlinkSync, writeFileSync } from "node:fs"; -import { createHash } from "node:crypto"; -import { - DOWNLOAD_URLS, - verifyChecksum, -} from "./downloadAgent.js"; - -describe("downloadAgent", () => { - const tempFiles = []; - - after(() => { - for (const file of tempFiles) { - try { - unlinkSync(file); - } catch { - // ignore cleanup errors - } - } - }); - - for (const [platform, architectures] of Object.entries(DOWNLOAD_URLS)) { - for (const [arch, { url, checksum }] of Object.entries(architectures)) { - it(`${platform}/${arch} has a valid download definition`, () => { - assert.match( - url, - /^https:\/\/github\.com\/AikidoSec\/safechain-internals\/releases\/download\/v\d+\.\d+\.\d+\/.+/, - ); - assert.match(checksum, /^sha256:[a-f0-9]{64}$/); - }); - } - } - - it("verifies checksum for a local file", async () => { - const destPath = join(tmpdir(), `safe-chain-test-${Date.now()}`); - tempFiles.push(destPath); - - writeFileSync(destPath, "safe-chain-test"); - - const expectedHash = createHash("sha256") - .update("safe-chain-test") - .digest("hex"); - - assert.equal( - await verifyChecksum(destPath, `sha256:${expectedHash}`), - true, - ); - assert.equal( - await verifyChecksum(destPath, `sha256:${"0".repeat(64)}`), - false, - ); - }); -}); diff --git a/packages/safe-chain/src/installation/installOnMacOS.js b/packages/safe-chain/src/installation/installOnMacOS.js deleted file mode 100644 index 22ce1a8..0000000 --- a/packages/safe-chain/src/installation/installOnMacOS.js +++ /dev/null @@ -1,155 +0,0 @@ -import { tmpdir } from "os"; -import { unlinkSync } from "fs"; -import { join } from "path"; -import { execSync, spawnSync } from "child_process"; -import { ui } from "../environment/userInteraction.js"; -import { printVerboseAndSafeSpawn } from "../utils/safeSpawn.js"; -import { downloadAgentToFile, getAgentVersion } from "./downloadAgent.js"; -import chalk from "chalk"; - -const MACOS_PKG_IDENTIFIER = "com.aikidosecurity.safechainultimate"; - -/** - * Checks if root privileges are available and displays error message if not. - * @param {string} command - The sudo command to show in the error message - * @returns {boolean} True if running as root, false otherwise. - */ -function requireRootPrivileges(command) { - if (isRunningAsRoot()) { - return true; - } - - ui.writeError("Root privileges required."); - ui.writeInformation("Please run this command with sudo:"); - ui.writeInformation(` ${command}`); - return false; -} - -function isRunningAsRoot() { - const rootUserUid = 0; - return process.getuid?.() === rootUserUid; -} - -export async function installOnMacOS() { - if (!requireRootPrivileges("sudo safe-chain ultimate")) { - return; - } - - const pkgPath = join(tmpdir(), `SafeChainUltimate-${Date.now()}.pkg`); - - ui.emptyLine(); - ui.writeInformation(`📥 Downloading SafeChain Ultimate ${getAgentVersion()}`); - ui.writeVerbose(`Destination: ${pkgPath}`); - - const result = await downloadAgentToFile(pkgPath); - if (!result) { - ui.writeError("No download available for this platform/architecture."); - return; - } - - try { - ui.writeInformation("⚙️ Installing SafeChain Ultimate..."); - await runPkgInstaller(pkgPath); - - ui.emptyLine(); - ui.writeInformation( - "✅ SafeChain Ultimate installed and started successfully!", - ); - ui.emptyLine(); - ui.writeInformation( - chalk.cyan("🔐 ") + - chalk.bold("ACTION REQUIRED: ") + - "macOS will show a popup to install our certificate.", - ); - ui.writeInformation( - " " + - chalk.bold("Please accept the certificate") + - " to complete the installation.", - ); - ui.emptyLine(); - } finally { - ui.writeVerbose(`Cleaning up temporary file: ${pkgPath}`); - cleanup(pkgPath); - } -} - -const MACOS_UNINSTALL_SCRIPT = - "/Library/Application\\ Support/AikidoSecurity/SafeChainUltimate/scripts/uninstall"; - -export async function uninstallOnMacOS() { - if (!requireRootPrivileges("sudo safe-chain ultimate uninstall")) { - return; - } - - ui.emptyLine(); - - if (!isPackageInstalled()) { - ui.writeInformation("SafeChain Ultimate is not installed."); - return; - } - - ui.writeInformation("🗑️ Uninstalling SafeChain Ultimate..."); - ui.writeVerbose(`Running: ${MACOS_UNINSTALL_SCRIPT}`); - - const result = spawnSync(MACOS_UNINSTALL_SCRIPT, { - stdio: "inherit", - shell: true, - }); - - if (result.status !== 0) { - ui.writeError( - `Uninstall script failed (exit code: ${result.status}). Please try again or remove manually.`, - ); - return; - } - - ui.emptyLine(); - ui.writeInformation("✅ SafeChain Ultimate has been uninstalled."); - ui.emptyLine(); -} - -function isPackageInstalled() { - try { - const output = execSync(`pkgutil --pkg-info ${MACOS_PKG_IDENTIFIER}`, { - encoding: "utf8", - stdio: "pipe", - }); - return output.includes(MACOS_PKG_IDENTIFIER); - } catch { - return false; - } -} - -/** - * @param {string} pkgPath - */ -async function runPkgInstaller(pkgPath) { - // Uses installer to install the package (https://ss64.com/mac/installer.html) - // Options: - // -pkg (required): The package to be installed. - // -target (required): The target volume is specified with the -target parameter. - // --> "-target /" installs to the current boot volume. - - const result = await printVerboseAndSafeSpawn( - "installer", - ["-pkg", pkgPath, "-target", "/"], - { - stdio: "inherit", - }, - ); - - if (result.status !== 0) { - throw new Error(`PKG installer failed (exit code: ${result.status})`); - } -} - -/** - * @param {string} pkgPath - */ -function cleanup(pkgPath) { - try { - unlinkSync(pkgPath); - } catch { - ui.writeVerbose("Failed to clean up temporary installer file."); - } -} diff --git a/packages/safe-chain/src/installation/installOnWindows.js b/packages/safe-chain/src/installation/installOnWindows.js deleted file mode 100644 index 4cee911..0000000 --- a/packages/safe-chain/src/installation/installOnWindows.js +++ /dev/null @@ -1,203 +0,0 @@ -import { tmpdir } from "os"; -import { unlinkSync } from "fs"; -import { join } from "path"; -import { execSync } from "child_process"; -import { ui } from "../environment/userInteraction.js"; -import { printVerboseAndSafeSpawn, safeSpawn } from "../utils/safeSpawn.js"; -import { downloadAgentToFile, getAgentVersion } from "./downloadAgent.js"; - -const WINDOWS_SERVICE_NAME = "SafeChainUltimate"; -const WINDOWS_APP_NAME = "SafeChain Ultimate"; - -export async function uninstallOnWindows() { - if (!(await requireAdminPrivileges())) { - return; - } - - ui.emptyLine(); - - const productCode = getInstalledProductCode(); - if (!productCode) { - ui.writeInformation("SafeChain Ultimate is not installed."); - return; - } - - await stopServiceIfRunning(); - - ui.writeInformation("🗑️ Uninstalling SafeChain Ultimate..."); - await uninstallByProductCode(productCode); - - ui.emptyLine(); - ui.writeInformation("✅ SafeChain Ultimate has been uninstalled."); - ui.emptyLine(); -} - -export async function installOnWindows() { - if (!(await requireAdminPrivileges())) { - return; - } - - const msiPath = join(tmpdir(), `SafeChainUltimate-${Date.now()}.msi`); - - ui.emptyLine(); - ui.writeInformation(`📥 Downloading SafeChain Ultimate ${getAgentVersion()}`); - ui.writeVerbose(`Destination: ${msiPath}`); - - const result = await downloadAgentToFile(msiPath); - if (!result) { - ui.writeError("No download available for this platform/architecture."); - return; - } - - try { - ui.emptyLine(); - await stopServiceIfRunning(); - await uninstallIfInstalled(); - - ui.writeInformation("⚙️ Installing SafeChain Ultimate..."); - await runMsiInstaller(msiPath); - - ui.emptyLine(); - ui.writeInformation( - "✅ SafeChain Ultimate installed and started successfully!", - ); - ui.emptyLine(); - } finally { - ui.writeVerbose(`Cleaning up temporary file: ${msiPath}`); - cleanup(msiPath); - } -} - -/** - * Checks if admin privileges are available and displays error message if not. - * @returns {Promise} True if running as admin, false otherwise. - */ -async function requireAdminPrivileges() { - if (await isRunningAsAdmin()) { - return true; - } - - ui.writeError("Administrator privileges required."); - ui.writeInformation( - "Please run this command in an elevated terminal (Run as Administrator).", - ); - return false; -} - -async function isRunningAsAdmin() { - // Uses Windows Security API to check if current process has admin privileges. - // Returns "True" or "False" as a string. - const result = await safeSpawn( - "powershell", - [ - "-Command", - "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)", - ], - { stdio: "pipe" }, - ); - - return result.status === 0 && result.stdout.trim() === "True"; -} - -/** - * Returns the MSI product code for SafeChain Ultimate, or null if not installed. - * @returns {string | null} - */ -function getInstalledProductCode() { - // Query Win32_Product via WMI to find the installed SafeChain Agent. - // If found, outputs the product GUID (e.g., "{12345678-1234-...}") needed for msiexec uninstall. - ui.writeVerbose(`Finding product code with PowerShell`); - - let productCode; - try { - productCode = execSync( - `powershell -Command "$app = Get-WmiObject -Class Win32_Product -Filter \\"Name='${WINDOWS_APP_NAME}'\\"; if ($app) { Write-Output $app.IdentifyingNumber }"`, - { encoding: "utf8" }, - ).trim(); - } catch { - return null; - } - return productCode || null; -} - -/** - * @param {string} productCode - */ -async function uninstallByProductCode(productCode) { - ui.writeVerbose(`Found product code: ${productCode}`); - - // Use msiexec to run the msi installer quitely (https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/msiexec) - // Options: - // - /x: Uninstalls the package. - // - /qn: Specifies there's no UI during the installation process. - // - /norestart: Stops the device from restarting after the installation completes. - const uninstallResult = await printVerboseAndSafeSpawn( - "msiexec", - ["/x", productCode, "/qn", "/norestart"], - { stdio: "inherit" }, - ); - - if (uninstallResult.status !== 0) { - throw new Error(`Uninstall failed (exit code: ${uninstallResult.status})`); - } -} - -async function uninstallIfInstalled() { - const productCode = getInstalledProductCode(); - if (!productCode) { - ui.writeVerbose("No existing installation found (fresh install)."); - return; - } - - ui.writeInformation("🗑️ Removing previous installation..."); - await uninstallByProductCode(productCode); -} - -/** - * @param {string} msiPath - */ -async function runMsiInstaller(msiPath) { - // Use msiexec to run the msi installer quitely (https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/msiexec) - // Options: - // - /i: Specifies normal installation - // - /qn: Specifies there's no UI during the installation process. - - const result = await printVerboseAndSafeSpawn( - "msiexec", - ["/i", msiPath, "/qn"], - { - stdio: "inherit", - }, - ); - - if (result.status !== 0) { - throw new Error(`MSI installer failed (exit code: ${result.status})`); - } -} - -async function stopServiceIfRunning() { - ui.writeInformation("⏹️ Stopping running service..."); - - const result = await printVerboseAndSafeSpawn( - "net", - ["stop", WINDOWS_SERVICE_NAME], - { - stdio: "pipe", - }, - ); - - if (result.status !== 0) { - ui.writeVerbose("Service not running (will start after installation)."); - } -} - -/** - * @param {string} msiPath - */ -function cleanup(msiPath) { - try { - unlinkSync(msiPath); - } catch { - ui.writeVerbose("Failed to clean up temporary installer file."); - } -} diff --git a/packages/safe-chain/src/installation/installUltimate.js b/packages/safe-chain/src/installation/installUltimate.js deleted file mode 100644 index 257c953..0000000 --- a/packages/safe-chain/src/installation/installUltimate.js +++ /dev/null @@ -1,35 +0,0 @@ -import { platform } from "os"; -import { ui } from "../environment/userInteraction.js"; -import { initializeCliArguments } from "../config/cliArguments.js"; -import { installOnWindows, uninstallOnWindows } from "./installOnWindows.js"; -import { installOnMacOS, uninstallOnMacOS } from "./installOnMacOS.js"; - -export async function uninstallUltimate() { - initializeCliArguments(process.argv); - - const operatingSystem = platform(); - - if (operatingSystem === "win32") { - await uninstallOnWindows(); - } else if (operatingSystem === "darwin") { - await uninstallOnMacOS(); - } else { - ui.writeInformation( - `Uninstall is not yet supported on ${operatingSystem}.`, - ); - } -} - -export async function installUltimate() { - const operatingSystem = platform(); - - if (operatingSystem === "win32") { - await installOnWindows(); - } else if (operatingSystem === "darwin") { - await installOnMacOS(); - } else { - ui.writeInformation( - `${operatingSystem} is not supported yet by SafeChain's ultimate version.`, - ); - } -} From 2ba6aaa46ec651dbf19fbdf34ec89cc61647bd40 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 30 Mar 2026 07:58:14 -0700 Subject: [PATCH 223/360] Adapt per review --- .../interceptors/pip/parsePipPackageUrl.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js b/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js index e96664a..377a648 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js @@ -1,4 +1,10 @@ /** + * Parse Python package artifact URLs from PyPI-style registries. + * Examples: + * - Wheel: https://files.pythonhosted.org/packages/.../requests-2.28.1-py3-none-any.whl + * - Wheel metadata: https://files.pythonhosted.org/packages/.../requests-2.28.1-py3-none-any.whl.metadata + * - Sdist: https://files.pythonhosted.org/packages/.../requests-2.28.1.tar.gz + * * @param {string} url * @param {string} registry * @returns {{packageName: string | undefined, version: string | undefined}} @@ -36,6 +42,11 @@ export function parsePipPackageFromUrl(url, registry) { } /** + * Parse wheel filenames and Poetry preflight metadata. + * Examples: + * - foo_bar-2.0.0-py3-none-any.whl + * - foo_bar-2.0.0-py3-none-any.whl.metadata + * * @param {string} filename * @param {RegExp} wheelExtRe * @returns {{packageName: string | undefined, version: string | undefined}} @@ -52,6 +63,7 @@ function parseWheelFilename(filename, wheelExtRe) { const secondDash = rest.indexOf("-"); const version = secondDash >= 0 ? rest.slice(0, secondDash) : rest; + // "latest" is a resolver-style token, not an actual published artifact version. if (version === "latest" || !packageName || !version) { return { packageName: undefined, version: undefined }; } @@ -60,6 +72,12 @@ function parseWheelFilename(filename, wheelExtRe) { } /** + * Parse source distribution filenames, with optional metadata suffix. + * Examples: + * - requests-2.28.1.tar.gz + * - requests-2.28.1.zip + * - requests-2.28.1.tar.gz.metadata + * * @param {string} filename * @param {RegExp} sdistExtWithMetadataRe * @returns {{packageName: string | undefined, version: string | undefined}} @@ -74,6 +92,7 @@ function parseSdistFilename(filename, sdistExtWithMetadataRe) { const packageName = base.slice(0, lastDash); const version = base.slice(lastDash + 1); + // "latest" is a resolver-style token, not an actual published artifact version. if (version === "latest" || !packageName || !version) { return { packageName: undefined, version: undefined }; } From 8810544c7c31dd018b1e11aca4fe9f4e0dd453a0 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 31 Mar 2026 08:08:33 +0200 Subject: [PATCH 224/360] Update Aikido Endpoint version to 1.2.8 --- 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 9f3b1c0..249ba79 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.7/EndpointProtection.pkg" -DOWNLOAD_SHA256="2c180c575b6fbeb1e33b69cf8357a2a7dbf6868b5f98cfb82b83243daccc0cf9" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.8/EndpointProtection.pkg" +DOWNLOAD_SHA256="e298864e9f41f9f1e6713f351d6b314a7fea7c420f52cca26eb262e50f38e165" 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 4407d83..e614abc 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.7/EndpointProtection.msi" -$DownloadSha256 = "7bad18d7df9e0654d2edd16a52aea34b0455c3c6d8fb407362d0a86a77cb7d4f" +$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.8/EndpointProtection.msi" +$DownloadSha256 = "1ac608cfcb6af8bdb00e857296f8ad4c7ed8c1ac8e956ea6da00bbef4732fd08" # Ensure TLS 1.2 is enabled for downloads [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 From 136e66b1d01abdd8a01941acd563ca16ffb08311 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 31 Mar 2026 09:59:08 +0200 Subject: [PATCH 225/360] Pin axios version in tests --- test/e2e/bun.e2e.spec.js | 2 +- test/e2e/certbundle.e2e.spec.js | 26 +++++++++++++------------- test/e2e/npm-ci.e2e.spec.js | 2 +- test/e2e/npm.e2e.spec.js | 2 +- test/e2e/pnpm-ci.e2e.spec.js | 2 +- test/e2e/setup-ci.e2e.spec.js | 2 +- test/e2e/setup.teardown.e2e.spec.js | 6 +++--- test/e2e/yarn-ci.e2e.spec.js | 2 +- test/e2e/yarn.e2e.spec.js | 2 +- 9 files changed, 23 insertions(+), 23 deletions(-) diff --git a/test/e2e/bun.e2e.spec.js b/test/e2e/bun.e2e.spec.js index 044b300..fb6e99a 100644 --- a/test/e2e/bun.e2e.spec.js +++ b/test/e2e/bun.e2e.spec.js @@ -29,7 +29,7 @@ describe("E2E: bun coverage", () => { it(`safe-chain succesfully installs safe packages`, async () => { const shell = await container.openShell("bash"); const result = await shell.runCommand( - "bun i axios --safe-chain-logging=verbose" + "bun i axios@1.13.0 --safe-chain-logging=verbose" ); assert.ok( diff --git a/test/e2e/certbundle.e2e.spec.js b/test/e2e/certbundle.e2e.spec.js index 4b4ad84..9c5102b 100644 --- a/test/e2e/certbundle.e2e.spec.js +++ b/test/e2e/certbundle.e2e.spec.js @@ -32,7 +32,7 @@ describe("E2E: NODE_EXTRA_CA_CERTS merging", () => { // Ensure NODE_EXTRA_CA_CERTS is not set await shell.runCommand("unset NODE_EXTRA_CA_CERTS"); - const result = await shell.runCommand("npm install axios"); + const result = await shell.runCommand("npm install axios@1.13.0"); assert.ok( result.output.includes("added") || result.output.includes("up to date"), @@ -55,7 +55,7 @@ describe("E2E: NODE_EXTRA_CA_CERTS merging", () => { // Set NODE_EXTRA_CA_CERTS and run npm install const result = await shell.runCommand( - "NODE_EXTRA_CA_CERTS=/tmp/valid-certs.pem npm install axios" + "NODE_EXTRA_CA_CERTS=/tmp/valid-certs.pem npm install axios@1.13.0" ); assert.ok( @@ -69,7 +69,7 @@ describe("E2E: NODE_EXTRA_CA_CERTS merging", () => { // Set NODE_EXTRA_CA_CERTS to a non-existent path const result = await shell.runCommand( - 'export NODE_EXTRA_CA_CERTS="/tmp/nonexistent-certs.pem" && npm install axios' + 'export NODE_EXTRA_CA_CERTS="/tmp/nonexistent-certs.pem" && npm install axios@1.13.0' ); // Should still succeed - safe-chain should gracefully handle missing user certs @@ -95,7 +95,7 @@ describe("E2E: NODE_EXTRA_CA_CERTS merging", () => { // Set NODE_EXTRA_CA_CERTS to invalid cert const result = await shell.runCommand( - 'export NODE_EXTRA_CA_CERTS="/tmp/invalid-certs.pem" && npm install axios' + 'export NODE_EXTRA_CA_CERTS="/tmp/invalid-certs.pem" && npm install axios@1.13.0' ); // Should still succeed - safe-chain should skip invalid user certs @@ -116,7 +116,7 @@ describe("E2E: NODE_EXTRA_CA_CERTS merging", () => { // Try to set NODE_EXTRA_CA_CERTS with path traversal const result = await shell.runCommand( - 'export NODE_EXTRA_CA_CERTS="/tmp/../../../etc/passwd" && npm install axios' + 'export NODE_EXTRA_CA_CERTS="/tmp/../../../etc/passwd" && npm install axios@1.13.0' ); // Should still succeed - safe-chain should reject path traversal @@ -133,7 +133,7 @@ describe("E2E: NODE_EXTRA_CA_CERTS merging", () => { await shell.runCommand("touch /tmp/empty-certs.pem"); const result = await shell.runCommand( - 'export NODE_EXTRA_CA_CERTS="/tmp/empty-certs.pem" && npm install axios' + 'export NODE_EXTRA_CA_CERTS="/tmp/empty-certs.pem" && npm install axios@1.13.0' ); // Should still succeed - empty file should be ignored gracefully @@ -150,7 +150,7 @@ describe("E2E: NODE_EXTRA_CA_CERTS merging", () => { await shell.runCommand("mkdir -p /tmp/cert-dir"); const result = await shell.runCommand( - 'export NODE_EXTRA_CA_CERTS="/tmp/cert-dir" && npm install axios' + 'export NODE_EXTRA_CA_CERTS="/tmp/cert-dir" && npm install axios@1.13.0' ); // Should still succeed - directory should be treated as invalid cert file @@ -169,7 +169,7 @@ describe("E2E: NODE_EXTRA_CA_CERTS merging", () => { ); const result = await shell.runCommand( - 'cd /tmp/cert-test && export NODE_EXTRA_CA_CERTS="./certs.pem" && npm install axios' + 'cd /tmp/cert-test && export NODE_EXTRA_CA_CERTS="./certs.pem" && npm install axios@1.13.0' ); // Should still succeed - relative paths should be resolved properly @@ -186,7 +186,7 @@ describe("E2E: NODE_EXTRA_CA_CERTS merging", () => { await shell.runCommand("cp /etc/ssl/certs/ca-certificates.crt /tmp/absolute-certs.pem"); const result = await shell.runCommand( - "NODE_EXTRA_CA_CERTS=/tmp/absolute-certs.pem npm install axios" + "NODE_EXTRA_CA_CERTS=/tmp/absolute-certs.pem npm install axios@1.13.0" ); assert.ok( @@ -202,7 +202,7 @@ describe("E2E: NODE_EXTRA_CA_CERTS merging", () => { await shell.runCommand("cp /etc/ssl/certs/ca-certificates.crt /tmp/merge-certs.pem"); const result = await shell.runCommand( - "NODE_EXTRA_CA_CERTS=/tmp/merge-certs.pem npm install axios lodash" + "NODE_EXTRA_CA_CERTS=/tmp/merge-certs.pem npm install axios@1.13.0 lodash" ); assert.ok( @@ -306,7 +306,7 @@ describe("E2E: NODE_EXTRA_CA_CERTS merging", () => { await shell.runCommand("cp /etc/ssl/certs/ca-certificates.crt /tmp/yarn-certs.pem"); const result = await shell.runCommand( - "NODE_EXTRA_CA_CERTS=/tmp/yarn-certs.pem yarn add axios" + "NODE_EXTRA_CA_CERTS=/tmp/yarn-certs.pem yarn add axios@1.13.0" ); assert.ok( @@ -322,7 +322,7 @@ describe("E2E: NODE_EXTRA_CA_CERTS merging", () => { await shell.runCommand("cp /etc/ssl/certs/ca-certificates.crt /tmp/pnpm-certs.pem"); const result = await shell.runCommand( - "NODE_EXTRA_CA_CERTS=/tmp/pnpm-certs.pem pnpm add axios" + "NODE_EXTRA_CA_CERTS=/tmp/pnpm-certs.pem pnpm add axios@1.13.0" ); assert.ok( @@ -336,7 +336,7 @@ describe("E2E: NODE_EXTRA_CA_CERTS merging", () => { // Create valid cert and run bun in the same command to ensure file exists const result = await shell.runCommand( - "cp /etc/ssl/certs/ca-certificates.crt /tmp/bun-certs.pem && NODE_EXTRA_CA_CERTS=/tmp/bun-certs.pem bun i axios" + "cp /etc/ssl/certs/ca-certificates.crt /tmp/bun-certs.pem && NODE_EXTRA_CA_CERTS=/tmp/bun-certs.pem bun i axios@1.13.0" ); assert.ok( diff --git a/test/e2e/npm-ci.e2e.spec.js b/test/e2e/npm-ci.e2e.spec.js index b78b7ab..1698759 100644 --- a/test/e2e/npm-ci.e2e.spec.js +++ b/test/e2e/npm-ci.e2e.spec.js @@ -34,7 +34,7 @@ describe("E2E: npm coverage using PATH", () => { it(`safe-chain succesfully installs safe packages`, async () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand( - "npm i axios --safe-chain-logging=verbose" + "npm i axios@1.13.0 --safe-chain-logging=verbose" ); assert.ok( diff --git a/test/e2e/npm.e2e.spec.js b/test/e2e/npm.e2e.spec.js index 02bd6ca..e8ba7c8 100644 --- a/test/e2e/npm.e2e.spec.js +++ b/test/e2e/npm.e2e.spec.js @@ -29,7 +29,7 @@ describe("E2E: npm coverage", () => { it(`safe-chain succesfully installs safe packages`, async () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand( - "npm i axios --safe-chain-logging=verbose" + "npm i axios@1.13.0 --safe-chain-logging=verbose" ); assert.ok( diff --git a/test/e2e/pnpm-ci.e2e.spec.js b/test/e2e/pnpm-ci.e2e.spec.js index 29b9d0f..a56bb77 100644 --- a/test/e2e/pnpm-ci.e2e.spec.js +++ b/test/e2e/pnpm-ci.e2e.spec.js @@ -34,7 +34,7 @@ describe("E2E: pnpm coverage", () => { it(`safe-chain succesfully installs safe packages`, async () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand( - "pnpm add axios --safe-chain-logging=verbose" + "pnpm add axios@1.13.0 --safe-chain-logging=verbose" ); assert.ok( diff --git a/test/e2e/setup-ci.e2e.spec.js b/test/e2e/setup-ci.e2e.spec.js index 70aac68..7237b1a 100644 --- a/test/e2e/setup-ci.e2e.spec.js +++ b/test/e2e/setup-ci.e2e.spec.js @@ -40,7 +40,7 @@ describe("E2E: safe-chain setup-ci command", () => { const projectShell = await container.openShell(shell); const result = await projectShell.runCommand( - "npm i axios --safe-chain-logging=verbose" + "npm i axios@1.13.0 --safe-chain-logging=verbose" ); const hasExpectedOutput = result.output.includes("Safe-chain: Scanned"); diff --git a/test/e2e/setup.teardown.e2e.spec.js b/test/e2e/setup.teardown.e2e.spec.js index c6ae337..0ddfaf4 100644 --- a/test/e2e/setup.teardown.e2e.spec.js +++ b/test/e2e/setup.teardown.e2e.spec.js @@ -30,7 +30,7 @@ describe("E2E: safe-chain setup command", () => { const projectShell = await container.openShell(shell); await projectShell.runCommand("cd /testapp"); const result = await projectShell.runCommand( - "npm i axios --safe-chain-logging=verbose" + "npm i axios@1.13.0 --safe-chain-logging=verbose" ); const hasExpectedOutput = result.output.includes("Safe-chain: Scanned"); @@ -50,8 +50,8 @@ describe("E2E: safe-chain setup command", () => { const projectShell = await container.openShell(shell); await projectShell.runCommand("cd /testapp"); - await projectShell.runCommand("npm i axios"); - const result = await projectShell.runCommand("npm i axios"); + await projectShell.runCommand("npm i axios@1.13.0"); + const result = await projectShell.runCommand("npm i axios@1.13.0"); assert.ok( !result.output.includes("Scanning for malicious packages..."), diff --git a/test/e2e/yarn-ci.e2e.spec.js b/test/e2e/yarn-ci.e2e.spec.js index 88b768d..47e2120 100644 --- a/test/e2e/yarn-ci.e2e.spec.js +++ b/test/e2e/yarn-ci.e2e.spec.js @@ -34,7 +34,7 @@ describe("E2E: yarn coverage", () => { it(`safe-chain succesfully installs safe packages`, async () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand( - "yarn add axios --safe-chain-logging=verbose" + "yarn add axios@1.13.0 --safe-chain-logging=verbose" ); assert.ok( diff --git a/test/e2e/yarn.e2e.spec.js b/test/e2e/yarn.e2e.spec.js index 726fff2..5e56d12 100644 --- a/test/e2e/yarn.e2e.spec.js +++ b/test/e2e/yarn.e2e.spec.js @@ -29,7 +29,7 @@ describe("E2E: yarn coverage", () => { it(`safe-chain succesfully installs safe packages`, async () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand( - "yarn add axios --safe-chain-logging=verbose" + "yarn add axios@1.13.0 --safe-chain-logging=verbose" ); assert.ok( From 1abe5932adf3c20878e2f9c43fbb1205ddad62c5 Mon Sep 17 00:00:00 2001 From: 123Haynes <209302+123Haynes@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:52:26 +0000 Subject: [PATCH 226/360] add a configuration option for custom malwaredb and newpackagelist urls. --- README.md | 35 ++++++++ packages/safe-chain/src/api/aikido.js | 41 +++++---- packages/safe-chain/src/api/aikido.spec.js | 1 + .../safe-chain/src/config/cliArguments.js | 25 +++++- packages/safe-chain/src/config/configFile.js | 14 +++ .../src/config/environmentVariables.js | 10 +++ packages/safe-chain/src/config/settings.js | 27 ++++++ .../safe-chain/src/config/settings.spec.js | 85 +++++++++++++++++++ 8 files changed, 219 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index e173b66..fad26af 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,41 @@ You can set custom registries through environment variable or config file. Both } ``` +## 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. + +### Configuration Options + +You can set the malware list base URL through multiple sources (in order of priority): + +1. **CLI Argument** (highest priority): + + ```shell + npm install express --safe-chain-malware-list-base-url=https://your-mirror.com + ``` + +2. **Environment Variable**: + + ```shell + export SAFE_CHAIN_MALWARE_LIST_BASE_URL=https://your-mirror.com + npm install express + ``` + +3. **Config File** (`~/.safe-chain/config.json`): + + ```json + { + "malwareListBaseUrl": "https://your-mirror.com" + } + ``` + +The base URL should point to a server that mirrors the structure of `https://malware-list.aikido.dev/`, including the following paths: +- `/malware_predictions.json` (JavaScript ecosystem malware database) +- `/malware_pypi.json` (Python ecosystem malware database) +- `/releases/npm.json` (JavaScript new packages list) +- `/releases/pypi.json` (Python new packages list) + # Usage in CI/CD You can protect your CI/CD pipelines from malicious packages by integrating Aikido Safe Chain into your build process. This ensures that any packages installed during your automated builds are checked for malware before installation. diff --git a/packages/safe-chain/src/api/aikido.js b/packages/safe-chain/src/api/aikido.js index 0ceec21..91ed692 100644 --- a/packages/safe-chain/src/api/aikido.js +++ b/packages/safe-chain/src/api/aikido.js @@ -3,17 +3,18 @@ import { getEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY, + getMalwareListBaseUrl, } from "../config/settings.js"; import { ui } from "../environment/userInteraction.js"; -const malwareDatabaseUrls = { - [ECOSYSTEM_JS]: "https://malware-list.aikido.dev/malware_predictions.json", - [ECOSYSTEM_PY]: "https://malware-list.aikido.dev/malware_pypi.json", +const malwareDatabasePaths = { + [ECOSYSTEM_JS]: "malware_predictions.json", + [ECOSYSTEM_PY]: "malware_pypi.json", }; -const newPackagesListUrls = { - [ECOSYSTEM_JS]: "https://malware-list.aikido.dev/releases/npm.json", - [ECOSYSTEM_PY]: "https://malware-list.aikido.dev/releases/pypi.json", +const newPackagesListPaths = { + [ECOSYSTEM_JS]: "releases/npm.json", + [ECOSYSTEM_PY]: "releases/pypi.json", }; const DEFAULT_FETCH_RETRY_ATTEMPTS = 4; @@ -40,10 +41,11 @@ const DEFAULT_FETCH_RETRY_ATTEMPTS = 4; export async function fetchMalwareDatabase() { return retry(async () => { const ecosystem = getEcoSystem(); - const malwareDatabaseUrl = - malwareDatabaseUrls[ - /** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem) - ]; + const baseUrl = getMalwareListBaseUrl(); + const path = malwareDatabasePaths[ + /** @type {keyof typeof malwareDatabasePaths} */ (ecosystem) + ]; + const malwareDatabaseUrl = `${baseUrl}/${path}`; const response = await fetch(malwareDatabaseUrl); if (!response.ok) { throw new Error( @@ -69,10 +71,11 @@ export async function fetchMalwareDatabase() { export async function fetchMalwareDatabaseVersion() { return retry(async () => { const ecosystem = getEcoSystem(); - const malwareDatabaseUrl = - malwareDatabaseUrls[ - /** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem) - ]; + const baseUrl = getMalwareListBaseUrl(); + const path = malwareDatabasePaths[ + /** @type {keyof typeof malwareDatabasePaths} */ (ecosystem) + ]; + const malwareDatabaseUrl = `${baseUrl}/${path}`; const response = await fetch(malwareDatabaseUrl, { method: "HEAD", }); @@ -92,8 +95,9 @@ export async function fetchMalwareDatabaseVersion() { export async function fetchNewPackagesList() { return retry(async () => { const ecosystem = getEcoSystem(); - const url = - newPackagesListUrls[/** @type {keyof typeof newPackagesListUrls} */ (ecosystem)]; + const baseUrl = getMalwareListBaseUrl(); + const path = newPackagesListPaths[/** @type {keyof typeof newPackagesListPaths} */ (ecosystem)]; + const url = `${baseUrl}/${path}`; if (!url) { return { newPackagesList: [], version: undefined }; @@ -124,8 +128,9 @@ export async function fetchNewPackagesList() { export async function fetchNewPackagesListVersion() { return retry(async () => { const ecosystem = getEcoSystem(); - const url = - newPackagesListUrls[/** @type {keyof typeof newPackagesListUrls} */ (ecosystem)]; + const baseUrl = getMalwareListBaseUrl(); + const path = newPackagesListPaths[/** @type {keyof typeof newPackagesListPaths} */ (ecosystem)]; + const url = `${baseUrl}/${path}`; if (!url) { return undefined; diff --git a/packages/safe-chain/src/api/aikido.spec.js b/packages/safe-chain/src/api/aikido.spec.js index 0c6c7d9..8b8d2dc 100644 --- a/packages/safe-chain/src/api/aikido.spec.js +++ b/packages/safe-chain/src/api/aikido.spec.js @@ -22,6 +22,7 @@ describe("aikido API", async () => { getEcoSystem: () => ecosystem, ECOSYSTEM_JS: "js", ECOSYSTEM_PY: "py", + getMalwareListBaseUrl: () => "https://malware-list.aikido.dev", }, }); diff --git a/packages/safe-chain/src/config/cliArguments.js b/packages/safe-chain/src/config/cliArguments.js index 25013fb..918761c 100644 --- a/packages/safe-chain/src/config/cliArguments.js +++ b/packages/safe-chain/src/config/cliArguments.js @@ -1,12 +1,13 @@ import { ui } from "../environment/userInteraction.js"; /** - * @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, minimumPackageAgeHours: string | undefined}} + * @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, minimumPackageAgeHours: string | undefined, malwareListBaseUrl: string | undefined}} */ const state = { loggingLevel: undefined, skipMinimumPackageAge: undefined, minimumPackageAgeHours: undefined, + malwareListBaseUrl: undefined, }; const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-"; @@ -20,6 +21,7 @@ export function initializeCliArguments(args) { state.loggingLevel = undefined; state.skipMinimumPackageAge = undefined; state.minimumPackageAgeHours = undefined; + state.malwareListBaseUrl = undefined; const safeChainArgs = []; const remainingArgs = []; @@ -35,6 +37,7 @@ export function initializeCliArguments(args) { setLoggingLevel(safeChainArgs); setSkipMinimumPackageAge(safeChainArgs); setMinimumPackageAgeHours(safeChainArgs); + setMalwareListBaseUrl(safeChainArgs); checkDeprecatedPythonFlag(args); return remainingArgs; } @@ -109,6 +112,26 @@ export function getMinimumPackageAgeHours() { return state.minimumPackageAgeHours; } +/** + * @param {string[]} args + * @returns {void} + */ +function setMalwareListBaseUrl(args) { + const argName = SAFE_CHAIN_ARG_PREFIX + "malware-list-base-url="; + + const value = getLastArgEqualsValue(args, argName); + if (value) { + state.malwareListBaseUrl = value; + } +} + +/** + * @returns {string | undefined} + */ +export function getMalwareListBaseUrl() { + return state.malwareListBaseUrl; +} + /** * @param {string[]} args * @param {string} flagName diff --git a/packages/safe-chain/src/config/configFile.js b/packages/safe-chain/src/config/configFile.js index e132c90..3fb0f21 100644 --- a/packages/safe-chain/src/config/configFile.js +++ b/packages/safe-chain/src/config/configFile.js @@ -10,6 +10,7 @@ import { getEcoSystem } from "./settings.js"; * We cannot trust the input and should add the necessary validations * @property {unknown | Number} scanTimeout * @property {unknown | Number} minimumPackageAgeHours + * @property {unknown | string} malwareListBaseUrl * @property {unknown | SafeChainRegistryConfiguration} npm * @property {unknown | SafeChainRegistryConfiguration} pip * @@ -84,6 +85,18 @@ export function getMinimumPackageAgeHours() { return undefined; } +/** + * Gets the malware list base URL from config file only + * @returns {string | undefined} + */ +export function getMalwareListBaseUrl() { + const config = readConfigFile(); + if (config.malwareListBaseUrl && typeof config.malwareListBaseUrl === "string") { + return config.malwareListBaseUrl; + } + return undefined; +} + /** * Gets the custom npm registries from the config file (format parsing only, no validation) * @returns {string[]} @@ -214,6 +227,7 @@ function readConfigFile() { const emptyConfig = { scanTimeout: undefined, minimumPackageAgeHours: undefined, + malwareListBaseUrl: undefined, npm: { customRegistries: undefined, }, diff --git a/packages/safe-chain/src/config/environmentVariables.js b/packages/safe-chain/src/config/environmentVariables.js index 6ed041f..932eff7 100644 --- a/packages/safe-chain/src/config/environmentVariables.js +++ b/packages/safe-chain/src/config/environmentVariables.js @@ -45,3 +45,13 @@ export function getMinimumPackageAgeExclusions() { return process.env.SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS || process.env.SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS; } + +/** + * Gets the malware list base URL from environment variable + * Expected format: full URL without trailing slash + * Example: "https://malware-list.aikido.dev" + * @returns {string | undefined} + */ +export function getMalwareListBaseUrl() { + return process.env.SAFE_CHAIN_MALWARE_LIST_BASE_URL; +} diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index b864bf9..9171849 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -198,3 +198,30 @@ export function getMinimumPackageAgeExclusions() { const allExclusions = [...envExclusions, ...configExclusions]; return [...new Set(allExclusions)]; } + +/** + * Gets the malware list base URL with priority: CLI argument > environment variable > config file > default + * @returns {string} + */ +export function getMalwareListBaseUrl() { + // Priority 1: CLI argument + const cliValue = cliArguments.getMalwareListBaseUrl(); + if (cliValue) { + return cliValue; + } + + // Priority 2: Environment variable + const envValue = environmentVariables.getMalwareListBaseUrl(); + if (envValue) { + return envValue; + } + + // Priority 3: Config file + const configValue = configFile.getMalwareListBaseUrl(); + if (configValue) { + return configValue; + } + + // Default + return "https://malware-list.aikido.dev"; +} diff --git a/packages/safe-chain/src/config/settings.spec.js b/packages/safe-chain/src/config/settings.spec.js index 18b5156..64e1272 100644 --- a/packages/safe-chain/src/config/settings.spec.js +++ b/packages/safe-chain/src/config/settings.spec.js @@ -15,6 +15,7 @@ const { getNpmCustomRegistries, getPipCustomRegistries, getMinimumPackageAgeExclusions, + getMalwareListBaseUrl, setEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY, @@ -534,3 +535,87 @@ describe("getMinimumPackageAgeExclusions", () => { assert.deepStrictEqual(exclusions, ["requests", "urllib3"]); }); }); + +describe("getMalwareListBaseUrl", () => { + let originalEnv; + const envVarName = "SAFE_CHAIN_MALWARE_LIST_BASE_URL"; + + beforeEach(() => { + originalEnv = process.env[envVarName]; + delete process.env[envVarName]; + // Reset CLI arguments state + initializeCliArguments([]); + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env[envVarName] = originalEnv; + } else { + delete process.env[envVarName]; + } + configFileContent = undefined; + }); + + it("should return default URL when nothing is configured", () => { + const url = getMalwareListBaseUrl(); + + assert.strictEqual(url, "https://malware-list.aikido.dev"); + }); + + it("should return CLI argument value with highest priority", () => { + initializeCliArguments(["--safe-chain-malware-list-base-url=https://cli-mirror.com"]); + + const url = getMalwareListBaseUrl(); + + assert.strictEqual(url, "https://cli-mirror.com"); + }); + + it("should return environment variable value when no CLI argument", () => { + process.env[envVarName] = "https://env-mirror.com"; + + const url = getMalwareListBaseUrl(); + + assert.strictEqual(url, "https://env-mirror.com"); + }); + + it("should return config file value when no CLI or env", () => { + configFileContent = JSON.stringify({ + malwareListBaseUrl: "https://config-mirror.com", + }); + + const url = getMalwareListBaseUrl(); + + assert.strictEqual(url, "https://config-mirror.com"); + }); + + it("should prioritize CLI over environment variable", () => { + process.env[envVarName] = "https://env-mirror.com"; + initializeCliArguments(["--safe-chain-malware-list-base-url=https://cli-mirror.com"]); + + const url = getMalwareListBaseUrl(); + + assert.strictEqual(url, "https://cli-mirror.com"); + }); + + it("should prioritize environment variable over config file", () => { + process.env[envVarName] = "https://env-mirror.com"; + configFileContent = JSON.stringify({ + malwareListBaseUrl: "https://config-mirror.com", + }); + + const url = getMalwareListBaseUrl(); + + assert.strictEqual(url, "https://env-mirror.com"); + }); + + it("should prioritize CLI over config file", () => { + initializeCliArguments(["--safe-chain-malware-list-base-url=https://cli-mirror.com"]); + configFileContent = JSON.stringify({ + malwareListBaseUrl: "https://config-mirror.com", + }); + + const url = getMalwareListBaseUrl(); + + assert.strictEqual(url, "https://cli-mirror.com"); + }); +}); From 55024ca1c378b271387097e276620d5ab4825145 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Tue, 31 Mar 2026 23:19:28 -0700 Subject: [PATCH 227/360] Update to endpoint v1.2.9 in install script --- 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 249ba79..a8675d7 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.8/EndpointProtection.pkg" -DOWNLOAD_SHA256="e298864e9f41f9f1e6713f351d6b314a7fea7c420f52cca26eb262e50f38e165" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.9/EndpointProtection.pkg" +DOWNLOAD_SHA256="b81ad3f5c172148dfe359e2536653fe76e851227ef4b902e4641d58feed78510" 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 e614abc..7e8be7f 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.8/EndpointProtection.msi" -$DownloadSha256 = "1ac608cfcb6af8bdb00e857296f8ad4c7ed8c1ac8e956ea6da00bbef4732fd08" +$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.9/EndpointProtection.msi" +$DownloadSha256 = "ecb0d7148d8f703d9e2aadcb006b537b02e2fc126dd73e7ff956e1fd123ec3ed" # Ensure TLS 1.2 is enabled for downloads [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 From f01d935bb1e1eb6f598a60a2e9a3038b559b0821 Mon Sep 17 00:00:00 2001 From: 123Haynes <209302+123Haynes@users.noreply.github.com> Date: Wed, 1 Apr 2026 07:08:30 +0000 Subject: [PATCH 228/360] remove trailing slashes and fix test failures --- packages/safe-chain/src/api/aikido.js | 10 ++++--- packages/safe-chain/src/api/aikido.spec.js | 9 +++++++ packages/safe-chain/src/config/settings.js | 21 ++++++++++++--- .../safe-chain/src/config/settings.spec.js | 26 +++++++++++++++++++ .../src/scanning/newPackagesDatabase.spec.js | 1 + .../newPackagesDatabaseBuilder.spec.js | 1 + .../src/scanning/newPackagesListCache.spec.js | 1 + 7 files changed, 61 insertions(+), 8 deletions(-) diff --git a/packages/safe-chain/src/api/aikido.js b/packages/safe-chain/src/api/aikido.js index 91ed692..25babb9 100644 --- a/packages/safe-chain/src/api/aikido.js +++ b/packages/safe-chain/src/api/aikido.js @@ -97,12 +97,13 @@ export async function fetchNewPackagesList() { const ecosystem = getEcoSystem(); const baseUrl = getMalwareListBaseUrl(); const path = newPackagesListPaths[/** @type {keyof typeof newPackagesListPaths} */ (ecosystem)]; - const url = `${baseUrl}/${path}`; - if (!url) { + if (!path) { return { newPackagesList: [], version: undefined }; } + const url = `${baseUrl}/${path}`; + const response = await fetch(url); if (!response.ok) { throw new Error( @@ -130,12 +131,13 @@ export async function fetchNewPackagesListVersion() { const ecosystem = getEcoSystem(); const baseUrl = getMalwareListBaseUrl(); const path = newPackagesListPaths[/** @type {keyof typeof newPackagesListPaths} */ (ecosystem)]; - const url = `${baseUrl}/${path}`; - if (!url) { + if (!path) { return undefined; } + const url = `${baseUrl}/${path}`; + const response = await fetch(url, { method: "HEAD" }); if (!response.ok) { throw new Error( diff --git a/packages/safe-chain/src/api/aikido.spec.js b/packages/safe-chain/src/api/aikido.spec.js index 8b8d2dc..f41b9d2 100644 --- a/packages/safe-chain/src/api/aikido.spec.js +++ b/packages/safe-chain/src/api/aikido.spec.js @@ -185,6 +185,15 @@ describe("aikido API", async () => { assert.deepStrictEqual(result.newPackagesList, []); assert.strictEqual(result.version, undefined); }); + + it("should return undefined version without fetching for unsupported ecosystems", async () => { + ecosystem = "ruby"; + + const result = await fetchNewPackagesListVersion(); + + assert.strictEqual(mockFetch.mock.calls.length, 0); + assert.strictEqual(result, undefined); + }); }); describe("fetchNewPackagesListVersion", () => { diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index 9171849..7aab75f 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -207,21 +207,34 @@ export function getMalwareListBaseUrl() { // Priority 1: CLI argument const cliValue = cliArguments.getMalwareListBaseUrl(); if (cliValue) { - return cliValue; + return removeTrailingSlashes(cliValue); } // Priority 2: Environment variable const envValue = environmentVariables.getMalwareListBaseUrl(); if (envValue) { - return envValue; + return removeTrailingSlashes(envValue); } // Priority 3: Config file const configValue = configFile.getMalwareListBaseUrl(); if (configValue) { - return configValue; + return removeTrailingSlashes(configValue); } // Default - return "https://malware-list.aikido.dev"; + return removeTrailingSlashes("https://malware-list.aikido.dev"); +} + +/** + * Removes trailing slashes from a URL-like string. + * @param {string} value + * @returns {string} + */ +function removeTrailingSlashes(value) { + if (!value || typeof value !== "string") { + return value; + } + + return value.replace(/\/+$/, ""); } diff --git a/packages/safe-chain/src/config/settings.spec.js b/packages/safe-chain/src/config/settings.spec.js index 64e1272..48108c4 100644 --- a/packages/safe-chain/src/config/settings.spec.js +++ b/packages/safe-chain/src/config/settings.spec.js @@ -562,6 +562,32 @@ describe("getMalwareListBaseUrl", () => { assert.strictEqual(url, "https://malware-list.aikido.dev"); }); + it("should trim trailing slash from CLI argument", () => { + initializeCliArguments(["--safe-chain-malware-list-base-url=https://cli-mirror.com/"]); + + const url = getMalwareListBaseUrl(); + + assert.strictEqual(url, "https://cli-mirror.com"); + }); + + it("should trim trailing slash from environment variable", () => { + process.env[envVarName] = "https://env-mirror.com/"; + + const url = getMalwareListBaseUrl(); + + assert.strictEqual(url, "https://env-mirror.com"); + }); + + it("should trim trailing slash from config file value", () => { + configFileContent = JSON.stringify({ + malwareListBaseUrl: "https://config-mirror.com/", + }); + + const url = getMalwareListBaseUrl(); + + assert.strictEqual(url, "https://config-mirror.com"); + }); + it("should return CLI argument value with highest priority", () => { initializeCliArguments(["--safe-chain-malware-list-base-url=https://cli-mirror.com"]); diff --git a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js index f363f27..32de737 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js +++ b/packages/safe-chain/src/scanning/newPackagesDatabase.spec.js @@ -51,6 +51,7 @@ mock.module("../config/settings.js", { namedExports: { getMinimumPackageAgeHours: () => minimumPackageAgeHours, getEcoSystem: () => ecosystem, + getMalwareListBaseUrl: () => "https://malware-list.aikido.dev", ECOSYSTEM_JS: "js", ECOSYSTEM_PY: "py", }, diff --git a/packages/safe-chain/src/scanning/newPackagesDatabaseBuilder.spec.js b/packages/safe-chain/src/scanning/newPackagesDatabaseBuilder.spec.js index 9670a9e..1424a20 100644 --- a/packages/safe-chain/src/scanning/newPackagesDatabaseBuilder.spec.js +++ b/packages/safe-chain/src/scanning/newPackagesDatabaseBuilder.spec.js @@ -8,6 +8,7 @@ mock.module("../config/settings.js", { namedExports: { getMinimumPackageAgeHours: () => minimumPackageAgeHours, getEcoSystem: () => ecosystem, + getMalwareListBaseUrl: () => "https://malware-list.aikido.dev", ECOSYSTEM_JS: "js", ECOSYSTEM_PY: "py", }, diff --git a/packages/safe-chain/src/scanning/newPackagesListCache.spec.js b/packages/safe-chain/src/scanning/newPackagesListCache.spec.js index 8616876..503a0cc 100644 --- a/packages/safe-chain/src/scanning/newPackagesListCache.spec.js +++ b/packages/safe-chain/src/scanning/newPackagesListCache.spec.js @@ -20,6 +20,7 @@ mock.module("../config/settings.js", { namedExports: { getEcoSystem: () => ecosystem, getMinimumPackageAgeHours: () => 24, + getMalwareListBaseUrl: () => "https://malware-list.aikido.dev", ECOSYSTEM_JS: "js", ECOSYSTEM_PY: "py", }, From 4564b7f6078f28a4fbcea10e5343b7cb625c07d6 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 1 Apr 2026 14:32:36 -0700 Subject: [PATCH 229/360] Initial --- README.md | 8 +- packages/safe-chain/package.json | 3 + .../src/registryProxy/http-utils.js | 16 + .../interceptors/npm/modifyNpmInfo.js | 26 +- .../interceptors/pip/modifyPipInfo.js | 199 +++++++++++++ .../interceptors/pip/modifyPipInfo.spec.js | 276 ++++++++++++++++++ .../interceptors/pip/parsePipPackageUrl.js | 51 ++++ .../pip/parsePipPackageUrl.spec.js | 93 ++++++ .../pipInterceptor.customRegistries.spec.js | 4 + .../interceptors/pip/pipInterceptor.js | 28 +- .../pip/pipInterceptor.minPackageAge.spec.js | 43 +++ .../pipInterceptor.packageDownload.spec.js | 4 + .../pip/pipMetadataResponseUtils.js | 27 ++ .../pip/pipMetadataVersionUtils.js | 125 ++++++++ .../interceptors/suppressedVersionsState.js | 17 ++ .../src/registryProxy/mitmRequestHandler.js | 15 +- .../registryProxy/mitmRequestHandler.spec.js | 138 +++++++++ .../src/registryProxy/registryProxy.js | 2 +- .../src/scanning/packageNameVariants.js | 10 + 19 files changed, 1057 insertions(+), 28 deletions(-) create mode 100644 packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.js create mode 100644 packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.spec.js create mode 100644 packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.spec.js create mode 100644 packages/safe-chain/src/registryProxy/interceptors/pip/pipMetadataResponseUtils.js create mode 100644 packages/safe-chain/src/registryProxy/interceptors/pip/pipMetadataVersionUtils.js create mode 100644 packages/safe-chain/src/registryProxy/interceptors/suppressedVersionsState.js create mode 100644 packages/safe-chain/src/registryProxy/mitmRequestHandler.spec.js diff --git a/README.md b/README.md index e173b66..26f8c22 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,8 @@ Current enforcement differs by ecosystem: - during normal package resolution, Safe Chain suppresses versions that are newer than the configured minimum age from the package metadata returned by the registry - for direct package download requests that bypass that metadata flow, Safe Chain can block the request itself using a cached list of newly released packages - Python package managers: - - Safe Chain blocks direct package download requests using a cached list of newly released packages + - during package resolution, Safe Chain suppresses too-young files and releases from PyPI metadata responses + - for direct package download requests that bypass that metadata flow, Safe Chain can block the request itself using a cached list of newly released packages By default, the minimum package age is 48 hours. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can configure this threshold or bypass this protection entirely - see the [Minimum Package Age Configuration](#minimum-package-age) section below. @@ -198,7 +199,10 @@ For npm-based package managers, this check currently has two enforcement modes: - Safe Chain suppresses too-young versions from package metadata during normal dependency resolution. - Safe Chain blocks direct package download requests when they are matched against the cached newly released packages list. -For Python package managers, Safe Chain currently enforces minimum package age by blocking direct package download requests when they are matched against the cached newly released packages list. +For Python package managers, this check currently has two enforcement modes: + +- Safe Chain suppresses too-young files and releases from PyPI metadata during dependency resolution. +- Safe Chain blocks direct package download requests when they are matched against the cached newly released packages list. ### Configuration Options diff --git a/packages/safe-chain/package.json b/packages/safe-chain/package.json index d4f3501..753aa10 100644 --- a/packages/safe-chain/package.json +++ b/packages/safe-chain/package.json @@ -38,7 +38,10 @@ "license": "AGPL-3.0-or-later", "description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), and [pip](https://pip.pypa.io/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, bunx, uv, or pip/pip3 from downloading or running the malware.", "dependencies": { + "@aikidosec/safe-chain": "file:", + "@relay-x/app-sdk": "^0.1.4", "archiver": "^7.0.1", + "bridgefy-react-native": "^1.2.2", "certifi": "14.5.15", "chalk": "5.4.1", "https-proxy-agent": "7.0.6", diff --git a/packages/safe-chain/src/registryProxy/http-utils.js b/packages/safe-chain/src/registryProxy/http-utils.js index e14a977..f44e1d6 100644 --- a/packages/safe-chain/src/registryProxy/http-utils.js +++ b/packages/safe-chain/src/registryProxy/http-utils.js @@ -15,3 +15,19 @@ export function getHeaderValueAsString(headers, headerName) { return header; } + +/** + * Remove headers that become stale when the response body is modified. + * @param {NodeJS.Dict | undefined} headers + * @returns {void} + */ +export function clearCachingHeaders(headers) { + if (!headers) { + return; + } + + delete headers["etag"]; + delete headers["last-modified"]; + delete headers["cache-control"]; + delete headers["content-length"]; +} diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js index 1743f82..26b3b70 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js @@ -1,10 +1,7 @@ import { getMinimumPackageAgeHours } from "../../../config/settings.js"; import { ui } from "../../../environment/userInteraction.js"; -import { getHeaderValueAsString } from "../../http-utils.js"; - -const state = { - hasSuppressedVersions: false, -}; +import { clearCachingHeaders, getHeaderValueAsString } from "../../http-utils.js"; +import { recordSuppressedVersion } from "../suppressedVersionsState.js"; /** * @param {NodeJS.Dict} headers @@ -82,15 +79,7 @@ export function modifyNpmInfoResponse(body, headers) { const timestampValue = new Date(timestamp); if (timestampValue > cutOff) { deleteVersionFromJson(bodyJson, version); - if (headers) { - // When modifying the response, the etag and last-modified headers - // no longer match the content so they needs to be removed before sending the response. - delete headers["etag"]; - delete headers["last-modified"]; - // Removing the cache-control header will prevent the package manager from caching - // the modified response. - delete headers["cache-control"]; - } + clearCachingHeaders(headers); } } @@ -114,7 +103,7 @@ export function modifyNpmInfoResponse(body, headers) { * @param {string} version */ function deleteVersionFromJson(json, version) { - state.hasSuppressedVersions = true; + recordSuppressedVersion(); const packageName = typeof json?.name === "string" ? json.name : "(unknown)"; @@ -171,13 +160,6 @@ function getMostRecentTag(tagList) { return current; } -/** - * @returns {boolean} - */ -export function getHasSuppressedVersions() { - return state.hasSuppressedVersions; -} - /** * @param {Buffer} body * @param {NodeJS.Dict | undefined} headers diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.js b/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.js new file mode 100644 index 0000000..de4cae8 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.js @@ -0,0 +1,199 @@ +import { ui } from "../../../environment/userInteraction.js"; +import { clearCachingHeaders } from "../../http-utils.js"; +import { normalizePipPackageName } from "../../../scanning/packageNameVariants.js"; +import { parsePipPackageFromUrl } from "./parsePipPackageUrl.js"; +export { parsePipMetadataUrl, isPipPackageInfoUrl } from "./parsePipPackageUrl.js"; +import { + calculateLatestVersion, + getAvailableVersionsFromJson, + getPackageVersionFromMetadataFile, +} from "./pipMetadataVersionUtils.js"; +import { + getPipMetadataContentType, + logSuppressedVersion, +} from "./pipMetadataResponseUtils.js"; + +/** + * @param {Buffer} body + * @param {NodeJS.Dict | undefined} headers + * @param {string} metadataUrl + * @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage + * @param {string} packageName + * @returns {Buffer} + */ +export function modifyPipInfoResponse( + body, + headers, + metadataUrl, + isNewlyReleasedPackage, + packageName +) { + try { + const contentType = getPipMetadataContentType(headers); + + if (!contentType || body.byteLength === 0) { + return body; + } + + if ( + contentType.includes("html") || + contentType.includes("application/vnd.pypi.simple.v1+html") + ) { + return modifyHtmlSimpleResponse( + body, + headers, + metadataUrl, + isNewlyReleasedPackage, + packageName + ); + } + + if ( + contentType.includes("json") || + contentType.includes("application/vnd.pypi.simple.v1+json") + ) { + return modifyJsonResponse( + body, + headers, + metadataUrl, + isNewlyReleasedPackage, + packageName + ); + } + + return body; + } catch (/** @type {any} */ err) { + ui.writeVerbose( + `Safe-chain: PyPI package metadata not in expected format - bypassing modification. Error: ${err.message}` + ); + return body; + } +} + +/** + * @param {Buffer} body + * @param {NodeJS.Dict | undefined} headers + * @param {string} metadataUrl + * @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage + * @param {string} packageName + * @returns {Buffer} + */ +function modifyHtmlSimpleResponse( + body, + headers, + metadataUrl, + isNewlyReleasedPackage, + packageName +) { + const html = body.toString("utf8"); + let modified = false; + + const updatedHtml = html.replace( + /]*href\s*=\s*(["'])([^"']+)\1[^>]*>[\s\S]*?<\/a>/gi, + (anchor, _quote, href) => { + const resolvedHref = new URL(href, metadataUrl).toString(); + const { packageName: hrefPackageName, version } = parsePipPackageFromUrl( + resolvedHref, + new URL(resolvedHref).host + ); + + if ( + hrefPackageName && + normalizePipPackageName(hrefPackageName) === normalizePipPackageName(packageName) && + version && + isNewlyReleasedPackage(packageName, version) + ) { + modified = true; + logSuppressedVersion(packageName, version); + return ""; + } + + return anchor; + } + ); + + if (!modified) return body; + const modifiedBuffer = Buffer.from(updatedHtml); + clearCachingHeaders(headers); + return modifiedBuffer; +} + +/** + * @param {Buffer} body + * @param {NodeJS.Dict | undefined} headers + * @param {string} metadataUrl + * @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage + * @param {string} packageName + * @returns {Buffer} + */ +function modifyJsonResponse( + body, + headers, + metadataUrl, + isNewlyReleasedPackage, + packageName +) { + const json = JSON.parse(body.toString("utf8")); + let modified = false; + + if (Array.isArray(json.files)) { + const filteredFiles = json.files.filter((/** @type {any} */ file) => { + const version = getPackageVersionFromMetadataFile(file, metadataUrl); + + if (version && isNewlyReleasedPackage(packageName, version)) { + modified = true; + logSuppressedVersion(packageName, version); + return false; + } + + return true; + }); + + json.files = filteredFiles; + } + + if (json.releases && typeof json.releases === "object") { + for (const [version, files] of Object.entries(json.releases)) { + if ( + Array.isArray(/** @type {unknown[]} */ (files)) && + isNewlyReleasedPackage(packageName, version) + ) { + delete json.releases[version]; + modified = true; + logSuppressedVersion(packageName, version); + } + } + } + + if (Array.isArray(json.urls)) { + json.urls = json.urls.filter((/** @type {any} */ file) => { + const version = getPackageVersionFromMetadataFile(file, metadataUrl); + + if (version && isNewlyReleasedPackage(packageName, version)) { + modified = true; + logSuppressedVersion(packageName, version); + return false; + } + return true; + }); + } + + if (json.info && typeof json.info === "object") { + const candidateVersions = getAvailableVersionsFromJson(json, metadataUrl); + const replacementVersion = calculateLatestVersion(candidateVersions); + + if ( + typeof json.info.version === "string" && + replacementVersion && + json.info.version !== replacementVersion + ) { + json.info.version = replacementVersion; + modified = true; + } + } + + if (!modified) return body; + const modifiedBuffer = Buffer.from(JSON.stringify(json)); + clearCachingHeaders(headers); + return modifiedBuffer; +} diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.spec.js b/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.spec.js new file mode 100644 index 0000000..ef1fc86 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.spec.js @@ -0,0 +1,276 @@ +import { describe, it, mock } from "node:test"; +import assert from "node:assert"; + +describe("modifyPipInfo", async () => { + mock.module("../../../config/settings.js", { + namedExports: { + getMinimumPackageAgeHours: () => 48, + ECOSYSTEM_PY: "py", + }, + }); + + mock.module("../../../environment/userInteraction.js", { + namedExports: { + ui: { + writeVerbose: () => {}, + }, + }, + }); + + const { + modifyPipInfoResponse, + } = await import("./modifyPipInfo.js"); + + it("removes too-young files from simple HTML metadata", () => { + const headers = { + "content-type": "application/vnd.pypi.simple.v1+html", + etag: "abc", + "cache-control": "public", + "content-length": "999", + "transfer-encoding": "chunked", + }; + + const body = Buffer.from(` + + + + requests-1.0.0.tar.gz + requests-2.0.0.tar.gz + + + `); + + const modified = modifyPipInfoResponse( + body, + headers, + "https://pypi.org/simple/requests/", + (_packageName, version) => version === "2.0.0", + "requests" + ).toString("utf8"); + + assert.ok(modified.includes("requests-1.0.0.tar.gz")); + assert.ok(!modified.includes("requests-2.0.0.tar.gz")); + assert.equal(headers.etag, undefined); + assert.equal(headers["cache-control"], undefined); + assert.equal(headers["content-length"], undefined); + assert.equal(headers["transfer-encoding"], "chunked"); + }); + + it("leaves mixed-case transport headers untouched for MITM layer to normalize", () => { + const headers = { + "content-type": "application/json", + ETag: "abc", + "Content-Length": "999", + "Last-Modified": "yesterday", + "Cache-Control": "public, max-age=60", + "Transfer-Encoding": "chunked", + }; + + const body = Buffer.from( + JSON.stringify({ + info: { version: "2.0.0" }, + releases: { + "1.0.0": [{ filename: "requests-1.0.0.tar.gz" }], + "2.0.0": [{ filename: "requests-2.0.0.tar.gz" }], + }, + }) + ); + + const modified = modifyPipInfoResponse( + body, + headers, + "https://pypi.org/pypi/requests/json", + (_packageName, version) => version === "2.0.0", + "requests" + ); + + assert.equal(headers.ETag, "abc"); + assert.equal(headers["Last-Modified"], "yesterday"); + assert.equal(headers["Cache-Control"], "public, max-age=60"); + assert.equal(headers["Transfer-Encoding"], "chunked"); + assert.equal(headers["Content-Length"], "999"); + assert.equal(headers["content-length"], undefined); + }); + + it("returns body unchanged when no HTML versions are suppressed", () => { + const headers = { + "content-type": "application/vnd.pypi.simple.v1+html", + etag: "abc", + }; + + const body = Buffer.from( + `requests-1.0.0.tar.gz` + ); + + const result = modifyPipInfoResponse( + body, + headers, + "https://pypi.org/simple/requests/", + () => false, + "requests" + ); + + assert.equal(result, body); // same Buffer reference — no copy made + assert.equal(headers.etag, "abc"); // headers untouched + }); + + it("matches HTML anchor hrefs using normalised package name (underscore vs hyphen)", () => { + const headers = { "content-type": "application/vnd.pypi.simple.v1+html" }; + + const body = Buffer.from( + `foo_bar-2.0.0.tar.gz` + + `foo_bar-1.0.0.tar.gz` + ); + + const modified = modifyPipInfoResponse( + body, + headers, + "https://pypi.org/simple/foo-bar/", + (_packageName, version) => version === "2.0.0", + "foo-bar" // hyphenated name, hrefs use underscore + ).toString("utf8"); + + assert.ok(!modified.includes("foo_bar-2.0.0.tar.gz")); + assert.ok(modified.includes("foo_bar-1.0.0.tar.gz")); + }); + + it("removes too-young files from simple JSON metadata", () => { + const headers = { + "content-type": "application/vnd.pypi.simple.v1+json", + }; + + const body = Buffer.from( + JSON.stringify({ + name: "requests", + files: [ + { + filename: "requests-1.0.0.tar.gz", + url: "https://files.pythonhosted.org/packages/source/r/requests/requests-1.0.0.tar.gz", + }, + { + filename: "requests-2.0.0.tar.gz", + url: "https://files.pythonhosted.org/packages/source/r/requests/requests-2.0.0.tar.gz", + }, + ], + }) + ); + + const modified = JSON.parse( + modifyPipInfoResponse( + body, + headers, + "https://pypi.org/simple/requests/", + (_packageName, version) => version === "2.0.0", + "requests" + ).toString("utf8") + ); + + assert.equal(modified.files.length, 1); + assert.equal(modified.files[0].filename, "requests-1.0.0.tar.gz"); + }); + + it("filters simple JSON metadata entries that have only filename (no url)", () => { + const headers = { "content-type": "application/vnd.pypi.simple.v1+json" }; + + const body = Buffer.from( + JSON.stringify({ + name: "requests", + files: [ + { filename: "requests-1.0.0.tar.gz" }, + { filename: "requests-2.0.0.tar.gz" }, + ], + }) + ); + + const modified = JSON.parse( + modifyPipInfoResponse( + body, + headers, + "https://pypi.org/simple/requests/", + (_packageName, version) => version === "2.0.0", + "requests" + ).toString("utf8") + ); + + assert.equal(modified.files.length, 1); + assert.equal(modified.files[0].filename, "requests-1.0.0.tar.gz"); + }); + + it("recalculates JSON API info.version after removing too-young releases", () => { + const headers = { + "content-type": "application/json", + }; + + const body = Buffer.from( + JSON.stringify({ + info: { version: "2.0.0" }, + releases: { + "1.0.0": [ + { + filename: "requests-1.0.0.tar.gz", + upload_time_iso_8601: "2024-01-01T00:00:00.000Z", + }, + ], + "2.0.0": [ + { + filename: "requests-2.0.0.tar.gz", + upload_time_iso_8601: "2024-01-02T00:00:00.000Z", + }, + ], + "3.0.0rc1": [ + { + filename: "requests-3.0.0rc1.tar.gz", + upload_time_iso_8601: "2024-01-03T00:00:00.000Z", + }, + ], + }, + urls: [ + { filename: "requests-2.0.0.tar.gz" }, + ], + }) + ); + + const modified = JSON.parse( + modifyPipInfoResponse( + body, + headers, + "https://pypi.org/pypi/requests/json", + (_packageName, version) => + version === "2.0.0" || version === "3.0.0rc1", + "requests" + ).toString("utf8") + ); + + assert.deepEqual(Object.keys(modified.releases), ["1.0.0"]); + assert.equal(modified.info.version, "1.0.0"); + assert.equal(modified.urls.length, 0); + }); + + it("falls back to latest pre-release when all stable versions are removed", () => { + const headers = { "content-type": "application/json" }; + + const body = Buffer.from( + JSON.stringify({ + info: { version: "2.0.0rc2" }, + releases: { + "1.0.0rc1": [{ filename: "requests-1.0.0rc1.tar.gz" }], + "2.0.0rc2": [{ filename: "requests-2.0.0rc2.tar.gz" }], + }, + urls: [], + }) + ); + + const modified = JSON.parse( + modifyPipInfoResponse( + body, + headers, + "https://pypi.org/pypi/requests/json", + (_packageName, version) => version === "2.0.0rc2", + "requests" + ).toString("utf8") + ); + + assert.deepEqual(Object.keys(modified.releases), ["1.0.0rc1"]); + assert.equal(modified.info.version, "1.0.0rc1"); + }); +}); diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js b/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js index 377a648..56f03f8 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js @@ -1,3 +1,54 @@ +/** + * @param {string} url + * @returns {{ packageName: string | undefined, type: "simple" | "json" | undefined }} + */ +export function parsePipMetadataUrl(url) { + if (typeof url !== "string") { + return { packageName: undefined, type: undefined }; + } + + let urlObj; + try { + urlObj = new URL(url); + } catch { + return { packageName: undefined, type: undefined }; + } + + const pathSegments = urlObj.pathname.split("/").filter(Boolean); + if ( + pathSegments.length >= 2 && + pathSegments[0] === "simple" && + pathSegments[1] + ) { + return { + packageName: decodeURIComponent(pathSegments[1]), + type: "simple", + }; + } + + if ( + pathSegments.length >= 3 && + pathSegments[0] === "pypi" && + pathSegments[2] === "json" && + pathSegments[1] + ) { + return { + packageName: decodeURIComponent(pathSegments[1]), + type: "json", + }; + } + + return { packageName: undefined, type: undefined }; +} + +/** + * @param {string} url + * @returns {boolean} + */ +export function isPipPackageInfoUrl(url) { + return !!parsePipMetadataUrl(url).packageName; +} + /** * Parse Python package artifact URLs from PyPI-style registries. * Examples: diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.spec.js b/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.spec.js new file mode 100644 index 0000000..3d6eecd --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.spec.js @@ -0,0 +1,93 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { + isPipPackageInfoUrl, + parsePipMetadataUrl, + parsePipPackageFromUrl, +} from "./parsePipPackageUrl.js"; + +describe("parsePipPackageUrl", () => { + it("parses simple metadata URLs", () => { + assert.deepEqual(parsePipMetadataUrl("https://pypi.org/simple/requests/"), { + packageName: "requests", + type: "simple", + }); + }); + + it("parses json metadata URLs", () => { + assert.deepEqual(parsePipMetadataUrl("https://pypi.org/pypi/requests/json"), { + packageName: "requests", + type: "json", + }); + }); + + it("decodes encoded metadata package names", () => { + assert.deepEqual( + parsePipMetadataUrl("https://pypi.org/simple/foo-bar%5Fbaz/"), + { + packageName: "foo-bar_baz", + type: "simple", + } + ); + }); + + it("returns undefined for unrecognized metadata paths", () => { + assert.deepEqual( + parsePipMetadataUrl("https://pypi.org/unknown/requests/"), + { + packageName: undefined, + type: undefined, + } + ); + }); + + it("returns undefined for invalid metadata URLs", () => { + assert.deepEqual(parsePipMetadataUrl("not a url"), { + packageName: undefined, + type: undefined, + }); + }); + + it("recognizes package info URLs", () => { + assert.equal( + isPipPackageInfoUrl("https://pypi.org/simple/requests/"), + true + ); + }); + + it("does not treat artifact URLs as package info URLs", () => { + assert.equal( + isPipPackageInfoUrl( + "https://files.pythonhosted.org/packages/source/r/requests/requests-2.28.1.tar.gz" + ), + false + ); + }); + + it("parses wheel artifact URLs", () => { + assert.deepEqual( + parsePipPackageFromUrl( + "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl", + "files.pythonhosted.org" + ), + { packageName: "foo_bar", version: "2.0.0" } + ); + }); + + it("parses sdist artifact URLs", () => { + assert.deepEqual( + parsePipPackageFromUrl( + "https://files.pythonhosted.org/packages/source/r/requests/requests-2.28.1.tar.gz", + "files.pythonhosted.org" + ), + { packageName: "requests", version: "2.28.1" } + ); + }); + + it("returns undefined for non-artifact URLs", () => { + assert.deepEqual( + parsePipPackageFromUrl("https://pypi.org/simple/requests/", "pypi.org"), + { packageName: undefined, version: undefined } + ); + }); +}); diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.customRegistries.spec.js b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.customRegistries.spec.js index c7ad597..5904f05 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.customRegistries.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.customRegistries.spec.js @@ -10,8 +10,12 @@ describe("pipInterceptor custom registries", async () => { namedExports: { ECOSYSTEM_PY: "py", getEcoSystem: () => "py", + getLoggingLevel: () => "silent", + getMinimumPackageAgeHours: () => 48, getMinimumPackageAgeExclusions: () => [], getPipCustomRegistries: () => customRegistries, + LOGGING_SILENT: "silent", + LOGGING_VERBOSE: "verbose", skipMinimumPackageAge: () => false, }, }); diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js index abdda17..51e6f0d 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js @@ -8,6 +8,10 @@ import { getEquivalentPackageNames } from "../../../scanning/packageNameVariants import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache.js"; import { interceptRequests } from "../interceptorBuilder.js"; import { isExcludedFromMinimumPackageAge } from "../minimumPackageAgeExclusions.js"; +import { + modifyPipInfoResponse, + parsePipMetadataUrl, +} from "./modifyPipInfo.js"; import { parsePipPackageFromUrl } from "./parsePipPackageUrl.js"; const knownPipRegistries = [ @@ -47,6 +51,28 @@ function buildPipInterceptor(registry) { */ function createPipRequestHandler(registry) { return async (reqContext) => { + const minimumAgeChecksEnabled = !skipMinimumPackageAge(); + const metadataInfo = parsePipMetadataUrl(reqContext.targetUrl); + const metadataPackageName = metadataInfo.packageName; + + if ( + minimumAgeChecksEnabled && + metadataPackageName && + !isExcludedFromMinimumPackageAge(metadataPackageName) + ) { + const newPackagesDatabase = await openNewPackagesDatabase(); + reqContext.modifyBody((body, headers) => + modifyPipInfoResponse( + body, + headers, + reqContext.targetUrl, + newPackagesDatabase.isNewlyReleasedPackage, + metadataPackageName + ) + ); + return; + } + const { packageName, version } = parsePipPackageFromUrl( reqContext.targetUrl, registry @@ -75,7 +101,7 @@ function createPipRequestHandler(registry) { if ( version && - !skipMinimumPackageAge() && + minimumAgeChecksEnabled && !isExcludedFromMinimumPackageAge(packageName) ) { const newPackagesDatabase = await openNewPackagesDatabase(); diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.minPackageAge.spec.js b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.minPackageAge.spec.js index 8a5b189..6bbd904 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.minPackageAge.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.minPackageAge.spec.js @@ -30,8 +30,12 @@ describe("pipInterceptor minimum package age", async () => { namedExports: { ECOSYSTEM_PY: "py", getEcoSystem: () => "py", + getLoggingLevel: () => "silent", + getMinimumPackageAgeHours: () => 48, getMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting, getPipCustomRegistries: () => [], + LOGGING_SILENT: "silent", + LOGGING_VERBOSE: "verbose", skipMinimumPackageAge: () => skipMinimumPackageAgeSetting, }, }); @@ -56,6 +60,31 @@ describe("pipInterceptor minimum package age", async () => { newlyReleasedPackageResponse = false; }); + it("should modify simple metadata responses to suppress too-young versions", async () => { + const url = "https://pypi.org/simple/foo-bar/"; + newlyReleasedPackageResponse = true; + + const interceptor = pipInterceptorForUrl(url); + const result = await interceptor.handleRequest(url); + + assert.equal(result.modifiesResponse(), true); + + const modifiedBody = result.modifyBody( + Buffer.from(` + foo_bar-1.0.0.tar.gz + foo_bar-2.0.0.tar.gz + `), + { + "content-type": "application/vnd.pypi.simple.v1+html", + } + ).toString("utf8"); + + assert.ok(modifiedBody.includes("foo_bar-1.0.0.tar.gz")); + assert.ok(!modifiedBody.includes("foo_bar-2.0.0.tar.gz")); + + newlyReleasedPackageResponse = false; + }); + it("should not block newly released package downloads when skipMinimumPackageAge is enabled", async () => { const url = "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl"; @@ -86,6 +115,20 @@ describe("pipInterceptor minimum package age", async () => { newlyReleasedPackageResponse = false; }); + it("should not modify metadata responses when the package is excluded", async () => { + const url = "https://pypi.org/simple/foo-bar/"; + newlyReleasedPackageResponse = true; + minimumPackageAgeExclusionsSetting = ["foo-bar"]; + + const interceptor = pipInterceptorForUrl(url); + const result = await interceptor.handleRequest(url); + + assert.equal(result.modifiesResponse(), false); + + minimumPackageAgeExclusionsSetting = []; + newlyReleasedPackageResponse = false; + }); + it("should not block newly released package downloads when a dot-name package matches a hyphen exclusion", async () => { const url = "https://files.pythonhosted.org/packages/xx/yy/foo.bar-2.0.0.tar.gz"; diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.packageDownload.spec.js b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.packageDownload.spec.js index d6fdec6..f4a54a4 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.packageDownload.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.packageDownload.spec.js @@ -26,8 +26,12 @@ describe("pipInterceptor", async () => { namedExports: { ECOSYSTEM_PY: "py", getEcoSystem: () => "py", + getLoggingLevel: () => "silent", + getMinimumPackageAgeHours: () => 48, getMinimumPackageAgeExclusions: () => [], getPipCustomRegistries: () => [], + LOGGING_SILENT: "silent", + LOGGING_VERBOSE: "verbose", skipMinimumPackageAge: () => false, }, }); diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipMetadataResponseUtils.js b/packages/safe-chain/src/registryProxy/interceptors/pip/pipMetadataResponseUtils.js new file mode 100644 index 0000000..e394810 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/pipMetadataResponseUtils.js @@ -0,0 +1,27 @@ +import { getMinimumPackageAgeHours } from "../../../config/settings.js"; +import { ui } from "../../../environment/userInteraction.js"; +import { getHeaderValueAsString } from "../../http-utils.js"; +import { recordSuppressedVersion } from "../suppressedVersionsState.js"; + +/** + * @param {NodeJS.Dict | undefined} headers + * @returns {string | undefined} + */ +export function getPipMetadataContentType(headers) { + return getHeaderValueAsString(headers, "content-type") + ?.toLowerCase() + .split(";")[0] + .trim(); +} + +/** + * @param {string} packageName + * @param {string} version + * @returns {void} + */ +export function logSuppressedVersion(packageName, version) { + recordSuppressedVersion(); + ui.writeVerbose( + `Safe-chain: ${packageName}@${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).` + ); +} diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipMetadataVersionUtils.js b/packages/safe-chain/src/registryProxy/interceptors/pip/pipMetadataVersionUtils.js new file mode 100644 index 0000000..28aaaf6 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/pipMetadataVersionUtils.js @@ -0,0 +1,125 @@ +import { parsePipPackageFromUrl } from "./parsePipPackageUrl.js"; + +/** + * @param {any} file + * @param {string} metadataUrl + * @returns {string | undefined} + */ +export function getPackageVersionFromMetadataFile(file, metadataUrl) { + const href = typeof file?.url === "string" ? file.url : undefined; + const filename = typeof file?.filename === "string" ? file.filename : undefined; + + if (href) { + const resolvedHref = new URL(href, metadataUrl).toString(); + return parsePipPackageFromUrl( + resolvedHref, + new URL(resolvedHref).host + ).version; + } + + if (filename) { + return parsePipPackageFromUrl( + new URL(filename, metadataUrl).toString(), + new URL(metadataUrl).host + ).version; + } + + return undefined; +} + +/** + * @param {any} json + * @param {string} metadataUrl + * @returns {string[]} + */ +export function getAvailableVersionsFromJson(json, metadataUrl) { + if (json.releases && typeof json.releases === "object") { + return Object.keys(json.releases); + } + + if (Array.isArray(json.files)) { + return [ + ...new Set( + json.files + .map((/** @type {any} */ file) => + getPackageVersionFromMetadataFile(file, metadataUrl) + ) + .filter((/** @type {string | undefined} */ version) => + typeof version === "string" + ) + ), + ]; + } + + return []; +} + +/** + * @param {string[]} versions + * @returns {string | undefined} + */ +export function calculateLatestVersion(versions) { + const stableVersions = versions.filter((version) => !isPrerelease(version)); + if (stableVersions.length > 0) { + return stableVersions.sort(comparePep440ishVersions).at(-1); + } + + return versions.sort(comparePep440ishVersions).at(-1); +} + +/** + * @param {string} left + * @param {string} right + * @returns {number} + */ +function comparePep440ishVersions(left, right) { + const leftParts = tokenizeVersion(left); + const rightParts = tokenizeVersion(right); + const maxLength = Math.max(leftParts.length, rightParts.length); + + for (let index = 0; index < maxLength; index += 1) { + const leftPart = leftParts[index]; + const rightPart = rightParts[index]; + + if (leftPart === undefined) return -1; + if (rightPart === undefined) return 1; + + if (leftPart === rightPart) { + continue; + } + + const leftNumeric = typeof leftPart === "number"; + const rightNumeric = typeof rightPart === "number"; + + if (leftNumeric && rightNumeric) { + return leftPart - rightPart; + } + + if (leftNumeric) return 1; + if (rightNumeric) return -1; + + return String(leftPart).localeCompare(String(rightPart)); + } + + return 0; +} + +/** + * @param {string} version + * @returns {(string | number)[]} + */ +function tokenizeVersion(version) { + return version + .toLowerCase() + .split(/[^a-z0-9]+/) + .flatMap((part) => part.match(/[a-z]+|\d+/g) || []) + .map((part) => (/^\d+$/.test(part) ? Number(part) : part)); +} + +/** + * @param {string} version + * @returns {boolean} + */ +function isPrerelease(version) { + return /(?:^|[.\-_])(a|b|rc|dev)\d*/i.test(version); +} diff --git a/packages/safe-chain/src/registryProxy/interceptors/suppressedVersionsState.js b/packages/safe-chain/src/registryProxy/interceptors/suppressedVersionsState.js new file mode 100644 index 0000000..a3b1055 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/suppressedVersionsState.js @@ -0,0 +1,17 @@ +const state = { + hasSuppressedVersions: false, +}; + +/** + * @returns {void} + */ +export function recordSuppressedVersion() { + state.hasSuppressedVersions = true; +} + +/** + * @returns {boolean} + */ +export function getHasSuppressedVersions() { + return state.hasSuppressedVersions; +} diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js index 8268559..7220370 100644 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js @@ -215,10 +215,21 @@ function createProxyRequest(hostname, port, req, res, requestHandler) { buffer = requestHandler.modifyBody(buffer, headers); - if (proxyRes.headers["content-encoding"] === "gzip") { - buffer = gzipSync(buffer); + // For rewritten responses, send the final body uncompressed. + // This avoids mismatches between upstream compression metadata and the + // rewritten payload on the wire. + for (const headerName of Object.keys(headers)) { + const lowerHeaderName = headerName.toLowerCase(); + if ( + lowerHeaderName === "content-length" || + lowerHeaderName === "transfer-encoding" || + lowerHeaderName === "content-encoding" + ) { + delete headers[headerName]; + } } + headers["content-length"] = String(buffer.byteLength); res.writeHead(statusCode, headers); res.end(buffer); }); diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.spec.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.spec.js new file mode 100644 index 0000000..de01e2c --- /dev/null +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.spec.js @@ -0,0 +1,138 @@ +import { describe, it, mock } from "node:test"; +import assert from "node:assert"; +import zlib from "node:zlib"; + +describe("mitmRequestHandler", async () => { + let capturedHandler; + let capturedOptions; + + mock.module("https", { + defaultExport: { + createServer: (_options, handler) => { + capturedHandler = handler; + return { + on: () => {}, + emit: () => {}, + }; + }, + request: (options, callback) => { + capturedOptions = options; + + const listeners = {}; + const proxyRes = { + statusCode: 200, + headers: { + "content-encoding": "gzip", + "content-length": "999", + "transfer-encoding": "chunked", + }, + on: (event, handler) => { + listeners[event] = handler; + }, + }; + + callback(proxyRes); + + return { + on: () => {}, + write: () => {}, + end: () => { + const payload = Buffer.from("rewritten body"); + listeners["data"]?.(zlib.gzipSync(payload)); + listeners["end"]?.(); + }, + destroy: () => {}, + }; + }, + }, + }); + + mock.module("./certUtils.js", { + namedExports: { + generateCertForHost: () => ({ + privateKey: "key", + certificate: "cert", + }), + }, + }); + + mock.module("https-proxy-agent", { + namedExports: { + HttpsProxyAgent: class {}, + }, + }); + + mock.module("../environment/userInteraction.js", { + namedExports: { + ui: { + writeVerbose: () => {}, + writeError: () => {}, + }, + }, + }); + + const { mitmConnect } = await import("./mitmRequestHandler.js"); + + it("sets content-length from the final compressed payload after body rewrite", async () => { + const interceptor = { + handleRequest: async () => ({ + blockResponse: undefined, + modifyRequestHeaders: (headers) => headers, + modifiesResponse: () => true, + modifyBody: () => Buffer.from("rewritten body"), + }), + }; + + const req = { + url: "pypi.org:443", + }; + + const clientSocket = { + on: () => {}, + write: () => {}, + headersSent: false, + writable: true, + end: () => {}, + }; + + mitmConnect(req, clientSocket, interceptor); + + const resState = { + statusCode: undefined, + headers: undefined, + body: undefined, + }; + + const res = { + headersSent: false, + writeHead: (statusCode, headers) => { + resState.statusCode = statusCode; + resState.headers = headers; + }, + end: (body) => { + resState.body = body; + }, + }; + + const request = { + url: "/simple/example/", + headers: {}, + method: "GET", + on: (event, handler) => { + if (event === "end") { + handler(); + } + }, + }; + + await capturedHandler(request, res); + + assert.equal(capturedOptions.hostname, "pypi.org"); + assert.equal(resState.statusCode, 200); + assert.equal(resState.headers["transfer-encoding"], undefined); + assert.equal( + resState.headers["content-length"], + String(resState.body.byteLength) + ); + }); +}); diff --git a/packages/safe-chain/src/registryProxy/registryProxy.js b/packages/safe-chain/src/registryProxy/registryProxy.js index 81b265d..0b009bb 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.js @@ -6,7 +6,7 @@ import { getCombinedCaBundlePath, cleanupCertBundle } from "./certBundle.js"; import { ui } from "../environment/userInteraction.js"; import chalk from "chalk"; import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js"; -import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js"; +import { getHasSuppressedVersions } from "./interceptors/suppressedVersionsState.js"; const SERVER_STOP_TIMEOUT_MS = 1000; /** diff --git a/packages/safe-chain/src/scanning/packageNameVariants.js b/packages/safe-chain/src/scanning/packageNameVariants.js index 97db91b..64075f2 100644 --- a/packages/safe-chain/src/scanning/packageNameVariants.js +++ b/packages/safe-chain/src/scanning/packageNameVariants.js @@ -1,5 +1,15 @@ import { ECOSYSTEM_PY } from "../config/settings.js"; +/** + * Normalises a Python package name per PEP 503: lowercase and collapse any + * run of `.`, `_`, or `-` into a single hyphen. + * @param {string} packageName + * @returns {string} + */ +export function normalizePipPackageName(packageName) { + return packageName.toLowerCase().replace(/[._-]+/g, "-"); +} + /** * @param {string} packageName * @param {string} ecosystem From e29c11546c4d83615c099e387f240f2eb3a05e81 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 1 Apr 2026 14:43:00 -0700 Subject: [PATCH 230/360] Some cleanup --- packages/safe-chain/package.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/safe-chain/package.json b/packages/safe-chain/package.json index 753aa10..d4f3501 100644 --- a/packages/safe-chain/package.json +++ b/packages/safe-chain/package.json @@ -38,10 +38,7 @@ "license": "AGPL-3.0-or-later", "description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), and [pip](https://pip.pypa.io/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, bunx, uv, or pip/pip3 from downloading or running the malware.", "dependencies": { - "@aikidosec/safe-chain": "file:", - "@relay-x/app-sdk": "^0.1.4", "archiver": "^7.0.1", - "bridgefy-react-native": "^1.2.2", "certifi": "14.5.15", "chalk": "5.4.1", "https-proxy-agent": "7.0.6", From 1a811edc95002c3fe10873a3600301e4b9a589a9 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 1 Apr 2026 14:57:24 -0700 Subject: [PATCH 231/360] More cleanup --- .../src/registryProxy/http-utils.js | 2 + .../interceptors/pip/modifyPipInfo.js | 74 +------- .../interceptors/pip/modifyPipJsonResponse.js | 168 ++++++++++++++++++ .../interceptors/suppressedVersionsState.js | 4 + .../src/registryProxy/mitmRequestHandler.js | 31 ++-- 5 files changed, 201 insertions(+), 78 deletions(-) create mode 100644 packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipJsonResponse.js diff --git a/packages/safe-chain/src/registryProxy/http-utils.js b/packages/safe-chain/src/registryProxy/http-utils.js index f44e1d6..967aec8 100644 --- a/packages/safe-chain/src/registryProxy/http-utils.js +++ b/packages/safe-chain/src/registryProxy/http-utils.js @@ -18,6 +18,8 @@ export function getHeaderValueAsString(headers, headerName) { /** * Remove headers that become stale when the response body is modified. + * Mutates the provided headers object in place. + * * @param {NodeJS.Dict | undefined} headers * @returns {void} */ diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.js b/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.js index de4cae8..d3d10fe 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.js @@ -3,15 +3,8 @@ import { clearCachingHeaders } from "../../http-utils.js"; import { normalizePipPackageName } from "../../../scanning/packageNameVariants.js"; import { parsePipPackageFromUrl } from "./parsePipPackageUrl.js"; export { parsePipMetadataUrl, isPipPackageInfoUrl } from "./parsePipPackageUrl.js"; -import { - calculateLatestVersion, - getAvailableVersionsFromJson, - getPackageVersionFromMetadataFile, -} from "./pipMetadataVersionUtils.js"; -import { - getPipMetadataContentType, - logSuppressedVersion, -} from "./pipMetadataResponseUtils.js"; +import { getPipMetadataContentType, logSuppressedVersion } from "./pipMetadataResponseUtils.js"; +import { modifyPipJsonResponse } from "./modifyPipJsonResponse.js"; /** * @param {Buffer} body @@ -134,63 +127,12 @@ function modifyJsonResponse( packageName ) { const json = JSON.parse(body.toString("utf8")); - let modified = false; - - if (Array.isArray(json.files)) { - const filteredFiles = json.files.filter((/** @type {any} */ file) => { - const version = getPackageVersionFromMetadataFile(file, metadataUrl); - - if (version && isNewlyReleasedPackage(packageName, version)) { - modified = true; - logSuppressedVersion(packageName, version); - return false; - } - - return true; - }); - - json.files = filteredFiles; - } - - if (json.releases && typeof json.releases === "object") { - for (const [version, files] of Object.entries(json.releases)) { - if ( - Array.isArray(/** @type {unknown[]} */ (files)) && - isNewlyReleasedPackage(packageName, version) - ) { - delete json.releases[version]; - modified = true; - logSuppressedVersion(packageName, version); - } - } - } - - if (Array.isArray(json.urls)) { - json.urls = json.urls.filter((/** @type {any} */ file) => { - const version = getPackageVersionFromMetadataFile(file, metadataUrl); - - if (version && isNewlyReleasedPackage(packageName, version)) { - modified = true; - logSuppressedVersion(packageName, version); - return false; - } - return true; - }); - } - - if (json.info && typeof json.info === "object") { - const candidateVersions = getAvailableVersionsFromJson(json, metadataUrl); - const replacementVersion = calculateLatestVersion(candidateVersions); - - if ( - typeof json.info.version === "string" && - replacementVersion && - json.info.version !== replacementVersion - ) { - json.info.version = replacementVersion; - modified = true; - } - } + const modified = modifyPipJsonResponse( + json, + metadataUrl, + isNewlyReleasedPackage, + packageName + ); if (!modified) return body; const modifiedBuffer = Buffer.from(JSON.stringify(json)); diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipJsonResponse.js b/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipJsonResponse.js new file mode 100644 index 0000000..869a516 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipJsonResponse.js @@ -0,0 +1,168 @@ +import { + calculateLatestVersion, + getAvailableVersionsFromJson, + getPackageVersionFromMetadataFile, +} from "./pipMetadataVersionUtils.js"; +import { logSuppressedVersion } from "./pipMetadataResponseUtils.js"; + +/** + * @param {any} json + * @param {string} metadataUrl + * @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage + * @param {string} packageName + * @returns {boolean} + */ +export function modifyPipJsonResponse( + json, + metadataUrl, + isNewlyReleasedPackage, + packageName +) { + const filesModified = filterJsonMetadataFiles( + json, + metadataUrl, + isNewlyReleasedPackage, + packageName + ); + const releasesModified = removeJsonMetadataReleases( + json, + isNewlyReleasedPackage, + packageName + ); + const urlsModified = filterJsonMetadataUrls( + json, + metadataUrl, + isNewlyReleasedPackage, + packageName + ); + const versionModified = updateJsonInfoVersion(json, metadataUrl); + + return filesModified || releasesModified || urlsModified || versionModified; +} + +/** + * @param {any} json + * @param {string} metadataUrl + * @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage + * @param {string} packageName + * @returns {boolean} + */ +function filterJsonMetadataFiles( + json, + metadataUrl, + isNewlyReleasedPackage, + packageName +) { + if (!Array.isArray(json.files)) { + return false; + } + + let modified = false; + json.files = json.files.filter((/** @type {any} */ file) => { + const version = getPackageVersionFromMetadataFile(file, metadataUrl); + + if (version && isNewlyReleasedPackage(packageName, version)) { + modified = true; + logSuppressedVersion(packageName, version); + return false; + } + + return true; + }); + + return modified; +} + +/** + * @param {any} json + * @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage + * @param {string} packageName + * @returns {boolean} + */ +function removeJsonMetadataReleases(json, isNewlyReleasedPackage, packageName) { + if (!json.releases || typeof json.releases !== "object") { + return false; + } + + let modified = false; + + for (const [version, files] of Object.entries(json.releases)) { + if ( + Array.isArray(/** @type {unknown[]} */ (files)) && + isNewlyReleasedPackage(packageName, version) + ) { + delete json.releases[version]; + modified = true; + logSuppressedVersion(packageName, version); + } + } + + return modified; +} + +/** + * @param {any} json + * @param {string} metadataUrl + * @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage + * @param {string} packageName + * @returns {boolean} + */ +function filterJsonMetadataUrls( + json, + metadataUrl, + isNewlyReleasedPackage, + packageName +) { + if (!Array.isArray(json.urls)) { + return false; + } + + let modified = false; + json.urls = json.urls.filter((/** @type {any} */ file) => { + const version = getPackageVersionFromMetadataFile(file, metadataUrl); + + if (version && isNewlyReleasedPackage(packageName, version)) { + modified = true; + logSuppressedVersion(packageName, version); + return false; + } + + return true; + }); + + return modified; +} + +/** + * @param {any} json + * @param {string} metadataUrl + * @returns {boolean} + */ +function updateJsonInfoVersion(json, metadataUrl) { + if (!json.info || typeof json.info !== "object") { + return false; + } + + const replacementVersion = computeReplacementVersion(json, metadataUrl); + + if ( + typeof json.info.version !== "string" || + !replacementVersion || + json.info.version === replacementVersion + ) { + return false; + } + + json.info.version = replacementVersion; + return true; +} + +/** + * @param {any} json + * @param {string} metadataUrl + * @returns {string | undefined} + */ +function computeReplacementVersion(json, metadataUrl) { + const candidateVersions = getAvailableVersionsFromJson(json, metadataUrl); + return calculateLatestVersion(candidateVersions); +} diff --git a/packages/safe-chain/src/registryProxy/interceptors/suppressedVersionsState.js b/packages/safe-chain/src/registryProxy/interceptors/suppressedVersionsState.js index a3b1055..26c0559 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/suppressedVersionsState.js +++ b/packages/safe-chain/src/registryProxy/interceptors/suppressedVersionsState.js @@ -3,6 +3,10 @@ const state = { }; /** + * Tracks whether any rewritten metadata response suppressed versions during the + * current process lifetime. This is intentional shared state used only for the + * end-of-run summary message exposed through the proxy API. + * * @returns {void} */ export function recordSuppressedVersion() { diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js index 7220370..1b76c81 100644 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js @@ -2,7 +2,7 @@ import https from "https"; import { generateCertForHost } from "./certUtils.js"; import { HttpsProxyAgent } from "https-proxy-agent"; import { ui } from "../environment/userInteraction.js"; -import { gunzipSync, gzipSync } from "zlib"; +import { gunzipSync } from "zlib"; /** * @typedef {import("./interceptors/interceptorBuilder.js").Interceptor} Interceptor @@ -107,6 +107,23 @@ function getRequestPathAndQuery(url) { return url; } +/** + * @param {NodeJS.Dict} headers + * @returns {void} + */ +function normalizeRewrittenResponseHeaders(headers) { + for (const headerName of Object.keys(headers)) { + const lowerHeaderName = headerName.toLowerCase(); + if ( + lowerHeaderName === "content-length" || + lowerHeaderName === "transfer-encoding" || + lowerHeaderName === "content-encoding" + ) { + delete headers[headerName]; + } + } +} + /** * @param {import("http").IncomingMessage} req * @param {string} hostname @@ -218,17 +235,7 @@ function createProxyRequest(hostname, port, req, res, requestHandler) { // For rewritten responses, send the final body uncompressed. // This avoids mismatches between upstream compression metadata and the // rewritten payload on the wire. - for (const headerName of Object.keys(headers)) { - const lowerHeaderName = headerName.toLowerCase(); - if ( - lowerHeaderName === "content-length" || - lowerHeaderName === "transfer-encoding" || - lowerHeaderName === "content-encoding" - ) { - delete headers[headerName]; - } - } - + normalizeRewrittenResponseHeaders(headers); headers["content-length"] = String(buffer.byteLength); res.writeHead(statusCode, headers); res.end(buffer); From 27e77d9b0b7c851cfd6b18df8a1ca7b28d4f1be9 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 1 Apr 2026 15:19:39 -0700 Subject: [PATCH 232/360] Fix regex --- .../registryProxy/interceptors/pip/pipMetadataVersionUtils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipMetadataVersionUtils.js b/packages/safe-chain/src/registryProxy/interceptors/pip/pipMetadataVersionUtils.js index 28aaaf6..938b149 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/pipMetadataVersionUtils.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/pipMetadataVersionUtils.js @@ -121,5 +121,5 @@ function tokenizeVersion(version) { * @returns {boolean} */ function isPrerelease(version) { - return /(?:^|[.\-_])(a|b|rc|dev)\d*/i.test(version); + return /(a|b|rc|dev)\d+/i.test(version); } From 2b1247cf365039a4dea55aa4c8c24abc868584fc Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 1 Apr 2026 15:23:25 -0700 Subject: [PATCH 233/360] Code Quality --- .../interceptors/pip/modifyPipInfo.spec.js | 2 +- .../src/registryProxy/mitmRequestHandler.js | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.spec.js b/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.spec.js index ef1fc86..46a872f 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.spec.js @@ -76,7 +76,7 @@ describe("modifyPipInfo", async () => { }) ); - const modified = modifyPipInfoResponse( + modifyPipInfoResponse( body, headers, "https://pypi.org/pypi/requests/json", diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js index 1b76c81..b2d82e9 100644 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js @@ -109,9 +109,12 @@ function getRequestPathAndQuery(url) { /** * @param {NodeJS.Dict} headers - * @returns {void} + * @returns {NodeJS.Dict} */ function normalizeRewrittenResponseHeaders(headers) { + /** @type {NodeJS.Dict} */ + const normalizedHeaders = { ...headers }; + for (const headerName of Object.keys(headers)) { const lowerHeaderName = headerName.toLowerCase(); if ( @@ -119,9 +122,11 @@ function normalizeRewrittenResponseHeaders(headers) { lowerHeaderName === "transfer-encoding" || lowerHeaderName === "content-encoding" ) { - delete headers[headerName]; + delete normalizedHeaders[headerName]; } } + + return normalizedHeaders; } /** @@ -235,9 +240,9 @@ function createProxyRequest(hostname, port, req, res, requestHandler) { // For rewritten responses, send the final body uncompressed. // This avoids mismatches between upstream compression metadata and the // rewritten payload on the wire. - normalizeRewrittenResponseHeaders(headers); - headers["content-length"] = String(buffer.byteLength); - res.writeHead(statusCode, headers); + const rewrittenHeaders = normalizeRewrittenResponseHeaders(headers); + rewrittenHeaders["content-length"] = String(buffer.byteLength); + res.writeHead(statusCode, rewrittenHeaders); res.end(buffer); }); } else { From c6963868250936824ff1e0510a0fcc458b964158 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 1 Apr 2026 15:38:42 -0700 Subject: [PATCH 234/360] Some more cleanup --- .../interceptors/pip/modifyPipJsonResponse.js | 12 ++++++++++-- .../interceptors/pip/parsePipPackageUrl.js | 17 ++++++++++++++++- .../interceptors/pip/parsePipPackageUrl.spec.js | 7 +++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipJsonResponse.js b/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipJsonResponse.js index 869a516..e005237 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipJsonResponse.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipJsonResponse.js @@ -58,12 +58,16 @@ function filterJsonMetadataFiles( } let modified = false; + const loggedVersions = new Set(); json.files = json.files.filter((/** @type {any} */ file) => { const version = getPackageVersionFromMetadataFile(file, metadataUrl); if (version && isNewlyReleasedPackage(packageName, version)) { modified = true; - logSuppressedVersion(packageName, version); + if (!loggedVersions.has(version)) { + logSuppressedVersion(packageName, version); + loggedVersions.add(version); + } return false; } @@ -118,12 +122,16 @@ function filterJsonMetadataUrls( } let modified = false; + const loggedVersions = new Set(); json.urls = json.urls.filter((/** @type {any} */ file) => { const version = getPackageVersionFromMetadataFile(file, metadataUrl); if (version && isNewlyReleasedPackage(packageName, version)) { modified = true; - logSuppressedVersion(packageName, version); + if (!loggedVersions.has(version)) { + logSuppressedVersion(packageName, version); + loggedVersions.add(version); + } return false; } diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js b/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js index 56f03f8..5a89e81 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js @@ -1,4 +1,19 @@ /** + * Parses a PyPI metadata URL and returns the package name and API type. + * + * @example + * parsePipMetadataUrl("https://pypi.org/simple/requests/") + * // => { packageName: "requests", type: "simple" } + * + * parsePipMetadataUrl("https://pypi.org/pypi/requests/json") + * // => { packageName: "requests", type: "json" } + * + * parsePipMetadataUrl("https://pypi.org/pypi/requests/2.28.1/json") + * // => { packageName: "requests", type: "json" } + * + * parsePipMetadataUrl("https://files.pythonhosted.org/packages/requests-2.28.1.tar.gz") + * // => { packageName: undefined, type: undefined } + * * @param {string} url * @returns {{ packageName: string | undefined, type: "simple" | "json" | undefined }} */ @@ -29,7 +44,7 @@ export function parsePipMetadataUrl(url) { if ( pathSegments.length >= 3 && pathSegments[0] === "pypi" && - pathSegments[2] === "json" && + pathSegments[pathSegments.length - 1] === "json" && pathSegments[1] ) { return { diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.spec.js b/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.spec.js index 3d6eecd..1345dd4 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.spec.js @@ -21,6 +21,13 @@ describe("parsePipPackageUrl", () => { }); }); + it("parses per-version json metadata URLs", () => { + assert.deepEqual( + parsePipMetadataUrl("https://pypi.org/pypi/requests/2.28.1/json"), + { packageName: "requests", type: "json" } + ); + }); + it("decodes encoded metadata package names", () => { assert.deepEqual( parsePipMetadataUrl("https://pypi.org/simple/foo-bar%5Fbaz/"), From 06ef0c399034b5024e633f74898c0e5768267229 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 1 Apr 2026 20:08:56 -0700 Subject: [PATCH 235/360] Adapt per review --- .../registryProxy/interceptors/pip/parsePipPackageUrl.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js b/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js index 5a89e81..da3d29f 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/parsePipPackageUrl.js @@ -30,11 +30,7 @@ export function parsePipMetadataUrl(url) { } const pathSegments = urlObj.pathname.split("/").filter(Boolean); - if ( - pathSegments.length >= 2 && - pathSegments[0] === "simple" && - pathSegments[1] - ) { + if (pathSegments[0] === "simple" && pathSegments[1]) { return { packageName: decodeURIComponent(pathSegments[1]), type: "simple", @@ -42,7 +38,6 @@ export function parsePipMetadataUrl(url) { } if ( - pathSegments.length >= 3 && pathSegments[0] === "pypi" && pathSegments[pathSegments.length - 1] === "json" && pathSegments[1] From 2bf6ba250272abed9ec0c7e3c9edca1de0ee4d37 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 2 Apr 2026 09:46:28 +0200 Subject: [PATCH 236/360] Update Aikido Endpoint version to 1.2.11 --- 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 a8675d7..c5108f6 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.9/EndpointProtection.pkg" -DOWNLOAD_SHA256="b81ad3f5c172148dfe359e2536653fe76e851227ef4b902e4641d58feed78510" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.11/EndpointProtection.pkg" +DOWNLOAD_SHA256="17cbe86a9ca444a900162c833ab5f4974b17509f8fcf93fd6a04e7ec4cc90aed" 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 7e8be7f..860f04f 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.9/EndpointProtection.msi" -$DownloadSha256 = "ecb0d7148d8f703d9e2aadcb006b537b02e2fc126dd73e7ff956e1fd123ec3ed" +$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.11/EndpointProtection.msi" +$DownloadSha256 = "cc191b9e5d8817bf8b063c12277d4d6d591b3ea90e83723199c979d3133ce202" # Ensure TLS 1.2 is enabled for downloads [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 From 5690e55d99be78d683196c55bb679c74eb2c699c Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Thu, 2 Apr 2026 12:31:02 +0100 Subject: [PATCH 237/360] Add rush command wrapper and tests --- README.md | 9 +- package-lock.json | 1 + packages/safe-chain/bin/aikido-rush.js | 14 ++ packages/safe-chain/bin/safe-chain.js | 2 +- packages/safe-chain/package.json | 3 +- .../packagemanager/currentPackageManager.js | 3 + .../rush/createRushPackageManager.js | 134 ++++++++++++++++++ .../rush/createRushPackageManager.spec.js | 66 +++++++++ .../src/packagemanager/rush/runRushCommand.js | 63 ++++++++ .../rush/runRushCommand.spec.js | 99 +++++++++++++ .../src/shell-integration/helpers.js | 6 + .../src/shell-integration/setup-ci.spec.js | 10 +- 12 files changed, 403 insertions(+), 7 deletions(-) create mode 100755 packages/safe-chain/bin/aikido-rush.js create mode 100644 packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js create mode 100644 packages/safe-chain/src/packagemanager/rush/createRushPackageManager.spec.js create mode 100644 packages/safe-chain/src/packagemanager/rush/runRushCommand.js create mode 100644 packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js diff --git a/README.md b/README.md index e173b66..956526b 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Aikido Safe Chain supports the following package managers: - 📦 **yarn** - 📦 **pnpm** - 📦 **pnpx** +- 📦 **rush** - 📦 **bun** - 📦 **bunx** - 📦 **pip** @@ -66,7 +67,7 @@ You can find all available versions on the [releases page](https://github.com/Ai ### Verify the installation 1. **❗Restart your terminal** to start using the Aikido Safe Chain. - - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, poetry, uv and pipx are loaded correctly. If you do not restart your terminal, the aliases will not be available. + - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, rush, bun, bunx, pip, pip3, poetry, uv and pipx are loaded correctly. If you do not restart your terminal, the aliases will not be available. 2. **Verify the installation** by running the verification command: @@ -97,7 +98,7 @@ You can find all available versions on the [releases page](https://github.com/Ai - The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware. -When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry` and `pipx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command. +When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry` and `pipx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command. You can check the installed version by running: @@ -109,7 +110,7 @@ safe-chain --version ### Malware Blocking -The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, uv, poetry or pipx commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. +The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, rush, bun, bunx, pip, pip3, uv, poetry or pipx commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. ### Minimum package age @@ -127,7 +128,7 @@ By default, the minimum package age is 48 hours. This provides an additional sec ### Shell Integration -The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and Python package managers (pip, uv, poetry, pipx). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support: +The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, rush, bun, bunx, and Python package managers (pip, uv, poetry, pipx). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support: - ✅ **Bash** - ✅ **Zsh** diff --git a/package-lock.json b/package-lock.json index ea8c410..75d73b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4026,6 +4026,7 @@ "aikido-poetry": "bin/aikido-poetry.js", "aikido-python": "bin/aikido-python.js", "aikido-python3": "bin/aikido-python3.js", + "aikido-rush": "bin/aikido-rush.js", "aikido-uv": "bin/aikido-uv.js", "aikido-yarn": "bin/aikido-yarn.js", "safe-chain": "bin/safe-chain.js" diff --git a/packages/safe-chain/bin/aikido-rush.js b/packages/safe-chain/bin/aikido-rush.js new file mode 100755 index 0000000..b5d8094 --- /dev/null +++ b/packages/safe-chain/bin/aikido-rush.js @@ -0,0 +1,14 @@ +#!/usr/bin/env node + +import { main } from "../src/main.js"; +import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js"; + +setEcoSystem(ECOSYSTEM_JS); +const packageManagerName = "rush"; +initializePackageManager(packageManagerName); + +(async () => { + var exitCode = await main(process.argv.slice(2)); + process.exit(exitCode); +})(); diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index 8d942e4..a3f80b1 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -96,7 +96,7 @@ function writeHelp() { ui.writeInformation( `- ${chalk.cyan( "safe-chain setup", - )}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, bun, bunx, pip and pip3.`, + )}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, rush, bun, bunx, pip and pip3.`, ); ui.writeInformation( `- ${chalk.cyan( diff --git a/packages/safe-chain/package.json b/packages/safe-chain/package.json index d4f3501..dae27c3 100644 --- a/packages/safe-chain/package.json +++ b/packages/safe-chain/package.json @@ -13,6 +13,7 @@ "aikido-yarn": "bin/aikido-yarn.js", "aikido-pnpm": "bin/aikido-pnpm.js", "aikido-pnpx": "bin/aikido-pnpx.js", + "aikido-rush": "bin/aikido-rush.js", "aikido-bun": "bin/aikido-bun.js", "aikido-bunx": "bin/aikido-bunx.js", "aikido-uv": "bin/aikido-uv.js", @@ -36,7 +37,7 @@ "keywords": [], "author": "Aikido Security", "license": "AGPL-3.0-or-later", - "description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), and [pip](https://pip.pypa.io/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, bunx, uv, or pip/pip3 from downloading or running the malware.", + "description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [rush](https://rushjs.io/), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), and [pip](https://pip.pypa.io/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, rush, bun, bunx, uv, or pip/pip3 from downloading or running the malware.", "dependencies": { "archiver": "^7.0.1", "certifi": "14.5.15", diff --git a/packages/safe-chain/src/packagemanager/currentPackageManager.js b/packages/safe-chain/src/packagemanager/currentPackageManager.js index af297dc..45d897e 100644 --- a/packages/safe-chain/src/packagemanager/currentPackageManager.js +++ b/packages/safe-chain/src/packagemanager/currentPackageManager.js @@ -13,6 +13,7 @@ import { createPipPackageManager } from "./pip/createPackageManager.js"; import { createUvPackageManager } from "./uv/createUvPackageManager.js"; import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js"; import { createPipXPackageManager } from "./pipx/createPipXPackageManager.js"; +import { createRushPackageManager } from "./rush/createRushPackageManager.js"; /** * @type {{packageManagerName: PackageManager | null}} @@ -64,6 +65,8 @@ export function initializePackageManager(packageManagerName, context) { state.packageManagerName = createPoetryPackageManager(); } else if (packageManagerName === "pipx") { state.packageManagerName = createPipXPackageManager(); + } else if (packageManagerName === "rush") { + state.packageManagerName = createRushPackageManager(); } else { throw new Error("Unsupported package manager: " + packageManagerName); } diff --git a/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js b/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js new file mode 100644 index 0000000..1a4aebb --- /dev/null +++ b/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js @@ -0,0 +1,134 @@ +import { runRushCommand } from "./runRushCommand.js"; +import { resolvePackageVersion } from "../../api/npmApi.js"; + +/** + * @returns {import("../currentPackageManager.js").PackageManager} + */ +export function createRushPackageManager() { + return { + runCommand: runRushCommand, + // We pre-scan rush add commands and rely on MITM for install/update flows. + isSupportedCommand: (args) => getRushCommand(args) === "add", + getDependencyUpdatesForCommand: scanRushAddCommand, + }; +} + +/** + * @param {string[]} args + * @returns {Promise} + */ +async function scanRushAddCommand(args) { + if (getRushCommand(args) !== "add") { + return []; + } + + const packageSpecs = extractRushAddPackageSpecs(args); + const changes = []; + + for (const spec of packageSpecs) { + const parsed = parsePackageSpec(spec); + if (!parsed) { + continue; + } + + const exactVersion = await resolvePackageVersion(parsed.name, parsed.version); + if (!exactVersion) { + continue; + } + + changes.push({ + name: parsed.name, + version: exactVersion, + type: "add", + }); + } + + return changes; +} + +/** + * @param {string[]} args + * @returns {string | undefined} + */ +function getRushCommand(args) { + if (!args || args.length === 0) { + return undefined; + } + + return args[0]?.toLowerCase(); +} + +/** + * @param {string[]} args + * @returns {string[]} + */ +function extractRushAddPackageSpecs(args) { + const packageSpecs = []; + + for (let i = 1; i < args.length; i++) { + const arg = args[i]; + if (!arg) { + continue; + } + + if (arg === "--package" || arg === "-p") { + const next = args[i + 1]; + if (next && !next.startsWith("-")) { + packageSpecs.push(next); + i += 1; + } + continue; + } + + if (arg.startsWith("--package=")) { + const value = arg.slice("--package=".length); + if (value) { + packageSpecs.push(value); + } + continue; + } + + if (!arg.startsWith("-")) { + packageSpecs.push(arg); + } + } + + return packageSpecs; +} + +/** + * @param {string} spec + * @returns {{name: string, version: string | null} | null} + */ +function parsePackageSpec(spec) { + const value = removeAlias(spec.trim()); + if (!value) { + return null; + } + + const lastAtIndex = value.lastIndexOf("@"); + if (lastAtIndex > 0) { + return { + name: value.slice(0, lastAtIndex), + version: value.slice(lastAtIndex + 1), + }; + } + + return { + name: value, + version: null, + }; +} + +/** + * @param {string} spec + * @returns {string} + */ +function removeAlias(spec) { + const aliasIndex = spec.indexOf("@npm:"); + if (aliasIndex !== -1) { + return spec.slice(aliasIndex + 5); + } + + return spec; +} diff --git a/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.spec.js b/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.spec.js new file mode 100644 index 0000000..5c02f52 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.spec.js @@ -0,0 +1,66 @@ +import { test, mock } from "node:test"; +import assert from "node:assert"; + +test("createRushPackageManager", async (t) => { + mock.module("../../api/npmApi.js", { + namedExports: { + resolvePackageVersion: async (name, version) => { + if (name === "safe-chain-test") { + return "0.0.1-security"; + } + + if (name === "@scope/tool") { + return version || "2.0.0"; + } + + return null; + }, + }, + }); + + try { + const { createRushPackageManager } = await import("./createRushPackageManager.js"); + + await t.test("should create package manager with required interface", () => { + const pm = createRushPackageManager(); + + assert.ok(pm); + assert.strictEqual(typeof pm.runCommand, "function"); + assert.strictEqual(typeof pm.isSupportedCommand, "function"); + assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function"); + }); + + await t.test("should scan rush add commands", () => { + const pm = createRushPackageManager(); + + assert.strictEqual(pm.isSupportedCommand(["add", "--package", "safe-chain-test"]), true); + assert.strictEqual(pm.isSupportedCommand(["install"]), false); + }); + + await t.test("should parse rush add package specs and resolve versions", async () => { + const pm = createRushPackageManager(); + + const changes = await pm.getDependencyUpdatesForCommand([ + "add", + "--package", + "safe-chain-test", + "--package=@scope/tool@1.2.3", + ]); + + assert.deepStrictEqual(changes, [ + { name: "safe-chain-test", version: "0.0.1-security", type: "add" }, + { name: "@scope/tool", version: "1.2.3", type: "add" }, + ]); + }); + + await t.test("should return no changes for non-add commands", async () => { + const pm = createRushPackageManager(); + + const changes = await pm.getDependencyUpdatesForCommand(["install"]); + + assert.deepStrictEqual(changes, []); + }); + } finally { + mock.reset(); + } +}); diff --git a/packages/safe-chain/src/packagemanager/rush/runRushCommand.js b/packages/safe-chain/src/packagemanager/rush/runRushCommand.js new file mode 100644 index 0000000..ebc3bf1 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/rush/runRushCommand.js @@ -0,0 +1,63 @@ +import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; +import { safeSpawn } from "../../utils/safeSpawn.js"; +import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; + +/** + * @param {string[]} args + * @returns {Promise<{status: number}>} + */ +export async function runRushCommand(args) { + try { + const env = mergeSafeChainProxyEnvironmentVariables(process.env); + normalizeProxyEnvironmentVariables(env); + + const result = await safeSpawn("rush", args, { + stdio: "inherit", + env, + }); + + return { status: result.status }; + } catch (/** @type any */ error) { + return reportCommandExecutionFailure(error, "rush"); + } +} + +/** + * Ensure proxy settings are visible to package manager variants that rely on + * lowercase or npm/yarn-specific environment variables. + * + * @param {Record} env + */ +function normalizeProxyEnvironmentVariables(env) { + if (env.HTTPS_PROXY && !env.HTTP_PROXY) { + env.HTTP_PROXY = env.HTTPS_PROXY; + } + + if (env.HTTP_PROXY && !env.http_proxy) { + env.http_proxy = env.HTTP_PROXY; + } + + if (env.HTTPS_PROXY && !env.https_proxy) { + env.https_proxy = env.HTTPS_PROXY; + } + + if (env.HTTP_PROXY && !env.npm_config_proxy) { + env.npm_config_proxy = env.HTTP_PROXY; + } + + if (env.HTTPS_PROXY && !env.npm_config_https_proxy) { + env.npm_config_https_proxy = env.HTTPS_PROXY; + } + + if (env.HTTP_PROXY && !env.NPM_CONFIG_PROXY) { + env.NPM_CONFIG_PROXY = env.HTTP_PROXY; + } + + if (env.HTTPS_PROXY && !env.NPM_CONFIG_HTTPS_PROXY) { + env.NPM_CONFIG_HTTPS_PROXY = env.HTTPS_PROXY; + } + + if (env.HTTPS_PROXY && !env.YARN_HTTPS_PROXY) { + env.YARN_HTTPS_PROXY = env.HTTPS_PROXY; + } +} diff --git a/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js b/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js new file mode 100644 index 0000000..97676e4 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js @@ -0,0 +1,99 @@ +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; + +describe("runRushCommand", () => { + let runRushCommand; + let safeSpawnMock; + let mergeCalls; + let nextSpawnStatus; + let nextSpawnError; + + beforeEach(async () => { + mergeCalls = []; + nextSpawnStatus = 0; + nextSpawnError = null; + safeSpawnMock = mock.fn(async () => { + if (nextSpawnError) { + const error = nextSpawnError; + nextSpawnError = null; + throw error; + } + + return { status: nextSpawnStatus }; + }); + + mock.module("../../utils/safeSpawn.js", { + namedExports: { + safeSpawn: safeSpawnMock, + }, + }); + + mock.module("../../registryProxy/registryProxy.js", { + namedExports: { + mergeSafeChainProxyEnvironmentVariables: (env) => { + mergeCalls.push(env); + return { + ...env, + HTTPS_PROXY: "http://localhost:8080", + }; + }, + }, + }); + + // commandErrors reports through ui on failures, so provide a no-op mock + mock.module("../../environment/userInteraction.js", { + namedExports: { + ui: { + writeError: () => {}, + }, + }, + }); + + const mod = await import("./runRushCommand.js"); + runRushCommand = mod.runRushCommand; + }); + + afterEach(() => { + mock.reset(); + }); + + it("spawns rush with merged proxy env", async () => { + const res = await runRushCommand(["install"]); + + assert.strictEqual(res.status, 0); + assert.strictEqual(safeSpawnMock.mock.calls.length, 1); + + const [command, args, options] = safeSpawnMock.mock.calls[0].arguments; + assert.strictEqual(command, "rush"); + assert.deepStrictEqual(args, ["install"]); + assert.strictEqual(options.stdio, "inherit"); + assert.strictEqual(options.env.HTTPS_PROXY, "http://localhost:8080"); + assert.strictEqual(options.env.HTTP_PROXY, "http://localhost:8080"); + assert.strictEqual(options.env.https_proxy, "http://localhost:8080"); + assert.strictEqual(options.env.http_proxy, "http://localhost:8080"); + assert.strictEqual(options.env.npm_config_https_proxy, "http://localhost:8080"); + assert.strictEqual(options.env.npm_config_proxy, "http://localhost:8080"); + assert.strictEqual(options.env.NPM_CONFIG_HTTPS_PROXY, "http://localhost:8080"); + assert.strictEqual(options.env.NPM_CONFIG_PROXY, "http://localhost:8080"); + assert.strictEqual(options.env.YARN_HTTPS_PROXY, "http://localhost:8080"); + assert.ok(mergeCalls.length >= 1, "proxy env merge should be called"); + }); + + it("returns spawn result status", async () => { + nextSpawnStatus = 7; + + const res = await runRushCommand(["update"]); + + assert.strictEqual(res.status, 7); + }); + + it("reports failures with rush target", async () => { + nextSpawnError = Object.assign(new Error("spawn failed"), { + code: "ENOENT", + }); + + const res = await runRushCommand(["install"]); + + assert.strictEqual(res.status, 1); + }); +}); diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index 18ba52e..5791aba 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -48,6 +48,12 @@ export const knownAikidoTools = [ ecoSystem: ECOSYSTEM_JS, internalPackageManagerName: "pnpx", }, + { + tool: "rush", + aikidoCommand: "aikido-rush", + ecoSystem: ECOSYSTEM_JS, + internalPackageManagerName: "rush", + }, { tool: "bun", aikidoCommand: "aikido-bun", diff --git a/packages/safe-chain/src/shell-integration/setup-ci.spec.js b/packages/safe-chain/src/shell-integration/setup-ci.spec.js index b437157..bbd05dc 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.spec.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.spec.js @@ -48,8 +48,9 @@ describe("Setup CI shell integration", () => { knownAikidoTools: [ { tool: "npm", aikidoCommand: "aikido-npm" }, { tool: "yarn", aikidoCommand: "aikido-yarn" }, + { tool: "rush", aikidoCommand: "aikido-rush" }, ], - getPackageManagerList: () => "npm, yarn", + getPackageManagerList: () => "npm, yarn, rush", getShimsDir: () => mockShimsDir, }, }); @@ -115,6 +116,10 @@ describe("Setup CI shell integration", () => { const yarnShimPath = path.join(mockShimsDir, "yarn"); assert.ok(fs.existsSync(yarnShimPath), "yarn shim should exist"); + // Check if rush shim was created + const rushShimPath = path.join(mockShimsDir, "rush"); + assert.ok(fs.existsSync(rushShimPath), "rush shim should exist"); + // Check content of npm shim const npmShimContent = fs.readFileSync(npmShimPath, "utf-8"); assert.ok(npmShimContent.includes("aikido-npm"), "npm shim should contain aikido-npm"); @@ -137,6 +142,9 @@ describe("Setup CI shell integration", () => { const yarnShimPath = path.join(mockShimsDir, "yarn.cmd"); assert.ok(fs.existsSync(yarnShimPath), "yarn.cmd shim should exist"); + const rushShimPath = path.join(mockShimsDir, "rush.cmd"); + assert.ok(fs.existsSync(rushShimPath), "rush.cmd shim should exist"); + // Check content of npm.cmd shim const npmShimContent = fs.readFileSync(npmShimPath, "utf-8"); assert.ok(npmShimContent.includes("aikido-npm"), "npm.cmd should contain aikido-npm"); From 6f976f6a2b90b2c218a93f2dca480764d8da6ce5 Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Thu, 2 Apr 2026 13:03:01 +0100 Subject: [PATCH 238/360] Address PR comments --- .../rush/createRushPackageManager.js | 30 ++++++++----- .../src/packagemanager/rush/runRushCommand.js | 44 +++++++++++-------- .../rush/runRushCommand.spec.js | 18 ++++++++ 3 files changed, 62 insertions(+), 30 deletions(-) diff --git a/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js b/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js index 1a4aebb..16c5815 100644 --- a/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js +++ b/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js @@ -22,23 +22,29 @@ async function scanRushAddCommand(args) { return []; } - const packageSpecs = extractRushAddPackageSpecs(args); + const parsedSpecs = extractRushAddPackageSpecs(args) + .map((spec) => parsePackageSpec(spec)) + .filter((spec) => spec !== null); + + const resolvedVersions = await Promise.all( + parsedSpecs.map(async (parsed) => { + const exactVersion = await resolvePackageVersion(parsed.name, parsed.version); + return { + parsed, + exactVersion, + }; + }), + ); + const changes = []; - - for (const spec of packageSpecs) { - const parsed = parsePackageSpec(spec); - if (!parsed) { - continue; - } - - const exactVersion = await resolvePackageVersion(parsed.name, parsed.version); - if (!exactVersion) { + for (const resolved of resolvedVersions) { + if (!resolved.exactVersion) { continue; } changes.push({ - name: parsed.name, - version: exactVersion, + name: resolved.parsed.name, + version: resolved.exactVersion, type: "add", }); } diff --git a/packages/safe-chain/src/packagemanager/rush/runRushCommand.js b/packages/safe-chain/src/packagemanager/rush/runRushCommand.js index ebc3bf1..f6ba3cc 100644 --- a/packages/safe-chain/src/packagemanager/rush/runRushCommand.js +++ b/packages/safe-chain/src/packagemanager/rush/runRushCommand.js @@ -8,8 +8,9 @@ import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; */ export async function runRushCommand(args) { try { - const env = mergeSafeChainProxyEnvironmentVariables(process.env); - normalizeProxyEnvironmentVariables(env); + const env = normalizeProxyEnvironmentVariables( + mergeSafeChainProxyEnvironmentVariables(process.env), + ); const result = await safeSpawn("rush", args, { stdio: "inherit", @@ -27,37 +28,44 @@ export async function runRushCommand(args) { * lowercase or npm/yarn-specific environment variables. * * @param {Record} env + * @returns {Record} */ function normalizeProxyEnvironmentVariables(env) { - if (env.HTTPS_PROXY && !env.HTTP_PROXY) { - env.HTTP_PROXY = env.HTTPS_PROXY; + const normalized = { + ...env, + }; + + if (normalized.HTTPS_PROXY && !normalized.HTTP_PROXY) { + normalized.HTTP_PROXY = normalized.HTTPS_PROXY; } - if (env.HTTP_PROXY && !env.http_proxy) { - env.http_proxy = env.HTTP_PROXY; + if (normalized.HTTP_PROXY && !normalized.http_proxy) { + normalized.http_proxy = normalized.HTTP_PROXY; } - if (env.HTTPS_PROXY && !env.https_proxy) { - env.https_proxy = env.HTTPS_PROXY; + if (normalized.HTTPS_PROXY && !normalized.https_proxy) { + normalized.https_proxy = normalized.HTTPS_PROXY; } - if (env.HTTP_PROXY && !env.npm_config_proxy) { - env.npm_config_proxy = env.HTTP_PROXY; + if (normalized.HTTP_PROXY && !normalized.npm_config_proxy) { + normalized.npm_config_proxy = normalized.HTTP_PROXY; } - if (env.HTTPS_PROXY && !env.npm_config_https_proxy) { - env.npm_config_https_proxy = env.HTTPS_PROXY; + if (normalized.HTTPS_PROXY && !normalized.npm_config_https_proxy) { + normalized.npm_config_https_proxy = normalized.HTTPS_PROXY; } - if (env.HTTP_PROXY && !env.NPM_CONFIG_PROXY) { - env.NPM_CONFIG_PROXY = env.HTTP_PROXY; + if (normalized.HTTP_PROXY && !normalized.NPM_CONFIG_PROXY) { + normalized.NPM_CONFIG_PROXY = normalized.HTTP_PROXY; } - if (env.HTTPS_PROXY && !env.NPM_CONFIG_HTTPS_PROXY) { - env.NPM_CONFIG_HTTPS_PROXY = env.HTTPS_PROXY; + if (normalized.HTTPS_PROXY && !normalized.NPM_CONFIG_HTTPS_PROXY) { + normalized.NPM_CONFIG_HTTPS_PROXY = normalized.HTTPS_PROXY; } - if (env.HTTPS_PROXY && !env.YARN_HTTPS_PROXY) { - env.YARN_HTTPS_PROXY = env.HTTPS_PROXY; + if (normalized.HTTPS_PROXY && !normalized.YARN_HTTPS_PROXY) { + normalized.YARN_HTTPS_PROXY = normalized.HTTPS_PROXY; } + + return normalized; } diff --git a/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js b/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js index 97676e4..b21087e 100644 --- a/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js +++ b/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js @@ -5,11 +5,13 @@ describe("runRushCommand", () => { let runRushCommand; let safeSpawnMock; let mergeCalls; + let mergeResultEnv; let nextSpawnStatus; let nextSpawnError; beforeEach(async () => { mergeCalls = []; + mergeResultEnv = null; nextSpawnStatus = 0; nextSpawnError = null; safeSpawnMock = mock.fn(async () => { @@ -32,6 +34,10 @@ describe("runRushCommand", () => { namedExports: { mergeSafeChainProxyEnvironmentVariables: (env) => { mergeCalls.push(env); + if (mergeResultEnv) { + return mergeResultEnv; + } + return { ...env, HTTPS_PROXY: "http://localhost:8080", @@ -96,4 +102,16 @@ describe("runRushCommand", () => { assert.strictEqual(res.status, 1); }); + + it("does not mutate merged env object", async () => { + mergeResultEnv = { + HTTPS_PROXY: "http://localhost:8080", + }; + + await runRushCommand(["install"]); + + assert.deepStrictEqual(mergeResultEnv, { + HTTPS_PROXY: "http://localhost:8080", + }); + }); }); From e12ae3179579ec41b26e9ae0acfaf32dce204664 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 2 Apr 2026 15:58:19 +0200 Subject: [PATCH 239/360] Fix version number on Windows --- .github/workflows/create-artifact.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/create-artifact.yml b/.github/workflows/create-artifact.yml index 4fee730..da2a1bd 100644 --- a/.github/workflows/create-artifact.yml +++ b/.github/workflows/create-artifact.yml @@ -80,6 +80,7 @@ jobs: if: inputs.version != '' env: VERSION: ${{ inputs.version }} + shell: bash run: npm --no-git-tag-version version $VERSION --workspace=packages/safe-chain --ignore-scripts - name: Create binary From 0aabba668e94a34e3c37dbe7ebc6272b93d5755b Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 2 Apr 2026 08:56:20 -0700 Subject: [PATCH 240/360] Adapt per review --- .../src/registryProxy/http-utils.js | 55 +++++++++++++++++-- .../pip/pipMetadataVersionUtils.js | 32 ++++++----- .../src/registryProxy/mitmRequestHandler.js | 29 ++-------- 3 files changed, 75 insertions(+), 41 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/http-utils.js b/packages/safe-chain/src/registryProxy/http-utils.js index 967aec8..8e2f8e2 100644 --- a/packages/safe-chain/src/registryProxy/http-utils.js +++ b/packages/safe-chain/src/registryProxy/http-utils.js @@ -16,9 +16,42 @@ export function getHeaderValueAsString(headers, headerName) { return header; } +/** + * Returns a copy of headers without the provided header names, matched + * either exactly or case-insensitively. + * + * @param {NodeJS.Dict | undefined} headers + * @param {string[]} headerNames + * @param {{ caseInsensitive?: boolean }} [options] + * @returns {NodeJS.Dict | undefined} + */ +export function omitHeaders(headers, headerNames, options = {}) { + if (!headers) { + return headers; + } + + const omittedHeaderNames = new Set( + options.caseInsensitive + ? headerNames.map((name) => name.toLowerCase()) + : headerNames + ); + /** @type {NodeJS.Dict} */ + const filteredHeaders = {}; + + for (const [headerName, value] of Object.entries(headers)) { + const comparableHeaderName = options.caseInsensitive + ? headerName.toLowerCase() + : headerName; + if (!omittedHeaderNames.has(comparableHeaderName)) { + filteredHeaders[headerName] = value; + } + } + + return filteredHeaders; +} + /** * Remove headers that become stale when the response body is modified. - * Mutates the provided headers object in place. * * @param {NodeJS.Dict | undefined} headers * @returns {void} @@ -28,8 +61,20 @@ export function clearCachingHeaders(headers) { return; } - delete headers["etag"]; - delete headers["last-modified"]; - delete headers["cache-control"]; - delete headers["content-length"]; + const filteredHeaders = omitHeaders(headers, [ + "etag", + "last-modified", + "cache-control", + "content-length", + ]); + + if (!filteredHeaders) { + return; + } + + for (const key of Object.keys(headers)) { + delete headers[key]; + } + + Object.assign(headers, filteredHeaders); } diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipMetadataVersionUtils.js b/packages/safe-chain/src/registryProxy/interceptors/pip/pipMetadataVersionUtils.js index 938b149..4ccb953 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/pipMetadataVersionUtils.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/pipMetadataVersionUtils.js @@ -37,21 +37,27 @@ export function getAvailableVersionsFromJson(json, metadataUrl) { return Object.keys(json.releases); } - if (Array.isArray(json.files)) { - return [ - ...new Set( - json.files - .map((/** @type {any} */ file) => - getPackageVersionFromMetadataFile(file, metadataUrl) - ) - .filter((/** @type {string | undefined} */ version) => - typeof version === "string" - ) - ), - ]; + if (!Array.isArray(json.files)) { + return []; } - return []; + return [ + ...new Set( + json.files + .map((/** @type {any} */ file) => + getPackageVersionFromMetadataFile(file, metadataUrl) + ) + .filter(isDefinedString) + ), + ]; +} + +/** + * @param {string | undefined} value + * @returns {value is string} + */ +function isDefinedString(value) { + return typeof value === "string"; } /** diff --git a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js index b2d82e9..4c4e9ec 100644 --- a/packages/safe-chain/src/registryProxy/mitmRequestHandler.js +++ b/packages/safe-chain/src/registryProxy/mitmRequestHandler.js @@ -3,6 +3,7 @@ import { generateCertForHost } from "./certUtils.js"; import { HttpsProxyAgent } from "https-proxy-agent"; import { ui } from "../environment/userInteraction.js"; import { gunzipSync } from "zlib"; +import { omitHeaders } from "./http-utils.js"; /** * @typedef {import("./interceptors/interceptorBuilder.js").Interceptor} Interceptor @@ -107,28 +108,6 @@ function getRequestPathAndQuery(url) { return url; } -/** - * @param {NodeJS.Dict} headers - * @returns {NodeJS.Dict} - */ -function normalizeRewrittenResponseHeaders(headers) { - /** @type {NodeJS.Dict} */ - const normalizedHeaders = { ...headers }; - - for (const headerName of Object.keys(headers)) { - const lowerHeaderName = headerName.toLowerCase(); - if ( - lowerHeaderName === "content-length" || - lowerHeaderName === "transfer-encoding" || - lowerHeaderName === "content-encoding" - ) { - delete normalizedHeaders[headerName]; - } - } - - return normalizedHeaders; -} - /** * @param {import("http").IncomingMessage} req * @param {string} hostname @@ -240,7 +219,11 @@ function createProxyRequest(hostname, port, req, res, requestHandler) { // For rewritten responses, send the final body uncompressed. // This avoids mismatches between upstream compression metadata and the // rewritten payload on the wire. - const rewrittenHeaders = normalizeRewrittenResponseHeaders(headers); + const rewrittenHeaders = omitHeaders( + headers, + ["content-length", "transfer-encoding", "content-encoding"], + { caseInsensitive: true } + ) || {}; rewrittenHeaders["content-length"] = String(buffer.byteLength); res.writeHead(statusCode, rewrittenHeaders); res.end(buffer); From 1a2805ba56539d35d86d452c71555ae0673b9864 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 2 Apr 2026 13:00:01 -0700 Subject: [PATCH 241/360] Adapt per review --- .../interceptors/pip/modifyPipInfo.js | 70 +++++++++++++------ .../interceptors/pip/modifyPipInfo.spec.js | 26 +++++++ 2 files changed, 74 insertions(+), 22 deletions(-) diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.js b/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.js index d3d10fe..9ef4328 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.js @@ -6,6 +6,11 @@ export { parsePipMetadataUrl, isPipPackageInfoUrl } from "./parsePipPackageUrl.j import { getPipMetadataContentType, logSuppressedVersion } from "./pipMetadataResponseUtils.js"; import { modifyPipJsonResponse } from "./modifyPipJsonResponse.js"; +// Match simple-index anchor tags and capture their href so we can suppress +// individual distribution links from PyPI HTML metadata responses. +const HTML_ANCHOR_HREF_RE = + /]*href\s*=\s*(["'])([^"']+)\1[^>]*>[\s\S]*?<\/a>/gi; + /** * @param {Buffer} body * @param {NodeJS.Dict | undefined} headers @@ -80,30 +85,15 @@ function modifyHtmlSimpleResponse( ) { const html = body.toString("utf8"); let modified = false; - - const updatedHtml = html.replace( - /]*href\s*=\s*(["'])([^"']+)\1[^>]*>[\s\S]*?<\/a>/gi, - (anchor, _quote, href) => { - const resolvedHref = new URL(href, metadataUrl).toString(); - const { packageName: hrefPackageName, version } = parsePipPackageFromUrl( - resolvedHref, - new URL(resolvedHref).host - ); - - if ( - hrefPackageName && - normalizePipPackageName(hrefPackageName) === normalizePipPackageName(packageName) && - version && - isNewlyReleasedPackage(packageName, version) - ) { - modified = true; - logSuppressedVersion(packageName, version); - return ""; - } - - return anchor; + const rewriteHtmlAnchor = createHtmlAnchorRewriter( + metadataUrl, + isNewlyReleasedPackage, + packageName, + () => { + modified = true; } ); + const updatedHtml = html.replace(HTML_ANCHOR_HREF_RE, rewriteHtmlAnchor); if (!modified) return body; const modifiedBuffer = Buffer.from(updatedHtml); @@ -111,6 +101,42 @@ function modifyHtmlSimpleResponse( return modifiedBuffer; } +/** + * @param {string} metadataUrl + * @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage + * @param {string} packageName + * @param {() => void} onModified + * @returns {(anchor: string, quote: string, href: string) => string} + */ +function createHtmlAnchorRewriter( + metadataUrl, + isNewlyReleasedPackage, + packageName, + onModified +) { + return (anchor, _quote, href) => { + const resolvedHref = new URL(href, metadataUrl).toString(); + const { packageName: hrefPackageName, version } = parsePipPackageFromUrl( + resolvedHref, + new URL(resolvedHref).host + ); + + if ( + hrefPackageName && + normalizePipPackageName(hrefPackageName) === + normalizePipPackageName(packageName) && + version && + isNewlyReleasedPackage(packageName, version) + ) { + onModified(); + logSuppressedVersion(packageName, version); + return ""; + } + + return anchor; + }; +} + /** * @param {Buffer} body * @param {NodeJS.Dict | undefined} headers diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.spec.js b/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.spec.js index 46a872f..900941d 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.spec.js @@ -134,6 +134,32 @@ describe("modifyPipInfo", async () => { assert.ok(modified.includes("foo_bar-1.0.0.tar.gz")); }); + it("matches anchor href regex with single quotes and extra attributes", () => { + const headers = { "content-type": "application/vnd.pypi.simple.v1+html" }; + + const body = Buffer.from(` + + foo_bar-2.0.0.tar.gz + + foo_bar-1.0.0.tar.gz + `); + + const modified = modifyPipInfoResponse( + body, + headers, + "https://pypi.org/simple/foo-bar/", + (_packageName, version) => version === "2.0.0", + "foo-bar" + ).toString("utf8"); + + assert.ok(!modified.includes("foo_bar-2.0.0.tar.gz")); + assert.ok(modified.includes("foo_bar-1.0.0.tar.gz")); + }); + it("removes too-young files from simple JSON metadata", () => { const headers = { "content-type": "application/vnd.pypi.simple.v1+json", From edc708f8ff878115336f45b64bd17e45a2bfce17 Mon Sep 17 00:00:00 2001 From: 123Haynes <209302+123Haynes@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:02:05 +0000 Subject: [PATCH 242/360] log which url was used to fetch the malware lists and why --- packages/safe-chain/src/config/settings.js | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index 7aab75f..47c98c4 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -1,6 +1,7 @@ import * as cliArguments from "./cliArguments.js"; import * as configFile from "./configFile.js"; import * as environmentVariables from "./environmentVariables.js"; +import { ui } from "../environment/userInteraction.js"; export const LOGGING_SILENT = "silent"; export const LOGGING_NORMAL = "normal"; @@ -207,23 +208,31 @@ export function getMalwareListBaseUrl() { // Priority 1: CLI argument const cliValue = cliArguments.getMalwareListBaseUrl(); if (cliValue) { - return removeTrailingSlashes(cliValue); + const url = removeTrailingSlashes(cliValue); + ui.writeInformation(`Fetching malware lists from ${url} as defined by CLI argument --safe-chain-malware-list-base-url`); + return url; } // Priority 2: Environment variable const envValue = environmentVariables.getMalwareListBaseUrl(); if (envValue) { - return removeTrailingSlashes(envValue); + const url = removeTrailingSlashes(envValue); + ui.writeInformation(`Fetching malware lists from ${url} as defined by environment variable SAFE_CHAIN_MALWARE_LIST_BASE_URL`); + return url; } // Priority 3: Config file const configValue = configFile.getMalwareListBaseUrl(); if (configValue) { - return removeTrailingSlashes(configValue); + const url = removeTrailingSlashes(configValue); + ui.writeInformation(`Fetching malware lists from ${url} as defined by config file (malwareListBaseUrl)`); + return url; } // Default - return removeTrailingSlashes("https://malware-list.aikido.dev"); + const url = removeTrailingSlashes("https://malware-list.aikido.dev"); + ui.writeInformation(`Fetching malware lists from ${url} (default)`); + return url; } /** From 4d87285fb7c8ec436a5a4e3730c32b9ee46d177a Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 3 Apr 2026 14:23:31 +0200 Subject: [PATCH 243/360] Aikido endpoint 1.2.12 --- 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 c5108f6..4208e06 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.11/EndpointProtection.pkg" -DOWNLOAD_SHA256="17cbe86a9ca444a900162c833ab5f4974b17509f8fcf93fd6a04e7ec4cc90aed" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.12/EndpointProtection.pkg" +DOWNLOAD_SHA256="26492f3cbb1094532dc298199842eb97d60cc670552c9c256314960b298ee784" 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 860f04f..511bdbe 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.11/EndpointProtection.msi" -$DownloadSha256 = "cc191b9e5d8817bf8b063c12277d4d6d591b3ea90e83723199c979d3133ce202" +$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.12/EndpointProtection.msi" +$DownloadSha256 = "06308fc06f95f4b2ad9e48bfd978eb8d02c2928f2ee3c8bba2c81ef2fde21e4f" # Ensure TLS 1.2 is enabled for downloads [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 From 458f7c3c4299fe1a199a357c142f220996cdaaa0 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Fri, 3 Apr 2026 16:43:36 +0200 Subject: [PATCH 244/360] Fix releases to create draft --- .github/workflows/build-and-release.yml | 33 +++++++++++-------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 1e593a3..1fe43a5 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -4,6 +4,8 @@ on: push: tags: - "*" + release: + types: [published] permissions: id-token: write @@ -12,30 +14,19 @@ permissions: jobs: set-version: name: Set version number + if: github.event_name == 'push' runs-on: open-source-releaser outputs: version: ${{ steps.get_version.outputs.tag }} - is_prerelease: ${{ steps.check_prerelease.outputs.is_prerelease }} steps: - - name: Checkout code - uses: actions/checkout@v3 - - name: Set version number id: get_version run: | version="${{ github.ref_name }}" echo "tag=$version" >> $GITHUB_OUTPUT - - name: Check if pre-release - id: check_prerelease - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - IS_PRERELEASE=$(gh release view ${{ steps.get_version.outputs.tag }} --json isPrerelease --jq '.isPrerelease') - echo "is_prerelease=$IS_PRERELEASE" >> $GITHUB_OUTPUT - echo "Release ${{ steps.get_version.outputs.tag }} is pre-release: $IS_PRERELEASE" - create-binaries: + if: github.event_name == 'push' needs: set-version uses: ./.github/workflows/create-artifact.yml with: @@ -43,6 +34,7 @@ jobs: publish-binaries: name: Publish to GitHub release + if: github.event_name == 'push' needs: [set-version, create-binaries] runs-on: open-source-releaser steps: @@ -81,11 +73,15 @@ jobs: cp install-scripts/uninstall-endpoint-mac.sh release-artifacts/uninstall-endpoint-mac.sh cp install-scripts/uninstall-endpoint-windows.ps1 release-artifacts/uninstall-endpoint-windows.ps1 - - name: Upload binaries to existing GitHub Release + - name: Create draft release and upload assets env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.set-version.outputs.version }} run: | - gh release upload ${{ needs.set-version.outputs.version }} \ + if ! gh release view "$VERSION" &>/dev/null; then + gh release create "$VERSION" --draft --title "$VERSION" --generate-notes + fi + gh release upload "$VERSION" --clobber \ release-artifacts/safe-chain-macos-x64 \ release-artifacts/safe-chain-macos-arm64 \ release-artifacts/safe-chain-linux-x64 \ @@ -105,8 +101,7 @@ jobs: publish-npm: name: Publish to npm - needs: [set-version, create-binaries] - if: needs.set-version.outputs.is_prerelease != 'true' + if: github.event_name == 'release' runs-on: ubuntu-latest steps: @@ -125,7 +120,7 @@ jobs: run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci - name: Set the version in safe-chain package - run: npm --no-git-tag-version version ${{ needs.set-version.outputs.version }} --workspace=packages/safe-chain + run: npm --no-git-tag-version version ${{ github.event.release.tag_name }} --workspace=packages/safe-chain - name: Install dependencies run: npm ci @@ -141,5 +136,5 @@ jobs: - name: Publish to npm run: | - echo "Publishing version ${{ needs.set-version.outputs.version }} to NPM" + echo "Publishing version ${{ github.event.release.tag_name }} to NPM" npm publish --workspace=packages/safe-chain --access public --provenance From aeb3a47cab2eedc41bc713d1f8b1af1771d84885 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 3 Apr 2026 14:32:10 -0700 Subject: [PATCH 245/360] Change log level --- packages/safe-chain/src/config/settings.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index 47c98c4..d04411e 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -209,7 +209,7 @@ export function getMalwareListBaseUrl() { const cliValue = cliArguments.getMalwareListBaseUrl(); if (cliValue) { const url = removeTrailingSlashes(cliValue); - ui.writeInformation(`Fetching malware lists from ${url} as defined by CLI argument --safe-chain-malware-list-base-url`); + ui.writeVerbose(`Fetching malware lists from ${url} as defined by CLI argument --safe-chain-malware-list-base-url`); return url; } @@ -217,7 +217,7 @@ export function getMalwareListBaseUrl() { const envValue = environmentVariables.getMalwareListBaseUrl(); if (envValue) { const url = removeTrailingSlashes(envValue); - ui.writeInformation(`Fetching malware lists from ${url} as defined by environment variable SAFE_CHAIN_MALWARE_LIST_BASE_URL`); + ui.writeVerbose(`Fetching malware lists from ${url} as defined by environment variable SAFE_CHAIN_MALWARE_LIST_BASE_URL`); return url; } @@ -225,14 +225,12 @@ export function getMalwareListBaseUrl() { const configValue = configFile.getMalwareListBaseUrl(); if (configValue) { const url = removeTrailingSlashes(configValue); - ui.writeInformation(`Fetching malware lists from ${url} as defined by config file (malwareListBaseUrl)`); + ui.writeVerbose(`Fetching malware lists from ${url} as defined by config file (malwareListBaseUrl)`); return url; } // Default - const url = removeTrailingSlashes("https://malware-list.aikido.dev"); - ui.writeInformation(`Fetching malware lists from ${url} (default)`); - return url; + return removeTrailingSlashes("https://malware-list.aikido.dev"); } /** From 1eb4fe05fdd7162cd0e4cbe58e41f01e2cfab95e Mon Sep 17 00:00:00 2001 From: Chris Ingram Date: Mon, 6 Apr 2026 13:01:42 +0100 Subject: [PATCH 246/360] Add pdm package manager support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PDM is a modern Python package manager using pyproject.toml (PEP 621). Uses the same MITM-only proxy approach as poetry/uv/pipx — all malware detection and minimum package age enforcement happens at the proxy layer by intercepting PyPI requests. --- README.md | 5 +- package-lock.json | 2 + packages/safe-chain/bin/aikido-pdm.js | 13 + packages/safe-chain/package.json | 3 +- .../packagemanager/currentPackageManager.js | 3 + .../pdm/createPdmPackageManager.js | 72 ++++ .../pdm/createPdmPackageManager.spec.js | 14 + .../src/shell-integration/helpers.js | 6 + .../startup-scripts/init-fish.fish | 4 + .../startup-scripts/init-posix.sh | 4 + .../startup-scripts/init-pwsh.ps1 | 4 + test/e2e/Dockerfile | 4 + test/e2e/pdm.e2e.spec.js | 317 ++++++++++++++++++ 13 files changed, 448 insertions(+), 3 deletions(-) create mode 100644 packages/safe-chain/bin/aikido-pdm.js create mode 100644 packages/safe-chain/src/packagemanager/pdm/createPdmPackageManager.js create mode 100644 packages/safe-chain/src/packagemanager/pdm/createPdmPackageManager.spec.js create mode 100644 test/e2e/pdm.e2e.spec.js diff --git a/README.md b/README.md index 3e73137..800d30c 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Aikido Safe Chain supports the following package managers: - 📦 **uv** - 📦 **poetry** - 📦 **pipx** +- 📦 **pdm** # Usage @@ -66,7 +67,7 @@ You can find all available versions on the [releases page](https://github.com/Ai ### Verify the installation 1. **❗Restart your terminal** to start using the Aikido Safe Chain. - - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, poetry, uv and pipx are loaded correctly. If you do not restart your terminal, the aliases will not be available. + - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, poetry, uv, pipx and pdm are loaded correctly. If you do not restart your terminal, the aliases will not be available. 2. **Verify the installation** by running the verification command: @@ -97,7 +98,7 @@ You can find all available versions on the [releases page](https://github.com/Ai - The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware. -When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry` and `pipx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command. +When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry`, `pipx` and `pdm` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command. You can check the installed version by running: diff --git a/package-lock.json b/package-lock.json index ea8c410..e6dc7b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3108,6 +3108,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4018,6 +4019,7 @@ "aikido-bunx": "bin/aikido-bunx.js", "aikido-npm": "bin/aikido-npm.js", "aikido-npx": "bin/aikido-npx.js", + "aikido-pdm": "bin/aikido-pdm.js", "aikido-pip": "bin/aikido-pip.js", "aikido-pip3": "bin/aikido-pip3.js", "aikido-pipx": "bin/aikido-pipx.js", diff --git a/packages/safe-chain/bin/aikido-pdm.js b/packages/safe-chain/bin/aikido-pdm.js new file mode 100644 index 0000000..9c6cf94 --- /dev/null +++ b/packages/safe-chain/bin/aikido-pdm.js @@ -0,0 +1,13 @@ +#!/usr/bin/env node + +import { main } from "../src/main.js"; +import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js"; + +setEcoSystem(ECOSYSTEM_PY); +initializePackageManager("pdm"); + +(async () => { + var exitCode = await main(process.argv.slice(2)); + process.exit(exitCode); +})(); diff --git a/packages/safe-chain/package.json b/packages/safe-chain/package.json index d4f3501..1ed2d5b 100644 --- a/packages/safe-chain/package.json +++ b/packages/safe-chain/package.json @@ -22,6 +22,7 @@ "aikido-python3": "bin/aikido-python3.js", "aikido-poetry": "bin/aikido-poetry.js", "aikido-pipx": "bin/aikido-pipx.js", + "aikido-pdm": "bin/aikido-pdm.js", "safe-chain": "bin/safe-chain.js" }, "type": "module", @@ -36,7 +37,7 @@ "keywords": [], "author": "Aikido Security", "license": "AGPL-3.0-or-later", - "description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), and [pip](https://pip.pypa.io/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, bunx, uv, or pip/pip3 from downloading or running the malware.", + "description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), [pip](https://pip.pypa.io/), and [pdm](https://pdm-project.org/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, bunx, uv, pip/pip3, or pdm from downloading or running the malware.", "dependencies": { "archiver": "^7.0.1", "certifi": "14.5.15", diff --git a/packages/safe-chain/src/packagemanager/currentPackageManager.js b/packages/safe-chain/src/packagemanager/currentPackageManager.js index af297dc..79e4625 100644 --- a/packages/safe-chain/src/packagemanager/currentPackageManager.js +++ b/packages/safe-chain/src/packagemanager/currentPackageManager.js @@ -13,6 +13,7 @@ import { createPipPackageManager } from "./pip/createPackageManager.js"; import { createUvPackageManager } from "./uv/createUvPackageManager.js"; import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js"; import { createPipXPackageManager } from "./pipx/createPipXPackageManager.js"; +import { createPdmPackageManager } from "./pdm/createPdmPackageManager.js"; /** * @type {{packageManagerName: PackageManager | null}} @@ -64,6 +65,8 @@ export function initializePackageManager(packageManagerName, context) { state.packageManagerName = createPoetryPackageManager(); } else if (packageManagerName === "pipx") { state.packageManagerName = createPipXPackageManager(); + } else if (packageManagerName === "pdm") { + state.packageManagerName = createPdmPackageManager(); } else { throw new Error("Unsupported package manager: " + packageManagerName); } diff --git a/packages/safe-chain/src/packagemanager/pdm/createPdmPackageManager.js b/packages/safe-chain/src/packagemanager/pdm/createPdmPackageManager.js new file mode 100644 index 0000000..1649a89 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/pdm/createPdmPackageManager.js @@ -0,0 +1,72 @@ +import { ui } from "../../environment/userInteraction.js"; +import { safeSpawn } from "../../utils/safeSpawn.js"; +import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js"; +import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js"; +import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; + +/** + * @returns {import("../currentPackageManager.js").PackageManager} + */ +export function createPdmPackageManager() { + return { + runCommand: (args) => runPdmCommand(args), + + // MITM only approach for PDM + isSupportedCommand: () => false, + getDependencyUpdatesForCommand: () => [], + }; +} + +/** + * Sets CA bundle environment variables used by PDM and Python libraries. + * PDM uses httpx (via unearth) which respects SSL_CERT_FILE through Python's ssl module. + * + * @param {NodeJS.ProcessEnv} env - Environment object to modify + * @param {string} combinedCaPath - Path to the combined CA bundle + */ +function setPdmCaBundleEnvironmentVariables(env, combinedCaPath) { + // SSL_CERT_FILE: Used by Python SSL libraries and httpx (which PDM uses) + if (env.SSL_CERT_FILE) { + ui.writeWarning("Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten."); + } + env.SSL_CERT_FILE = combinedCaPath; + + // REQUESTS_CA_BUNDLE: Used by the requests library (PDM plugins may use it) + if (env.REQUESTS_CA_BUNDLE) { + ui.writeWarning("Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten."); + } + env.REQUESTS_CA_BUNDLE = combinedCaPath; + + // PIP_CERT: PDM may use pip internally + if (env.PIP_CERT) { + ui.writeWarning("Safe-chain: User defined PIP_CERT found in environment. It will be overwritten."); + } + env.PIP_CERT = combinedCaPath; +} + +/** + * Runs a pdm command with safe-chain's certificate bundle and proxy configuration. + * + * PDM respects standard HTTP_PROXY/HTTPS_PROXY environment variables through + * httpx which it uses for package downloads. + * + * @param {string[]} args - Command line arguments to pass to pdm + * @returns {Promise<{status: number}>} Exit status of the pdm command + */ +async function runPdmCommand(args) { + try { + const env = mergeSafeChainProxyEnvironmentVariables(process.env); + + const combinedCaPath = getCombinedCaBundlePath(); + setPdmCaBundleEnvironmentVariables(env, combinedCaPath); + + const result = await safeSpawn("pdm", args, { + stdio: "inherit", + env, + }); + + return { status: result.status }; + } catch (/** @type any */ error) { + return reportCommandExecutionFailure(error, "pdm"); + } +} diff --git a/packages/safe-chain/src/packagemanager/pdm/createPdmPackageManager.spec.js b/packages/safe-chain/src/packagemanager/pdm/createPdmPackageManager.spec.js new file mode 100644 index 0000000..2b2266b --- /dev/null +++ b/packages/safe-chain/src/packagemanager/pdm/createPdmPackageManager.spec.js @@ -0,0 +1,14 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { createPdmPackageManager } from "./createPdmPackageManager.js"; + +test("createPdmPackageManager", async (t) => { + await t.test("should create package manager with required interface", () => { + const pm = createPdmPackageManager(); + + assert.ok(pm); + assert.strictEqual(typeof pm.runCommand, "function"); + assert.strictEqual(typeof pm.isSupportedCommand, "function"); + assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function"); + }); +}); diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index 18ba52e..6bef263 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -102,6 +102,12 @@ export const knownAikidoTools = [ ecoSystem: ECOSYSTEM_PY, internalPackageManagerName: "pipx", }, + { + tool: "pdm", + aikidoCommand: "aikido-pdm", + ecoSystem: ECOSYSTEM_PY, + internalPackageManagerName: "pdm", + }, // When adding a new tool here, also update the documentation for the new tool in the README.md ]; diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish index 13463f6..a33c3d5 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish @@ -69,6 +69,10 @@ function pipx wrapSafeChainCommand "pipx" $argv end +function pdm + wrapSafeChainCommand "pdm" $argv +end + function printSafeChainWarning set original_cmd $argv[1] diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh index ebaaf3c..51eece2 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh @@ -65,6 +65,10 @@ function pipx() { wrapSafeChainCommand "pipx" "$@" } +function pdm() { + wrapSafeChainCommand "pdm" "$@" +} + function printSafeChainWarning() { # \033[43;30m is used to set the background color to yellow and text color to black # \033[0m is used to reset the text formatting diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 index f82d0fc..15ac86c 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 @@ -70,6 +70,10 @@ function pipx { Invoke-WrappedCommand "pipx" $args $MyInvocation.Line $MyInvocation.OffsetInLine } +function pdm { + Invoke-WrappedCommand "pdm" $args $MyInvocation.Line $MyInvocation.OffsetInLine +} + function Write-SafeChainWarning { param([string]$Command) diff --git a/test/e2e/Dockerfile b/test/e2e/Dockerfile index bc7ffc2..ff2a86b 100644 --- a/test/e2e/Dockerfile +++ b/test/e2e/Dockerfile @@ -77,6 +77,10 @@ RUN apt-get update && apt-get install -y pipx && \ pipx install poetry && \ ln -sf /root/.local/bin/poetry /usr/local/bin/poetry +# Install PDM +RUN pipx install pdm && \ + ln -sf /root/.local/bin/pdm /usr/local/bin/pdm + # Copy and install Safe chain COPY --from=builder /app/*.tgz /pkgs/ RUN npm install -g /pkgs/*.tgz diff --git a/test/e2e/pdm.e2e.spec.js b/test/e2e/pdm.e2e.spec.js new file mode 100644 index 0000000..96379fb --- /dev/null +++ b/test/e2e/pdm.e2e.spec.js @@ -0,0 +1,317 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +describe("E2E: pdm coverage", () => { + let container; + + before(async () => { + DockerTestContainer.buildImage(); + }); + + beforeEach(async () => { + // Run a new Docker container for each test + container = new DockerTestContainer(); + await container.start(); + + const installationShell = await container.openShell("zsh"); + await installationShell.runCommand("safe-chain setup"); + + // Clear pdm cache + await installationShell.runCommand("command pdm cache clear"); + }); + + afterEach(async () => { + // Stop and clean up the container after each test + if (container) { + await container.stop(); + container = null; + } + }); + + it(`successfully installs known safe packages with pdm add`, async () => { + const shell = await container.openShell("zsh"); + + // Initialize a new pdm project + await shell.runCommand("mkdir /tmp/test-pdm-project && cd /tmp/test-pdm-project"); + await shell.runCommand("cd /tmp/test-pdm-project && pdm init --non-interactive"); + + // Add a safe package + const result = await shell.runCommand( + "cd /tmp/test-pdm-project && pdm add requests" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Installing"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`pdm add with specific version`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-pdm-version && cd /tmp/test-pdm-version"); + await shell.runCommand("cd /tmp/test-pdm-version && pdm init --non-interactive"); + + const result = await shell.runCommand( + "cd /tmp/test-pdm-version && pdm add requests==2.32.3" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Installing"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`safe-chain blocks installation of malicious Python packages via pdm`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-pdm-malware && cd /tmp/test-pdm-malware"); + await shell.runCommand("cd /tmp/test-pdm-malware && pdm init --non-interactive"); + + const result = await shell.runCommand( + "cd /tmp/test-pdm-malware && pdm add safe-chain-pi-test" + ); + + assert.ok( + result.output.includes("blocked by safe-chain"), + `Expected malware to be blocked. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected exit message. Output was:\n${result.output}` + ); + }); + + it(`pdm install installs dependencies from pyproject.toml`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-pdm-install && cd /tmp/test-pdm-install"); + await shell.runCommand("cd /tmp/test-pdm-install && pdm init --non-interactive"); + await shell.runCommand("cd /tmp/test-pdm-install && pdm add requests"); + + // Now remove the virtualenv and run install + await shell.runCommand("cd /tmp/test-pdm-install && rm -rf .venv"); + + const result = await shell.runCommand( + "cd /tmp/test-pdm-install && pdm install" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Installing"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`pdm update updates dependencies`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-pdm-update && cd /tmp/test-pdm-update"); + await shell.runCommand("cd /tmp/test-pdm-update && pdm init --non-interactive"); + await shell.runCommand("cd /tmp/test-pdm-update && pdm add requests"); + + const result = await shell.runCommand( + "cd /tmp/test-pdm-update && pdm update" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Updating"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`pdm update with specific packages`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-pdm-update-specific && cd /tmp/test-pdm-update-specific"); + await shell.runCommand("cd /tmp/test-pdm-update-specific && pdm init --non-interactive"); + await shell.runCommand("cd /tmp/test-pdm-update-specific && pdm add requests certifi"); + + const result = await shell.runCommand( + "cd /tmp/test-pdm-update-specific && pdm update requests" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Updating"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`pdm add with multiple packages`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-pdm-multi && cd /tmp/test-pdm-multi"); + await shell.runCommand("cd /tmp/test-pdm-multi && pdm init --non-interactive"); + + const result = await shell.runCommand( + "cd /tmp/test-pdm-multi && pdm add requests certifi" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Installing"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`pdm add with extras`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-pdm-extras && cd /tmp/test-pdm-extras"); + await shell.runCommand("cd /tmp/test-pdm-extras && pdm init --non-interactive"); + + // Use quotes to prevent shell expansion of square brackets + const result = await shell.runCommand( + 'cd /tmp/test-pdm-extras && pdm add "requests[security]"' + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Installing"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`pdm add with development group`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-pdm-dev && cd /tmp/test-pdm-dev"); + await shell.runCommand("cd /tmp/test-pdm-dev && pdm init --non-interactive"); + + const result = await shell.runCommand( + "cd /tmp/test-pdm-dev && pdm add -dG dev pytest" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Installing"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`pdm lock creates/updates lock file`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-pdm-lock && cd /tmp/test-pdm-lock"); + await shell.runCommand("cd /tmp/test-pdm-lock && pdm init --non-interactive"); + await shell.runCommand("cd /tmp/test-pdm-lock && pdm add requests"); + await shell.runCommand("cd /tmp/test-pdm-lock && rm pdm.lock"); + + const result = await shell.runCommand( + "cd /tmp/test-pdm-lock && pdm lock" + ); + + assert.ok( + result.output.includes("no malware found.") || result.output.includes("Resolving") || result.output.includes("lock file"), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + + it(`pdm remove does not download packages`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-pdm-remove && cd /tmp/test-pdm-remove"); + await shell.runCommand("cd /tmp/test-pdm-remove && pdm init --non-interactive"); + await shell.runCommand("cd /tmp/test-pdm-remove && pdm add requests"); + + const result = await shell.runCommand( + "cd /tmp/test-pdm-remove && pdm remove requests" + ); + + // Remove should succeed - it doesn't download packages, just modifies pyproject.toml + assert.ok( + !result.output.includes("blocked"), + `Remove command should not trigger downloads. Output was:\n${result.output}` + ); + }); + + it(`blocks malware during pdm install`, async () => { + const shell = await container.openShell("zsh"); + + // Create a project with malware in dependencies + await shell.runCommand("mkdir /tmp/test-pdm-install-malware && cd /tmp/test-pdm-install-malware"); + await shell.runCommand("cd /tmp/test-pdm-install-malware && pdm init --non-interactive"); + + // Add malware package - this will create lock file and attempt download + const result = await shell.runCommand( + "cd /tmp/test-pdm-install-malware && pdm add safe-chain-pi-test 2>&1" + ); + + assert.ok( + result.output.includes("blocked by safe-chain"), + `Expected malware to be blocked during add (which triggers install). Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected exit message. Output was:\n${result.output}` + ); + }); + + it(`blocks malware when adding malicious dependency alongside safe one`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-pdm-batch && cd /tmp/test-pdm-batch"); + await shell.runCommand("cd /tmp/test-pdm-batch && pdm init --non-interactive"); + + // Try to add malware alongside safe package + const result = await shell.runCommand( + "cd /tmp/test-pdm-batch && pdm add safe-chain-pi-test requests 2>&1" + ); + + assert.ok( + result.output.includes("blocked by safe-chain"), + `Expected malware to be blocked. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected exit message. Output was:\n${result.output}` + ); + + // Verify safe package was also not installed due to malware in batch + const listResult = await shell.runCommand("cd /tmp/test-pdm-batch && pdm list"); + assert.ok( + !listResult.output.includes("requests"), + `Safe package should not be installed when batch includes malware. Output was:\n${listResult.output}` + ); + }); + + it(`pdm non-network commands work correctly`, async () => { + const shell = await container.openShell("zsh"); + + await shell.runCommand("mkdir /tmp/test-pdm-nonnetwork && cd /tmp/test-pdm-nonnetwork"); + await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm init --non-interactive"); + await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm add requests"); + + // Test pdm --version + const versionResult = await shell.runCommand("pdm --version"); + assert.ok( + versionResult.output.includes("PDM") || versionResult.output.includes("pdm"), + `Expected version output. Output was:\n${versionResult.output}` + ); + + // Test pdm list (list installed packages) + const listResult = await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm list"); + assert.ok( + listResult.output.includes("requests"), + `Expected to see installed package. Output was:\n${listResult.output}` + ); + + // Test pdm info (show project info) + const infoResult = await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm info"); + assert.ok( + infoResult.output.includes("PDM") || infoResult.output.includes("Python") || infoResult.output.includes("Project"), + `Expected project info. Output was:\n${infoResult.output}` + ); + + // Test pdm config (show configuration) + const configResult = await shell.runCommand("pdm config"); + assert.ok( + configResult.output.length > 0, + `Expected configuration output. Output was:\n${configResult.output}` + ); + + // Test pdm run (execute command in virtualenv) - non-network command + const runResult = await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm run python --version"); + assert.ok( + runResult.output.includes("Python"), + `Expected Python version output. Output was:\n${runResult.output}` + ); + }); +}); From 7994c42f8c1faca4e9972b49576e79ab02adabbc Mon Sep 17 00:00:00 2001 From: willem-delbare <20814660+willem-delbare@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:30:49 +0200 Subject: [PATCH 247/360] Add npm-shrinkwrap.json file --- package-lock.json => npm-shrinkwrap.json | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename package-lock.json => npm-shrinkwrap.json (100%) diff --git a/package-lock.json b/npm-shrinkwrap.json similarity index 100% rename from package-lock.json rename to npm-shrinkwrap.json From ae63d42ae90a2791d6064774da396ae01bef4b9d Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 6 Apr 2026 15:03:11 +0200 Subject: [PATCH 248/360] Copy shrinkwrap before publishing --- .github/workflows/build-and-release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 1fe43a5..772b928 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -133,6 +133,7 @@ jobs: cp README.md packages/safe-chain/ cp LICENSE packages/safe-chain/ cp -r docs packages/safe-chain/ + cp npm-shrinkwrap.json packages/safe-chain/ - name: Publish to npm run: | From a5541df5ec242006e61395d8274123b4748f1efa Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 6 Apr 2026 15:08:23 +0200 Subject: [PATCH 249/360] Fix pre-release publishing --- .github/workflows/build-and-release.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 772b928..7cd2a91 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -137,5 +137,11 @@ jobs: - name: Publish to npm run: | - echo "Publishing version ${{ github.event.release.tag_name }} to NPM" - npm publish --workspace=packages/safe-chain --access public --provenance + VERSION="${{ github.event.release.tag_name }}" + echo "Publishing version $VERSION to NPM" + if [[ "$VERSION" == *"-"* ]]; then + PRERELEASE_TAG=$(echo "$VERSION" | sed 's/.*-\([^-]*\)$/\1/') + npm publish --workspace=packages/safe-chain --access public --provenance --tag "$PRERELEASE_TAG" + else + npm publish --workspace=packages/safe-chain --access public --provenance + fi From 47ee9718d350e8107c221ac8429a75166d297bcb Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 6 Apr 2026 15:15:01 +0200 Subject: [PATCH 250/360] Remove check on npm release --- .github/workflows/build-and-release.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 7cd2a91..82cae34 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -101,7 +101,6 @@ jobs: publish-npm: name: Publish to npm - if: github.event_name == 'release' runs-on: ubuntu-latest steps: From ced5e264208cb3959b4d95ef2ed5800fcfb3d5c0 Mon Sep 17 00:00:00 2001 From: Chris Ingram Date: Tue, 7 Apr 2026 11:19:04 +0100 Subject: [PATCH 251/360] File mode on aikido-pdm.js --- packages/safe-chain/bin/aikido-pdm.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 packages/safe-chain/bin/aikido-pdm.js diff --git a/packages/safe-chain/bin/aikido-pdm.js b/packages/safe-chain/bin/aikido-pdm.js old mode 100644 new mode 100755 From 070afb93640c846be696941cae96f4e4a253f743 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 7 Apr 2026 17:19:45 +0200 Subject: [PATCH 252/360] Remove archiver dependency and safe-chain ultimate troubleshooting --- npm-shrinkwrap.json => package-lock.json | 942 +----------------- packages/safe-chain/package.json | 2 - .../src/ultimate/ultimateTroubleshooting.js | 111 --- 3 files changed, 26 insertions(+), 1029 deletions(-) rename npm-shrinkwrap.json => package-lock.json (76%) delete mode 100644 packages/safe-chain/src/ultimate/ultimateTroubleshooting.js diff --git a/npm-shrinkwrap.json b/package-lock.json similarity index 76% rename from npm-shrinkwrap.json rename to package-lock.json index ea8c410..c852d4f 100644 --- a/npm-shrinkwrap.json +++ b/package-lock.json @@ -555,102 +555,6 @@ "node": "20 || >=22" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -844,26 +748,6 @@ "win32" ] }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@types/archiver": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-7.0.0.tgz", - "integrity": "sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/readdir-glob": "*" - } - }, "node_modules/@types/ini": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz", @@ -938,16 +822,6 @@ "@types/node": "*" } }, - "node_modules/@types/readdir-glob": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz", - "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/retry": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz", @@ -1045,18 +919,6 @@ "node": ">= 6" } }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, "node_modules/agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", @@ -1070,6 +932,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1079,6 +942,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -1090,243 +954,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/archiver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", - "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", - "license": "MIT", - "dependencies": { - "archiver-utils": "^5.0.2", - "async": "^3.2.4", - "buffer-crc32": "^1.0.0", - "readable-stream": "^4.0.0", - "readdir-glob": "^1.1.2", - "tar-stream": "^3.0.0", - "zip-stream": "^6.0.1" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/archiver-utils": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", - "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", - "license": "MIT", - "dependencies": { - "glob": "^10.0.0", - "graceful-fs": "^4.2.0", - "is-stream": "^2.0.1", - "lazystream": "^1.0.0", - "lodash": "^4.17.15", - "normalize-path": "^3.0.0", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/archiver-utils/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/archiver-utils/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/archiver-utils/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" - }, - "node_modules/archiver-utils/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/archiver-utils/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/archiver-utils/node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/archiver-utils/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/archiver-utils/node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/archiver/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/archiver/node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/archiver/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/archiver/node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "license": "MIT" - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1337,6 +964,7 @@ "version": "1.7.3", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "dev": true, "license": "Apache-2.0", "peerDependencies": { "react-native-b4a": "*" @@ -1347,16 +975,11 @@ } } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, "node_modules/bare-events": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "dev": true, "license": "Apache-2.0", "peerDependencies": { "bare-abort-controller": "*" @@ -1453,6 +1076,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, "funding": [ { "type": "github", @@ -1503,15 +1127,6 @@ "dev": true, "license": "MIT" }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -1537,15 +1152,6 @@ "ieee754": "^1.1.13" } }, - "node_modules/buffer-crc32": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", - "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/cacache": { "version": "20.0.3", "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.3.tgz", @@ -1627,6 +1233,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -1639,6 +1246,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -1653,205 +1261,13 @@ "node": ">= 0.8" } }, - "node_modules/compress-commons": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", - "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", - "license": "MIT", - "dependencies": { - "crc-32": "^1.2.0", - "crc32-stream": "^6.0.0", - "is-stream": "^2.0.1", - "normalize-path": "^3.0.0", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/compress-commons/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/compress-commons/node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/compress-commons/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/compress-commons/node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, "license": "MIT" }, - "node_modules/crc-32": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", - "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", - "license": "Apache-2.0", - "bin": { - "crc32": "bin/crc32.njs" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/crc32-stream": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", - "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", - "license": "MIT", - "dependencies": { - "crc-32": "^1.2.0", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/crc32-stream/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/crc32-stream/node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/crc32-stream/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/crc32-stream/node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -1938,16 +1354,11 @@ "readable-stream": "^2.0.2" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/encoding": { @@ -2073,28 +1484,11 @@ "node": ">=6" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/events-universal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "bare-events": "^2.7.0" @@ -2114,6 +1508,7 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, "license": "MIT" }, "node_modules/fdir": { @@ -2134,22 +1529,6 @@ } } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/form-data": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", @@ -2307,6 +1686,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, "node_modules/has-symbols": { @@ -2409,6 +1789,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, "funding": [ { "type": "github", @@ -2438,6 +1819,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -2495,50 +1877,19 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, "license": "MIT" }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2574,24 +1925,6 @@ ], "license": "MIT" }, - "node_modules/lazystream": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", - "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", - "license": "MIT", - "dependencies": { - "readable-stream": "^2.0.5" - }, - "engines": { - "node": ">= 0.6.3" - } - }, - "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "license": "MIT" - }, "node_modules/lru-cache": { "version": "11.2.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", @@ -2948,15 +2281,6 @@ "nan": "^2.17.0" } }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/npm-package-arg": { "version": "13.0.2", "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-13.0.2.tgz", @@ -3057,21 +2381,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -3203,19 +2512,11 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, "license": "MIT" }, "node_modules/progress": { @@ -3279,6 +2580,7 @@ "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -3290,27 +2592,6 @@ "util-deprecate": "~1.0.1" } }, - "node_modules/readdir-glob": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", - "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", - "license": "Apache-2.0", - "dependencies": { - "minimatch": "^5.1.0" - } - }, - "node_modules/readdir-glob/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -3355,6 +2636,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, "license": "MIT" }, "node_modules/safer-buffer": { @@ -3376,39 +2658,6 @@ "node": ">=10" } }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -3520,6 +2769,7 @@ "version": "2.23.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "dev": true, "license": "MIT", "dependencies": { "events-universal": "^1.0.0", @@ -3531,6 +2781,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -3540,21 +2791,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -3569,19 +2806,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -3649,6 +2874,7 @@ "version": "3.1.7", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, "license": "MIT", "dependencies": { "b4a": "^1.6.4", @@ -3670,6 +2896,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.4" @@ -3783,6 +3010,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, "license": "MIT" }, "node_modules/validate-npm-package-name": { @@ -3812,21 +3040,6 @@ "webidl-conversions": "^3.0.0" } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -3845,24 +3058,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -3915,95 +3110,11 @@ "node": ">=10" } }, - "node_modules/zip-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", - "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", - "license": "MIT", - "dependencies": { - "archiver-utils": "^5.0.0", - "compress-commons": "^6.0.2", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/zip-stream/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/zip-stream/node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/zip-stream/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/zip-stream/node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "packages/safe-chain": { "name": "@aikidosec/safe-chain", "version": "1.0.0", "license": "AGPL-3.0-or-later", "dependencies": { - "archiver": "^7.0.1", "certifi": "14.5.15", "chalk": "5.4.1", "https-proxy-agent": "7.0.6", @@ -4031,7 +3142,6 @@ "safe-chain": "bin/safe-chain.js" }, "devDependencies": { - "@types/archiver": "^7.0.0", "@types/ini": "^4.1.1", "@types/make-fetch-happen": "^10.0.4", "@types/node": "^18.19.130", diff --git a/packages/safe-chain/package.json b/packages/safe-chain/package.json index d4f3501..3d527cb 100644 --- a/packages/safe-chain/package.json +++ b/packages/safe-chain/package.json @@ -38,7 +38,6 @@ "license": "AGPL-3.0-or-later", "description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), and [pip](https://pip.pypa.io/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, bunx, uv, or pip/pip3 from downloading or running the malware.", "dependencies": { - "archiver": "^7.0.1", "certifi": "14.5.15", "chalk": "5.4.1", "https-proxy-agent": "7.0.6", @@ -49,7 +48,6 @@ "semver": "7.7.2" }, "devDependencies": { - "@types/archiver": "^7.0.0", "@types/ini": "^4.1.1", "@types/make-fetch-happen": "^10.0.4", "@types/node": "^18.19.130", diff --git a/packages/safe-chain/src/ultimate/ultimateTroubleshooting.js b/packages/safe-chain/src/ultimate/ultimateTroubleshooting.js deleted file mode 100644 index 114bd5e..0000000 --- a/packages/safe-chain/src/ultimate/ultimateTroubleshooting.js +++ /dev/null @@ -1,111 +0,0 @@ -import { platform } from 'os'; -import { ui } from "../environment/userInteraction.js"; -import { readFileSync, existsSync } from "node:fs"; -import {randomUUID} from "node:crypto"; -import {createWriteStream} from "fs"; -import archiver from 'archiver'; -import path from "node:path"; - -export async function printUltimateLogs() { - const { proxyLogPath, ultimateLogPath, proxyErrLogPath, ultimateErrLogPath } = getPathsPerPlatform(); - - await printLogs( - "SafeChain Proxy", - proxyLogPath, - proxyErrLogPath - ); - - await printLogs( - "SafeChain Ultimate", - ultimateLogPath, - ultimateErrLogPath - ); -} - -export async function troubleshootingExport() { - const { logDir } = getPathsPerPlatform(); - return new Promise((resolve, reject) => { - if (!existsSync(logDir)) { - ui.writeError(`Log directory not found: ${logDir}`); - reject(new Error(`Log directory not found: ${logDir}`)); - return; - } - - const date = new Date().toISOString().split('T')[0]; - const uuid = randomUUID(); - const zipFileName = `safechain-ultimate-${date}-${uuid}.zip`; - const output = createWriteStream(zipFileName); - const archive = archiver('zip', { zlib: { level: 9 } }); - - output.on('close', () => { - ui.writeInformation(`Logs collected and zipped as: ${path.resolve(zipFileName)}`); - resolve(zipFileName); - }); - - archive.on('error', (/** @type {Error} */ err) => { - ui.writeError(`Failed to zip logs: ${err.message}`); - reject(err); - }); - - archive.pipe(output); - archive.directory(logDir, false); - archive.finalize(); - }); -} - - -function getPathsPerPlatform() { - const os = platform(); - if (os === 'win32') { - const logDir = `C:\\ProgramData\\AikidoSecurity\\SafeChainUltimate\\logs`; - return { - logDir, - proxyLogPath: `${logDir}\\SafeChainProxy.log`, - ultimateLogPath: `${logDir}\\SafeChainUltimate.log`, - proxyErrLogPath: `${logDir}\\SafeChainProxy.err`, - ultimateErrLogPath: `${logDir}\\SafeChainUltimate.err`, - }; - } else if (os === 'darwin') { - const logDir = `/Library/Logs/AikidoSecurity/SafeChainUltimate`; - return { - logDir, - proxyLogPath: `${logDir}/safechain-proxy.log`, - ultimateLogPath: `${logDir}/safechain-ultimate.log`, - proxyErrLogPath: `${logDir}/safechain-proxy.error.log`, - ultimateErrLogPath: `${logDir}/safechain-ultimate.error.log`, - }; - } else { - throw new Error('Unsupported platform for log printing.'); - } -} - -/** - * @param {string} appName - * @param {string} logPath - * @param {string} errLogPath - */ -async function printLogs(appName, logPath, errLogPath) { - ui.writeInformation(`=== ${appName} Logs ===`); - try { - if (existsSync(logPath)) { - const logs = readFileSync(logPath, "utf-8"); - ui.writeInformation(logs); - } else { - ui.writeWarning(`${appName} log file not found: ${logPath}`); - } - } catch (error) { - ui.writeError(`Failed to read ${appName} logs: ${error}`); - } - - ui.writeInformation(`=== ${appName} Error Logs ===`); - try { - if (existsSync(errLogPath)) { - const errLogs = readFileSync(errLogPath, "utf-8"); - ui.writeInformation(errLogs); - } else { - ui.writeInformation(`No error log file found for ${appName}.`); - } - } catch (error) { - ui.writeError(`Failed to read ${appName} error logs: ${error}`); - } -} From 6db9f346e3a6a302616523f6d1a316a817d8f877 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 7 Apr 2026 17:20:56 +0200 Subject: [PATCH 253/360] Undo accidental rename --- package-lock.json => npm-shrinkwrap.json | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename package-lock.json => npm-shrinkwrap.json (100%) diff --git a/package-lock.json b/npm-shrinkwrap.json similarity index 100% rename from package-lock.json rename to npm-shrinkwrap.json From f1307c6d82f393239a351d6de28d4a92cb8db7e2 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 8 Apr 2026 13:16:14 +0200 Subject: [PATCH 254/360] Fix release pipeline for immutable builds again --- .github/workflows/build-and-release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 82cae34..7cd2a91 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -101,6 +101,7 @@ jobs: publish-npm: name: Publish to npm + if: github.event_name == 'release' runs-on: ubuntu-latest steps: From b116bc7016b393c674a6117829ecff02e9579757 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 8 Apr 2026 14:09:26 +0200 Subject: [PATCH 255/360] Add doc about release process --- docs/Release.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 docs/Release.md diff --git a/docs/Release.md b/docs/Release.md new file mode 100644 index 0000000..ed116d2 --- /dev/null +++ b/docs/Release.md @@ -0,0 +1,25 @@ +# Release Guide + +## Steps + +### 1. Create and push a version tag + +```bash +git tag 1.0.0 +git push origin 1.0.0 +``` + +This triggers the build pipeline, which compiles binaries for all platforms and creates a draft GitHub release. + +### 2. Wait for artifacts to build + +Monitor the [Actions tab](https://github.com/AikidoSec/safe-chain/actions) until the `Create Release` workflow completes. + +### 3. Publish the GitHub release + +1. Go to the [Releases page](https://github.com/AikidoSec/safe-chain/releases) +2. Open the draft release created for your tag +3. Add release notes +4. Click **Publish release** + +Publishing the release automatically triggers an npm publish. Pre-release versions (e.g. `1.0.0-beta`) are published to npm under a tag matching the pre-release identifier (e.g. `beta`). Stable versions are published to the `latest` tag. From a6960d81e30a86c8d38907760e2c2ea1f3216d84 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 9 Apr 2026 13:11:29 +0200 Subject: [PATCH 256/360] Update Aikido Endpoint version to 1.2.13 --- 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 4208e06..d3d5dd4 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.12/EndpointProtection.pkg" -DOWNLOAD_SHA256="26492f3cbb1094532dc298199842eb97d60cc670552c9c256314960b298ee784" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.13/EndpointProtection.pkg" +DOWNLOAD_SHA256="ab68536dad46625aff19897e0191f3b84c8facf36e07852854bb868e46bfe28a" 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 511bdbe..cfbbc76 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.12/EndpointProtection.msi" -$DownloadSha256 = "06308fc06f95f4b2ad9e48bfd978eb8d02c2928f2ee3c8bba2c81ef2fde21e4f" +$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.13/EndpointProtection.msi" +$DownloadSha256 = "9005700b23c8214816642eea741a584c694d19c0eeb26deebf560092f4e5d568" # Ensure TLS 1.2 is enabled for downloads [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 From a0fb8d6b3d88f6a467e1a566e5802fa671c9e9b8 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 08:57:08 -0700 Subject: [PATCH 257/360] Add env var support for home dir --- .../src/config/environmentVariables.js | 11 ++++ .../src/config/environmentVariables.spec.js | 30 +++++++++ .../src/shell-integration/helpers.js | 21 +++++- .../src/shell-integration/helpers.spec.js | 66 ++++++++++++++++++- 4 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 packages/safe-chain/src/config/environmentVariables.spec.js diff --git a/packages/safe-chain/src/config/environmentVariables.js b/packages/safe-chain/src/config/environmentVariables.js index 932eff7..b76a413 100644 --- a/packages/safe-chain/src/config/environmentVariables.js +++ b/packages/safe-chain/src/config/environmentVariables.js @@ -55,3 +55,14 @@ export function getMinimumPackageAgeExclusions() { export function getMalwareListBaseUrl() { return process.env.SAFE_CHAIN_MALWARE_LIST_BASE_URL; } + +/** + * Gets the safe-chain base directory from environment variable. + * When set, all safe-chain data (bin, shims, scripts) will be placed under this directory + * instead of the default ~/.safe-chain, enabling system-wide installations. + * Example: "/usr/local/.safe-chain" + * @returns {string | undefined} + */ +export function getSafeChainDir() { + return process.env.SAFE_CHAIN_DIR; +} diff --git a/packages/safe-chain/src/config/environmentVariables.spec.js b/packages/safe-chain/src/config/environmentVariables.spec.js new file mode 100644 index 0000000..2cbdd0f --- /dev/null +++ b/packages/safe-chain/src/config/environmentVariables.spec.js @@ -0,0 +1,30 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert"; + +const { getSafeChainDir } = await import("./environmentVariables.js"); + +describe("getSafeChainDir", () => { + let original; + + beforeEach(() => { + original = process.env.SAFE_CHAIN_DIR; + }); + + afterEach(() => { + if (original !== undefined) { + process.env.SAFE_CHAIN_DIR = original; + } else { + delete process.env.SAFE_CHAIN_DIR; + } + }); + + it("returns undefined when SAFE_CHAIN_DIR is not set", () => { + delete process.env.SAFE_CHAIN_DIR; + assert.strictEqual(getSafeChainDir(), undefined); + }); + + it("returns the value of SAFE_CHAIN_DIR when set", () => { + process.env.SAFE_CHAIN_DIR = "/usr/local/.safe-chain"; + assert.strictEqual(getSafeChainDir(), "/usr/local/.safe-chain"); + }); +}); diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index 18ba52e..7ccfd99 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -3,6 +3,7 @@ import * as os from "os"; import fs from "fs"; import path from "path"; import { ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; +import { getSafeChainDir } from "../config/environmentVariables.js"; import { safeSpawn } from "../utils/safeSpawn.js"; import { ui } from "../environment/userInteraction.js"; @@ -121,18 +122,34 @@ export function getPackageManagerList() { return `${tools.join(", ")}, and ${lastTool} commands`; } +/** + * Returns the safe-chain base directory. + * Uses SAFE_CHAIN_DIR environment variable when set, otherwise defaults to ~/.safe-chain. + * @returns {string} + */ +export function getSafeChainBaseDir() { + return getSafeChainDir() ?? path.join(os.homedir(), ".safe-chain"); +} + +/** + * @returns {string} + */ +export function getBinDir() { + return path.join(getSafeChainBaseDir(), "bin"); +} + /** * @returns {string} */ export function getShimsDir() { - return path.join(os.homedir(), ".safe-chain", "shims"); + return path.join(getSafeChainBaseDir(), "shims"); } /** * @returns {string} */ export function getScriptsDir() { - return path.join(os.homedir(), ".safe-chain", "scripts"); + return path.join(getSafeChainBaseDir(), "scripts"); } /** diff --git a/packages/safe-chain/src/shell-integration/helpers.spec.js b/packages/safe-chain/src/shell-integration/helpers.spec.js index 4f18c36..8fd172b 100644 --- a/packages/safe-chain/src/shell-integration/helpers.spec.js +++ b/packages/safe-chain/src/shell-integration/helpers.spec.js @@ -1,6 +1,6 @@ import { describe, it, beforeEach, afterEach, mock } from "node:test"; import assert from "node:assert"; -import { tmpdir } from "node:os"; +import { tmpdir, homedir } from "node:os"; import fs from "node:fs"; import path from "path"; @@ -15,6 +15,7 @@ describe("removeLinesMatchingPatternTests", () => { mock.module("node:os", { namedExports: { EOL: "\r\n", // Simulate Windows line endings + homedir, tmpdir: tmpdir, platform: () => "linux", }, @@ -182,3 +183,66 @@ describe("removeLinesMatchingPatternTests", () => { assert.strictEqual(resultLines.length, 5, "Should have exactly 5 lines"); }); }); + +describe("getSafeChainBaseDir / getBinDir / getShimsDir / getScriptsDir", () => { + const customDir = "/usr/local/.safe-chain"; + + let originalSafeChainDir; + + beforeEach(() => { + originalSafeChainDir = process.env.SAFE_CHAIN_DIR; + delete process.env.SAFE_CHAIN_DIR; + }); + + afterEach(() => { + if (originalSafeChainDir !== undefined) { + process.env.SAFE_CHAIN_DIR = originalSafeChainDir; + } else { + delete process.env.SAFE_CHAIN_DIR; + } + }); + + it("defaults base dir to ~/.safe-chain when SAFE_CHAIN_DIR is not set", async () => { + const { getSafeChainBaseDir } = await import("./helpers.js"); + assert.strictEqual(getSafeChainBaseDir(), path.join(homedir(), ".safe-chain")); + }); + + it("uses SAFE_CHAIN_DIR as base dir when set", async () => { + process.env.SAFE_CHAIN_DIR = customDir; + const { getSafeChainBaseDir } = await import("./helpers.js"); + assert.strictEqual(getSafeChainBaseDir(), customDir); + }); + + it("getBinDir returns ~/.safe-chain/bin by default", async () => { + const { getBinDir } = await import("./helpers.js"); + assert.strictEqual(getBinDir(), path.join(homedir(), ".safe-chain", "bin")); + }); + + it("getBinDir returns custom dir + /bin when SAFE_CHAIN_DIR is set", async () => { + process.env.SAFE_CHAIN_DIR = customDir; + const { getBinDir } = await import("./helpers.js"); + assert.strictEqual(getBinDir(), `${customDir}/bin`); + }); + + it("getShimsDir returns ~/.safe-chain/shims by default", async () => { + const { getShimsDir } = await import("./helpers.js"); + assert.strictEqual(getShimsDir(), path.join(homedir(), ".safe-chain", "shims")); + }); + + it("getShimsDir returns custom dir + /shims when SAFE_CHAIN_DIR is set", async () => { + process.env.SAFE_CHAIN_DIR = customDir; + const { getShimsDir } = await import("./helpers.js"); + assert.strictEqual(getShimsDir(), `${customDir}/shims`); + }); + + it("getScriptsDir returns ~/.safe-chain/scripts by default", async () => { + const { getScriptsDir } = await import("./helpers.js"); + assert.strictEqual(getScriptsDir(), path.join(homedir(), ".safe-chain", "scripts")); + }); + + it("getScriptsDir returns custom dir + /scripts when SAFE_CHAIN_DIR is set", async () => { + process.env.SAFE_CHAIN_DIR = customDir; + const { getScriptsDir } = await import("./helpers.js"); + assert.strictEqual(getScriptsDir(), `${customDir}/scripts`); + }); +}); From 422963b38a3f279ac8282430830973c677f650ec Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 09:05:29 -0700 Subject: [PATCH 258/360] Do not hardcode path in setup-ci --- packages/safe-chain/src/shell-integration/setup-ci.js | 4 ++-- packages/safe-chain/src/shell-integration/setup-ci.spec.js | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/setup-ci.js b/packages/safe-chain/src/shell-integration/setup-ci.js index 762bd9b..1986bba 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.js @@ -1,6 +1,6 @@ import chalk from "chalk"; import { ui } from "../environment/userInteraction.js"; -import { getPackageManagerList, knownAikidoTools, getShimsDir } from "./helpers.js"; +import { getPackageManagerList, knownAikidoTools, getShimsDir, getBinDir } from "./helpers.js"; import fs from "fs"; import os from "os"; import path from "path"; @@ -31,7 +31,7 @@ export async function setupCi() { ui.emptyLine(); const shimsDir = getShimsDir(); - const binDir = path.join(os.homedir(), ".safe-chain", "bin"); + const binDir = getBinDir(); // Create the shims directory if it doesn't exist if (!fs.existsSync(shimsDir)) { fs.mkdirSync(shimsDir, { recursive: true }); diff --git a/packages/safe-chain/src/shell-integration/setup-ci.spec.js b/packages/safe-chain/src/shell-integration/setup-ci.spec.js index b437157..c0a5ca1 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.spec.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.spec.js @@ -51,6 +51,7 @@ describe("Setup CI shell integration", () => { ], getPackageManagerList: () => "npm, yarn", getShimsDir: () => mockShimsDir, + getBinDir: () => path.join(mockHomeDir, ".safe-chain", "bin"), }, }); From 1635bee387f72bfb126ae8a0f5854ae4b65b7d8e Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 10:18:49 -0700 Subject: [PATCH 259/360] Add support for setup-ci with custom install dir --- .../templates/unix-wrapper.template.sh | 10 +- .../startup-scripts/init-fish.fish | 3 +- .../startup-scripts/init-posix.sh | 2 +- .../startup-scripts/init-pwsh.ps1 | 3 +- test/e2e/DockerTestContainer.js | 8 +- test/e2e/safe-chain-dir.e2e.spec.js | 115 ++++++++++++++++++ 6 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 test/e2e/safe-chain-dir.e2e.spec.js diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh index d6c9efd..94ed364 100644 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh @@ -4,13 +4,21 @@ # Function to remove shim from PATH (POSIX-compliant) remove_shim_from_path() { - echo "$PATH" | sed "s|$HOME/.safe-chain/shims:||g" + _safe_chain_shims="${SAFE_CHAIN_DIR:-$HOME/.safe-chain}/shims" + echo "$PATH" | sed "s|${_safe_chain_shims}:||g" } if command -v safe-chain >/dev/null 2>&1; then # Remove shim directory from PATH when calling {{AIKIDO_COMMAND}} to prevent infinite loops PATH=$(remove_shim_from_path) exec safe-chain {{PACKAGE_MANAGER}} "$@" else + # safe-chain is not reachable — warn the user so they know protection is inactive + if [ -n "$SAFE_CHAIN_DIR" ]; then + printf "\033[43;30mWarning:\033[0m safe-chain is not accessible. Check that '%s/bin' is readable and executable by the current user.\n" "$SAFE_CHAIN_DIR" >&2 + else + printf "\033[43;30mWarning:\033[0m safe-chain is not available to protect you from installing malware. {{PACKAGE_MANAGER}} will run without it.\n" >&2 + fi + # Dynamically find original {{PACKAGE_MANAGER}} (excluding this shim directory) original_cmd=$(PATH=$(remove_shim_from_path) command -v {{PACKAGE_MANAGER}}) if [ -n "$original_cmd" ]; then diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish index 13463f6..a705634 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish @@ -1,4 +1,5 @@ -set -gx PATH $PATH $HOME/.safe-chain/bin +set -l safe_chain_base (if set -q SAFE_CHAIN_DIR; echo $SAFE_CHAIN_DIR; else; echo $HOME/.safe-chain; end) +set -gx PATH $PATH $safe_chain_base/bin function npx wrapSafeChainCommand "npx" $argv diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh index ebaaf3c..b567902 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh @@ -1,4 +1,4 @@ -export PATH="$PATH:$HOME/.safe-chain/bin" +export PATH="$PATH:${SAFE_CHAIN_DIR:-$HOME/.safe-chain}/bin" function npx() { wrapSafeChainCommand "npx" "$@" diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 index f82d0fc..bcdd1c6 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 @@ -2,7 +2,8 @@ # $IsWindows is only available in PowerShell Core 6.0+. If it doesn't exist, assume Windows PowerShell $isWindowsPlatform = if (Test-Path variable:IsWindows) { $IsWindows } else { $true } $pathSeparator = if ($isWindowsPlatform) { ';' } else { ':' } -$safeChainBin = Join-Path (Join-Path $HOME '.safe-chain') 'bin' +$safeChainBase = if ($env:SAFE_CHAIN_DIR) { $env:SAFE_CHAIN_DIR } else { Join-Path $HOME '.safe-chain' } +$safeChainBin = Join-Path $safeChainBase 'bin' $env:PATH = "$env:PATH$pathSeparator$safeChainBin" function npx { diff --git a/test/e2e/DockerTestContainer.js b/test/e2e/DockerTestContainer.js index 95a467c..cd48c4e 100644 --- a/test/e2e/DockerTestContainer.js +++ b/test/e2e/DockerTestContainer.js @@ -84,10 +84,14 @@ export class DockerTestContainer { } } - async openShell(shell) { + async openShell(shell, { user } = {}) { + const execArgs = user + ? ["exec", "-it", "-u", user, this.containerName, shell] + : ["exec", "-it", this.containerName, shell]; + let ptyProcess = pty.spawn( "docker", - ["exec", "-it", this.containerName, shell], + execArgs, { name: "xterm-color", cols: 80, diff --git a/test/e2e/safe-chain-dir.e2e.spec.js b/test/e2e/safe-chain-dir.e2e.spec.js new file mode 100644 index 0000000..e28bd72 --- /dev/null +++ b/test/e2e/safe-chain-dir.e2e.spec.js @@ -0,0 +1,115 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +const CUSTOM_DIR = "/usr/local/.safe-chain"; + +describe("E2E: SAFE_CHAIN_DIR support", () => { + let container; + + before(async () => { + DockerTestContainer.buildImage(); + }); + + beforeEach(async () => { + container = new DockerTestContainer(); + await container.start(); + }); + + afterEach(async () => { + if (container) { + await container.stop(); + container = null; + } + }); + + it("setup-ci installs shims in the custom directory when SAFE_CHAIN_DIR is set", async () => { + const shell = await container.openShell("bash"); + await shell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await shell.runCommand("safe-chain setup-ci"); + + // Shims should be in the custom dir + const customShimResult = await shell.runCommand( + `test -f ${CUSTOM_DIR}/shims/npm && echo "EXISTS"` + ); + assert.ok( + customShimResult.output.includes("EXISTS"), + `Expected npm shim at ${CUSTOM_DIR}/shims/npm. Output:\n${customShimResult.output}` + ); + + // Default location should NOT have been created + const defaultShimResult = await shell.runCommand( + `test -d $HOME/.safe-chain/shims && echo "EXISTS" || echo "ABSENT"` + ); + assert.ok( + defaultShimResult.output.includes("ABSENT"), + `Expected default shims dir to be absent. Output:\n${defaultShimResult.output}` + ); + }); + + it("setup-ci writes the custom directory path to GITHUB_PATH when SAFE_CHAIN_DIR is set", async () => { + const shell = await container.openShell("bash"); + await shell.runCommand("export GITHUB_PATH=/tmp/github_path"); + await shell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await shell.runCommand("safe-chain setup-ci"); + + const result = await shell.runCommand("cat /tmp/github_path"); + assert.ok( + result.output.includes(`${CUSTOM_DIR}/shims`), + `Expected GITHUB_PATH to contain custom shims dir. Output:\n${result.output}` + ); + assert.ok( + result.output.includes(`${CUSTOM_DIR}/bin`), + `Expected GITHUB_PATH to contain custom bin dir. Output:\n${result.output}` + ); + }); + + it("safe-chain protects a non-root user when installed to a shared dir with SAFE_CHAIN_DIR", async () => { + // Step 1: create a non-root user inside the container + container.dockerExec("useradd -m safeuser"); + + // Step 2: as root, run setup-ci with the shared SAFE_CHAIN_DIR + const rootShell = await container.openShell("bash"); + await rootShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await rootShell.runCommand("safe-chain setup-ci"); + + // Step 3: simulate what install-safe-chain.sh does — place the safe-chain binary + // in SAFE_CHAIN_DIR/bin. In Docker tests safe-chain is installed via npm/Volta, + // so we symlink it there. + container.dockerExec(`mkdir -p ${CUSTOM_DIR}/bin`); + container.dockerExec( + `ln -sf \\$(which safe-chain) ${CUSTOM_DIR}/bin/safe-chain` + ); + + // Step 4: make npm accessible to all users (in real Dockerfiles npm is installed + // before the user switch; here Volta manages it for root, so we symlink it). + container.dockerExec("ln -sf \\$(which npm) /usr/local/bin/npm"); + + // Step 5: make the shared safe-chain dir readable + executable by all users + container.dockerExec(`chmod -R a+rx ${CUSTOM_DIR}`); + + // Step 6: Volta installs under /root/.volta which is only accessible to root by + // default. /root/ itself is mode 700, so safeuser can't traverse into it even + // if .volta/ is world-readable. Fix both levels. Safe in a throw-away container. + container.dockerExec("chmod a+x /root && chmod -R a+rX /root/.volta"); + + // Step 7: as the non-root user, set SAFE_CHAIN_DIR and PATH, then run npm. + // SAFE_CHAIN_DIR must be set so the shim knows which dir to strip from PATH + // when invoking the real npm (prevents infinite loop). + const userShell = await container.openShell("bash", { user: "safeuser" }); + await userShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + // Reuse root's Volta dir so safeuser doesn't trigger a slow first-run setup + await userShell.runCommand("export VOLTA_HOME=/root/.volta"); + await userShell.runCommand( + `export PATH="${CUSTOM_DIR}/shims:${CUSTOM_DIR}/bin:$PATH"` + ); + const result = await userShell.runCommand( + "npm i axios@1.13.0 --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("Safe-chain: Scanned"), + `Expected safe-chain to protect non-root user. Output:\n${result.output}` + ); + }); +}); From 24af6f21eb4d05b84331dcb75d88d9c4a0db732c Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 12:09:40 -0700 Subject: [PATCH 260/360] Add regular setup support --- .../supported-shells/bash.js | 8 +-- .../supported-shells/bash.spec.js | 13 ++--- .../supported-shells/fish.js | 8 +-- .../supported-shells/fish.spec.js | 15 +++--- .../supported-shells/powershell.js | 8 +-- .../supported-shells/powershell.spec.js | 13 ++--- .../supported-shells/windowsPowershell.js | 8 +-- .../windowsPowershell.spec.js | 13 ++--- .../shell-integration/supported-shells/zsh.js | 8 +-- .../supported-shells/zsh.spec.js | 17 ++++--- test/e2e/bun.e2e.spec.js | 35 +++++++++++++ test/e2e/npm-ci.e2e.spec.js | 43 ++++++++++++++++ test/e2e/npm.e2e.spec.js | 35 +++++++++++++ test/e2e/pip-ci.e2e.spec.js | 40 +++++++++++++++ test/e2e/pip.e2e.spec.js | 36 +++++++++++++ test/e2e/pipx.e2e.spec.js | 35 +++++++++++++ test/e2e/pnpm-ci.e2e.spec.js | 41 +++++++++++++++ test/e2e/pnpm.e2e.spec.js | 35 +++++++++++++ test/e2e/poetry.e2e.spec.js | 42 +++++++++++++++ test/e2e/safe-chain-dir.e2e.spec.js | 51 +++++++++++++++++++ test/e2e/uv.e2e.spec.js | 39 ++++++++++++++ test/e2e/yarn-ci.e2e.spec.js | 41 +++++++++++++++ test/e2e/yarn.e2e.spec.js | 39 ++++++++++++++ 23 files changed, 575 insertions(+), 48 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.js index cc50223..364323e 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.js @@ -2,9 +2,11 @@ import { addLineToFile, doesExecutableExistOnSystem, removeLinesMatchingPattern, + getScriptsDir, } from "../helpers.js"; import { execSync, spawnSync } from "child_process"; import * as os from "os"; +import path from "path"; const shellName = "Bash"; const executableName = "bash"; @@ -32,10 +34,10 @@ function teardown(tools) { ); } - // Removes the line that sources the safe-chain bash initialization script (~/.safe-chain/scripts/init-posix.sh) + // Removes the line that sources the safe-chain bash initialization script (any path, requires safe-chain comment) removeLinesMatchingPattern( startupFile, - /^source\s+~\/\.safe-chain\/scripts\/init-posix\.sh/, + /^source\s+.*init-posix\.sh.*#\s*Safe-chain/, eol ); @@ -47,7 +49,7 @@ function setup() { addLineToFile( startupFile, - `source ~/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script`, + `source ${path.join(getScriptsDir(), "init-posix.sh")} # Safe-chain bash initialization script`, eol ); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js index aa7159f..f0a56d2 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js @@ -19,6 +19,7 @@ describe("Bash shell integration", () => { mock.module("../helpers.js", { namedExports: { doesExecutableExistOnSystem: () => true, + getScriptsDir: () => "/test-home/.safe-chain/scripts", addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -109,7 +110,7 @@ describe("Bash shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( content.includes( - "source ~/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script" + "source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script" ) ); }); @@ -129,7 +130,7 @@ describe("Bash shell integration", () => { const content = fs.readFileSync(windowsCygwinPath, "utf-8"); assert.ok( content.includes( - "source ~/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script" + "source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script" ) ); }); @@ -209,13 +210,13 @@ describe("Bash shell integration", () => { // Setup bash.setup(tools); let content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(content.includes("source ~/.safe-chain/scripts/init-posix.sh")); + assert.ok(content.includes("source /test-home/.safe-chain/scripts/init-posix.sh")); // Teardown bash.teardown(tools); content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - !content.includes("source ~/.safe-chain/scripts/init-posix.sh") + !content.includes("source /test-home/.safe-chain/scripts/init-posix.sh") ); }); @@ -236,7 +237,7 @@ describe("Bash shell integration", () => { const initialContent = [ "#!/bin/bash", "alias npm='old-npm'", - "source ~/.safe-chain/scripts/init-posix.sh", + "source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script", "alias ls='ls --color=auto'", ].join("\n"); @@ -247,7 +248,7 @@ describe("Bash shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("alias npm=")); assert.ok( - !content.includes("source ~/.safe-chain/scripts/init-posix.sh") + !content.includes("source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script") ); assert.ok(content.includes("alias ls=")); }); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.js index a623d0b..5f59826 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.js @@ -2,8 +2,10 @@ import { addLineToFile, doesExecutableExistOnSystem, removeLinesMatchingPattern, + getScriptsDir, } from "../helpers.js"; import { execSync } from "child_process"; +import path from "path"; const shellName = "Fish"; const executableName = "fish"; @@ -31,10 +33,10 @@ function teardown(tools) { ); } - // Removes the line that sources the safe-chain fish initialization script (~/.safe-chain/scripts/init-fish.fish) + // Removes the line that sources the safe-chain fish initialization script (any path, requires safe-chain comment) removeLinesMatchingPattern( startupFile, - /^source\s+~\/\.safe-chain\/scripts\/init-fish\.fish/, + /^source\s+.*init-fish\.fish.*#\s*Safe-chain/, eol ); @@ -46,7 +48,7 @@ function setup() { addLineToFile( startupFile, - `source ~/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script`, + `source ${path.join(getScriptsDir(), "init-fish.fish")} # Safe-chain Fish initialization script`, eol ); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js index e138957..0933b6e 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js @@ -17,6 +17,7 @@ describe("Fish shell integration", () => { mock.module("../helpers.js", { namedExports: { doesExecutableExistOnSystem: () => true, + getScriptsDir: () => "/test-home/.safe-chain/scripts", addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -72,7 +73,7 @@ describe("Fish shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - content.includes('source ~/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script') + content.includes('source /test-home/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script') ); }); @@ -81,7 +82,7 @@ describe("Fish shell integration", () => { fish.setup(); const content = fs.readFileSync(mockStartupFile, "utf-8"); - const sourceMatches = (content.match(/source ~\/\.safe-chain\/scripts\/init-fish\.fish/g) || []).length; + const sourceMatches = (content.match(/source \/test-home\/\.safe-chain\/scripts\/init-fish\.fish/g) || []).length; assert.strictEqual(sourceMatches, 2, "Should allow multiple source lines (helper doesn't dedupe)"); }); }); @@ -93,7 +94,7 @@ describe("Fish shell integration", () => { "alias npm 'aikido-npm'", "alias npx 'aikido-npx'", "alias yarn 'aikido-yarn'", - "source ~/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script", + "source /test-home/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script", "alias ls 'ls --color=auto'", "alias grep 'grep --color=auto'", ].join("\n"); @@ -107,7 +108,7 @@ describe("Fish shell integration", () => { assert.ok(!content.includes("alias npm ")); assert.ok(!content.includes("alias npx ")); assert.ok(!content.includes("alias yarn ")); - assert.ok(!content.includes("source ~/.safe-chain/scripts/init-fish.fish")); + assert.ok(!content.includes("source /test-home/.safe-chain/scripts/init-fish.fish")); assert.ok(content.includes("alias ls ")); assert.ok(content.includes("alias grep ")); }); @@ -162,12 +163,12 @@ describe("Fish shell integration", () => { // Setup fish.setup(); let content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(content.includes('source ~/.safe-chain/scripts/init-fish.fish')); + assert.ok(content.includes('source /test-home/.safe-chain/scripts/init-fish.fish')); // Teardown fish.teardown(tools); content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(!content.includes("source ~/.safe-chain/scripts/init-fish.fish")); + assert.ok(!content.includes("source /test-home/.safe-chain/scripts/init-fish.fish")); }); it("should handle multiple setup calls", () => { @@ -176,7 +177,7 @@ describe("Fish shell integration", () => { fish.setup(); const content = fs.readFileSync(mockStartupFile, "utf-8"); - const sourceMatches = (content.match(/source ~\/\.safe-chain\/scripts\/init-fish\.fish/g) || []).length; + const sourceMatches = (content.match(/source \/test-home\/\.safe-chain\/scripts\/init-fish\.fish/g) || []).length; assert.strictEqual(sourceMatches, 1, "Should have exactly one source line after setup-teardown-setup cycle"); }); }); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js index 4bbc332..59aee41 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js @@ -3,8 +3,10 @@ import { doesExecutableExistOnSystem, removeLinesMatchingPattern, validatePowerShellExecutionPolicy, + getScriptsDir, } from "../helpers.js"; import { execSync } from "child_process"; +import path from "path"; const shellName = "PowerShell Core"; const executableName = "pwsh"; @@ -30,10 +32,10 @@ function teardown(tools) { ); } - // Remove the line that sources the safe-chain PowerShell initialization script + // Remove the line that sources the safe-chain PowerShell initialization script (any path, requires safe-chain comment) removeLinesMatchingPattern( startupFile, - /^\.\s+["']?\$HOME[/\\].safe-chain[/\\]scripts[/\\]init-pwsh\.ps1["']?/, + /^\.\s+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/, ); return true; @@ -52,7 +54,7 @@ async function setup() { addLineToFile( startupFile, - `. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script`, + `. "${path.join(getScriptsDir(), "init-pwsh.ps1")}" # Safe-chain PowerShell initialization script`, ); return true; diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js index de2c14b..1d9f65c 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js @@ -40,6 +40,7 @@ describe("PowerShell Core shell integration", () => { fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8"); }, validatePowerShellExecutionPolicy: () => executionPolicyResult, + getScriptsDir: () => "/test-home/.safe-chain/scripts", }, }); @@ -83,7 +84,7 @@ describe("PowerShell Core shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( content.includes( - '. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script', + '. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script', ), ); }); @@ -93,7 +94,7 @@ describe("PowerShell Core shell integration", () => { it("should remove init-pwsh.ps1 source line", () => { const initialContent = [ "# PowerShell profile", - '. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script', + '. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script', "Set-Alias ls Get-ChildItem", "Set-Alias grep Select-String", ].join("\n"); @@ -105,7 +106,7 @@ describe("PowerShell Core shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - !content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'), + !content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'), ); assert.ok(content.includes("Set-Alias ls ")); assert.ok(content.includes("Set-Alias grep ")); @@ -180,14 +181,14 @@ describe("PowerShell Core shell integration", () => { await powershell.setup(); let content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'), + content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'), ); // Teardown powershell.teardown(knownAikidoTools); content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - !content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'), + !content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'), ); }); @@ -198,7 +199,7 @@ describe("PowerShell Core shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); const sourceMatches = ( - content.match(/\. "\$HOME\\.safe-chain\\scripts\\init-pwsh\.ps1"/g) || + content.match(/\. "\/test-home\/\.safe-chain\/scripts\/init-pwsh\.ps1"/g) || [] ).length; assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines"); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js index 3e81da7..36ab114 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -3,8 +3,10 @@ import { doesExecutableExistOnSystem, removeLinesMatchingPattern, validatePowerShellExecutionPolicy, + getScriptsDir, } from "../helpers.js"; import { execSync } from "child_process"; +import path from "path"; const shellName = "Windows PowerShell"; const executableName = "powershell"; @@ -30,10 +32,10 @@ function teardown(tools) { ); } - // Remove the line that sources the safe-chain PowerShell initialization script + // Remove the line that sources the safe-chain PowerShell initialization script (any path, requires safe-chain comment) removeLinesMatchingPattern( startupFile, - /^\.\s+["']?\$HOME[/\\].safe-chain[/\\]scripts[/\\]init-pwsh\.ps1["']?/, + /^\.\s+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/, ); return true; @@ -52,7 +54,7 @@ async function setup() { addLineToFile( startupFile, - `. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script`, + `. "${path.join(getScriptsDir(), "init-pwsh.ps1")}" # Safe-chain PowerShell initialization script`, ); return true; diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js index 561d0d4..621b380 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js @@ -40,6 +40,7 @@ describe("Windows PowerShell shell integration", () => { fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8"); }, validatePowerShellExecutionPolicy: () => executionPolicyResult, + getScriptsDir: () => "/test-home/.safe-chain/scripts", }, }); @@ -83,7 +84,7 @@ describe("Windows PowerShell shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( content.includes( - '. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script', + '. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script', ), ); }); @@ -93,7 +94,7 @@ describe("Windows PowerShell shell integration", () => { it("should remove init-pwsh.ps1 source line", () => { const initialContent = [ "# Windows PowerShell profile", - '. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script', + '. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script', "Set-Alias ls Get-ChildItem", "Set-Alias grep Select-String", ].join("\n"); @@ -105,7 +106,7 @@ describe("Windows PowerShell shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - !content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'), + !content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'), ); assert.ok(content.includes("Set-Alias ls ")); assert.ok(content.includes("Set-Alias grep ")); @@ -180,14 +181,14 @@ describe("Windows PowerShell shell integration", () => { await windowsPowershell.setup(); let content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'), + content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'), ); // Teardown windowsPowershell.teardown(knownAikidoTools); content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - !content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'), + !content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'), ); }); @@ -198,7 +199,7 @@ describe("Windows PowerShell shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); const sourceMatches = ( - content.match(/\. "\$HOME\\.safe-chain\\scripts\\init-pwsh\.ps1"/g) || + content.match(/\. "\/test-home\/\.safe-chain\/scripts\/init-pwsh\.ps1"/g) || [] ).length; assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines"); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js index f187af3..369b445 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js @@ -2,8 +2,10 @@ import { addLineToFile, doesExecutableExistOnSystem, removeLinesMatchingPattern, + getScriptsDir, } from "../helpers.js"; import { execSync } from "child_process"; +import path from "path"; const shellName = "Zsh"; const executableName = "zsh"; @@ -31,10 +33,10 @@ function teardown(tools) { ); } - // Removes the line that sources the safe-chain zsh initialization script (~/.safe-chain/scripts/init-posix.sh) + // Removes the line that sources the safe-chain zsh initialization script (any path, requires safe-chain comment) removeLinesMatchingPattern( startupFile, - /^source\s+~\/\.safe-chain\/scripts\/init-posix\.sh/, + /^source\s+.*init-posix\.sh.*#\s*Safe-chain/, eol ); @@ -46,7 +48,7 @@ function setup() { addLineToFile( startupFile, - `source ~/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script`, + `source ${path.join(getScriptsDir(), "init-posix.sh")} # Safe-chain Zsh initialization script`, eol ); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js index 99106ec..41e1bd1 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js @@ -17,6 +17,7 @@ describe("Zsh shell integration", () => { mock.module("../helpers.js", { namedExports: { doesExecutableExistOnSystem: () => true, + getScriptsDir: () => "/test-home/.safe-chain/scripts", addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -73,7 +74,7 @@ describe("Zsh shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( content.includes( - "source ~/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script" + "source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script" ) ); }); @@ -83,7 +84,7 @@ describe("Zsh shell integration", () => { assert.strictEqual(result, true); const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(content.includes("source ~/.safe-chain/scripts/init-posix.sh")); + assert.ok(content.includes("source /test-home/.safe-chain/scripts/init-posix.sh")); }); }); @@ -114,7 +115,7 @@ describe("Zsh shell integration", () => { it("should remove zsh initialization script source line", () => { const initialContent = [ "#!/bin/zsh", - "source ~/.safe-chain/scripts/init-posix.sh", + "source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script", "alias ls='ls --color=auto'", ].join("\n"); @@ -125,7 +126,7 @@ describe("Zsh shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - !content.includes("source ~/.safe-chain/scripts/init-posix.sh") + !content.includes("source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script") ); assert.ok(content.includes("alias ls=")); }); @@ -180,13 +181,13 @@ describe("Zsh shell integration", () => { // Setup zsh.setup(); let content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(content.includes("source ~/.safe-chain/scripts/init-posix.sh")); + assert.ok(content.includes("source /test-home/.safe-chain/scripts/init-posix.sh")); // Teardown zsh.teardown(tools); content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - !content.includes("source ~/.safe-chain/scripts/init-posix.sh") + !content.includes("source /test-home/.safe-chain/scripts/init-posix.sh") ); }); @@ -207,7 +208,7 @@ describe("Zsh shell integration", () => { const initialContent = [ "#!/bin/zsh", "alias npm='old-npm'", - "source ~/.safe-chain/scripts/init-posix.sh", + "source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script", "alias ls='ls --color=auto'", ].join("\n"); @@ -218,7 +219,7 @@ describe("Zsh shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("alias npm=")); assert.ok( - !content.includes("source ~/.safe-chain/scripts/init-posix.sh") + !content.includes("source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script") ); assert.ok(content.includes("alias ls=")); }); diff --git a/test/e2e/bun.e2e.spec.js b/test/e2e/bun.e2e.spec.js index fb6e99a..1de6100 100644 --- a/test/e2e/bun.e2e.spec.js +++ b/test/e2e/bun.e2e.spec.js @@ -78,4 +78,39 @@ describe("E2E: bun coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); + + describe("with SAFE_CHAIN_DIR (custom install directory)", () => { + const CUSTOM_DIR = "/usr/local/.safe-chain"; + let customContainer; + + beforeEach(async () => { + customContainer = new DockerTestContainer(); + await customContainer.start(); + + const setupShell = await customContainer.openShell("bash"); + await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await setupShell.runCommand("safe-chain setup"); + }); + + afterEach(async () => { + if (customContainer) { + await customContainer.stop(); + customContainer = null; + } + }); + + it("blocks malicious bun packages when scripts are in a custom directory", async () => { + const shell = await customContainer.openShell("bash"); + const result = await shell.runCommand("bunx safe-chain-test"); + + assert.ok( + result.output.includes("blocked 1 malicious package downloads"), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + }); + }); }); diff --git a/test/e2e/npm-ci.e2e.spec.js b/test/e2e/npm-ci.e2e.spec.js index 1698759..cc3349b 100644 --- a/test/e2e/npm-ci.e2e.spec.js +++ b/test/e2e/npm-ci.e2e.spec.js @@ -102,4 +102,47 @@ describe("E2E: npm coverage using PATH", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); + + describe("with SAFE_CHAIN_DIR (custom install directory)", () => { + const CUSTOM_DIR = "/usr/local/.safe-chain"; + let customContainer; + + beforeEach(async () => { + customContainer = new DockerTestContainer(); + await customContainer.start(); + + const setupShell = await customContainer.openShell("zsh"); + await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await setupShell.runCommand("safe-chain setup-ci"); + // Persist SAFE_CHAIN_DIR and the custom shims dir in .zshrc so new shells + // inherit both (shims need SAFE_CHAIN_DIR to strip themselves from PATH) + await setupShell.runCommand( + `echo 'export SAFE_CHAIN_DIR=${CUSTOM_DIR}' >> ~/.zshrc` + ); + await setupShell.runCommand( + `echo 'export PATH="${CUSTOM_DIR}/shims:$PATH"' >> ~/.zshrc` + ); + }); + + afterEach(async () => { + if (customContainer) { + await customContainer.stop(); + customContainer = null; + } + }); + + it("blocks malicious npm packages when shims are in a custom directory", async () => { + const shell = await customContainer.openShell("zsh"); + const result = await shell.runCommand("npm i safe-chain-test"); + + assert.ok( + result.output.includes("Malicious changes detected:"), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + }); + }); }); diff --git a/test/e2e/npm.e2e.spec.js b/test/e2e/npm.e2e.spec.js index e8ba7c8..d86af3c 100644 --- a/test/e2e/npm.e2e.spec.js +++ b/test/e2e/npm.e2e.spec.js @@ -119,4 +119,39 @@ describe("E2E: npm coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); + + describe("with SAFE_CHAIN_DIR (custom install directory)", () => { + const CUSTOM_DIR = "/usr/local/.safe-chain"; + let customContainer; + + beforeEach(async () => { + customContainer = new DockerTestContainer(); + await customContainer.start(); + + const setupShell = await customContainer.openShell("zsh"); + await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await setupShell.runCommand("safe-chain setup"); + }); + + afterEach(async () => { + if (customContainer) { + await customContainer.stop(); + customContainer = null; + } + }); + + it("blocks malicious npm packages when scripts are in a custom directory", async () => { + const shell = await customContainer.openShell("zsh"); + const result = await shell.runCommand("npm i safe-chain-test"); + + assert.ok( + result.output.includes("Malicious changes detected:"), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + }); + }); }); diff --git a/test/e2e/pip-ci.e2e.spec.js b/test/e2e/pip-ci.e2e.spec.js index 49db6ce..e1a7aed 100644 --- a/test/e2e/pip-ci.e2e.spec.js +++ b/test/e2e/pip-ci.e2e.spec.js @@ -204,4 +204,44 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => { ); }); } + + describe("with SAFE_CHAIN_DIR (custom install directory)", () => { + const CUSTOM_DIR = "/usr/local/.safe-chain"; + let customContainer; + + beforeEach(async () => { + customContainer = new DockerTestContainer(); + await customContainer.start(); + + const setupShell = await customContainer.openShell("zsh"); + await setupShell.runCommand("pip3 cache purge"); + await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await setupShell.runCommand("safe-chain setup-ci"); + await setupShell.runCommand( + `echo 'export SAFE_CHAIN_DIR=${CUSTOM_DIR}' >> ~/.zshrc` + ); + await setupShell.runCommand( + `echo 'export PATH="${CUSTOM_DIR}/shims:$PATH"' >> ~/.zshrc` + ); + }); + + afterEach(async () => { + if (customContainer) { + await customContainer.stop(); + customContainer = null; + } + }); + + it("intercepts pip3 install when shims are in a custom directory", async () => { + const shell = await customContainer.openShell("zsh"); + const result = await shell.runCommand( + "pip3 install --break-system-packages certifi --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Expected pip3 to be protected with SAFE_CHAIN_DIR. Output:\n${result.output}` + ); + }); + }); }); diff --git a/test/e2e/pip.e2e.spec.js b/test/e2e/pip.e2e.spec.js index b06978f..684ee4f 100644 --- a/test/e2e/pip.e2e.spec.js +++ b/test/e2e/pip.e2e.spec.js @@ -844,4 +844,40 @@ describe("E2E: pip coverage", () => { `python -m pip SHOULD go through safe-chain. Output was:\n${result.output}` ); }); + + describe("with SAFE_CHAIN_DIR (custom install directory)", () => { + const CUSTOM_DIR = "/usr/local/.safe-chain"; + let customContainer; + + beforeEach(async () => { + customContainer = new DockerTestContainer(); + await customContainer.start(); + + const setupShell = await customContainer.openShell("zsh"); + await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await setupShell.runCommand("safe-chain setup"); + await setupShell.runCommand("pip3 cache purge"); + }); + + afterEach(async () => { + if (customContainer) { + await customContainer.stop(); + customContainer = null; + } + }); + + it("intercepts pip3 install when scripts are in a custom directory", async () => { + // New shell sources ~/.zshrc → sources init-posix.sh from custom dir + // → defines pip3() shell function that routes through safe-chain + const shell = await customContainer.openShell("zsh"); + const result = await shell.runCommand( + "pip3 install --break-system-packages requests --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found."), + `Expected pip3 to be protected with SAFE_CHAIN_DIR. Output:\n${result.output}` + ); + }); + }); }); diff --git a/test/e2e/pipx.e2e.spec.js b/test/e2e/pipx.e2e.spec.js index a554aa6..489d8c6 100644 --- a/test/e2e/pipx.e2e.spec.js +++ b/test/e2e/pipx.e2e.spec.js @@ -197,4 +197,39 @@ describe("E2E: pipx coverage", () => { `Expected exit message. Output was:\n${result.output}` ); }); + + describe("with SAFE_CHAIN_DIR (custom install directory)", () => { + const CUSTOM_DIR = "/usr/local/.safe-chain"; + let customContainer; + + beforeEach(async () => { + customContainer = new DockerTestContainer(); + await customContainer.start(); + + const setupShell = await customContainer.openShell("zsh"); + await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await setupShell.runCommand("safe-chain setup"); + }); + + afterEach(async () => { + if (customContainer) { + await customContainer.stop(); + customContainer = null; + } + }); + + it("blocks malicious pipx packages when scripts are in a custom directory", async () => { + const shell = await customContainer.openShell("zsh"); + const result = await shell.runCommand("pipx install safe-chain-pi-test"); + + assert.ok( + result.output.includes("blocked by safe-chain"), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + }); + }); }); diff --git a/test/e2e/pnpm-ci.e2e.spec.js b/test/e2e/pnpm-ci.e2e.spec.js index a56bb77..391001e 100644 --- a/test/e2e/pnpm-ci.e2e.spec.js +++ b/test/e2e/pnpm-ci.e2e.spec.js @@ -122,4 +122,45 @@ describe("E2E: pnpm coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); + + describe("with SAFE_CHAIN_DIR (custom install directory)", () => { + const CUSTOM_DIR = "/usr/local/.safe-chain"; + let customContainer; + + beforeEach(async () => { + customContainer = new DockerTestContainer(); + await customContainer.start(); + + const setupShell = await customContainer.openShell("zsh"); + await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await setupShell.runCommand("safe-chain setup-ci"); + await setupShell.runCommand( + `echo 'export SAFE_CHAIN_DIR=${CUSTOM_DIR}' >> ~/.zshrc` + ); + await setupShell.runCommand( + `echo 'export PATH="${CUSTOM_DIR}/shims:$PATH"' >> ~/.zshrc` + ); + }); + + afterEach(async () => { + if (customContainer) { + await customContainer.stop(); + customContainer = null; + } + }); + + it("blocks malicious pnpm packages when shims are in a custom directory", async () => { + const shell = await customContainer.openShell("zsh"); + const result = await shell.runCommand("pnpm add safe-chain-test"); + + assert.ok( + result.output.includes("Malicious changes detected:"), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + }); + }); }); diff --git a/test/e2e/pnpm.e2e.spec.js b/test/e2e/pnpm.e2e.spec.js index a15250a..90ef57c 100644 --- a/test/e2e/pnpm.e2e.spec.js +++ b/test/e2e/pnpm.e2e.spec.js @@ -139,4 +139,39 @@ describe("E2E: pnpm coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); + + describe("with SAFE_CHAIN_DIR (custom install directory)", () => { + const CUSTOM_DIR = "/usr/local/.safe-chain"; + let customContainer; + + beforeEach(async () => { + customContainer = new DockerTestContainer(); + await customContainer.start(); + + const setupShell = await customContainer.openShell("zsh"); + await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await setupShell.runCommand("safe-chain setup"); + }); + + afterEach(async () => { + if (customContainer) { + await customContainer.stop(); + customContainer = null; + } + }); + + it("blocks malicious pnpm packages when scripts are in a custom directory", async () => { + const shell = await customContainer.openShell("zsh"); + const result = await shell.runCommand("pnpm add safe-chain-test"); + + assert.ok( + result.output.includes("Malicious changes detected:"), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + }); + }); }); diff --git a/test/e2e/poetry.e2e.spec.js b/test/e2e/poetry.e2e.spec.js index 58b74fd..072d1b6 100644 --- a/test/e2e/poetry.e2e.spec.js +++ b/test/e2e/poetry.e2e.spec.js @@ -422,4 +422,46 @@ describe("E2E: poetry coverage", () => { `Expected env list output. Output was:\n${envListResult.output}` ); }); + + describe("with SAFE_CHAIN_DIR (custom install directory)", () => { + const CUSTOM_DIR = "/usr/local/.safe-chain"; + let customContainer; + + beforeEach(async () => { + customContainer = new DockerTestContainer(); + await customContainer.start(); + + const setupShell = await customContainer.openShell("zsh"); + await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await setupShell.runCommand("safe-chain setup"); + await setupShell.runCommand("command poetry cache clear pypi --all -n"); + }); + + afterEach(async () => { + if (customContainer) { + await customContainer.stop(); + customContainer = null; + } + }); + + it("blocks malicious poetry packages when scripts are in a custom directory", async () => { + const shell = await customContainer.openShell("zsh"); + await shell.runCommand("mkdir /tmp/test-poetry-custom-dir"); + await shell.runCommand( + "cd /tmp/test-poetry-custom-dir && poetry init --no-interaction" + ); + const result = await shell.runCommand( + "cd /tmp/test-poetry-custom-dir && poetry add safe-chain-pi-test" + ); + + assert.ok( + result.output.includes("blocked by safe-chain"), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + }); + }); }); diff --git a/test/e2e/safe-chain-dir.e2e.spec.js b/test/e2e/safe-chain-dir.e2e.spec.js index e28bd72..e738949 100644 --- a/test/e2e/safe-chain-dir.e2e.spec.js +++ b/test/e2e/safe-chain-dir.e2e.spec.js @@ -64,6 +64,57 @@ describe("E2E: SAFE_CHAIN_DIR support", () => { ); }); + it("setup writes the custom path to ~/.bashrc when SAFE_CHAIN_DIR is set", async () => { + const shell = await container.openShell("bash"); + await shell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await shell.runCommand("safe-chain setup"); + + const result = await shell.runCommand("cat ~/.bashrc"); + + assert.ok( + result.output.includes(`source ${CUSTOM_DIR}/scripts/init-posix.sh`), + `Expected ~/.bashrc to contain custom scripts path. Output:\n${result.output}` + ); + assert.ok( + !result.output.includes("source ~/.safe-chain/scripts/init-posix.sh"), + `Expected ~/.bashrc to NOT contain default path. Output:\n${result.output}` + ); + }); + + it("setup with SAFE_CHAIN_DIR still protects npm in a new shell session", async () => { + // Run setup with the custom dir + const setupShell = await container.openShell("bash"); + await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await setupShell.runCommand("safe-chain setup"); + + // Open a fresh shell — it will source ~/.bashrc which sources init-posix.sh + // from the custom dir, defining the npm wrapper function + const projectShell = await container.openShell("bash"); + await projectShell.runCommand("cd /testapp"); + const result = await projectShell.runCommand( + "npm i axios@1.13.0 --safe-chain-logging=verbose" + ); + + // "Safe-chain: Package" appears before npm downloads — confirms interception happened + assert.ok( + result.output.includes("Safe-chain: Package"), + `Expected npm to be protected after setup with SAFE_CHAIN_DIR. Output:\n${result.output}` + ); + }); + + it("teardown removes the custom SAFE_CHAIN_DIR source line from ~/.bashrc", async () => { + const shell = await container.openShell("bash"); + await shell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await shell.runCommand("safe-chain setup"); + await shell.runCommand("safe-chain teardown"); + + const result = await shell.runCommand("cat ~/.bashrc"); + assert.ok( + !result.output.includes(`source ${CUSTOM_DIR}/scripts/init-posix.sh`), + `Expected custom source line to be removed from ~/.bashrc. Output:\n${result.output}` + ); + }); + it("safe-chain protects a non-root user when installed to a shared dir with SAFE_CHAIN_DIR", async () => { // Step 1: create a non-root user inside the container container.dockerExec("useradd -m safeuser"); diff --git a/test/e2e/uv.e2e.spec.js b/test/e2e/uv.e2e.spec.js index 9d5f3b9..ad24f6e 100644 --- a/test/e2e/uv.e2e.spec.js +++ b/test/e2e/uv.e2e.spec.js @@ -569,4 +569,43 @@ describe("E2E: uv coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); + + describe("with SAFE_CHAIN_DIR (custom install directory)", () => { + const CUSTOM_DIR = "/usr/local/.safe-chain"; + let customContainer; + + beforeEach(async () => { + customContainer = new DockerTestContainer(); + await customContainer.start(); + + const setupShell = await customContainer.openShell("zsh"); + await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await setupShell.runCommand("safe-chain setup"); + await setupShell.runCommand("uv cache clean"); + }); + + afterEach(async () => { + if (customContainer) { + await customContainer.stop(); + customContainer = null; + } + }); + + it("blocks malicious uv packages when scripts are in a custom directory", async () => { + const shell = await customContainer.openShell("zsh"); + await shell.runCommand("uv init test-project-custom-dir"); + const result = await shell.runCommand( + "cd test-project-custom-dir && uv add safe-chain-pi-test" + ); + + assert.ok( + result.output.includes("blocked 1 malicious package downloads:"), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + }); + }); }); diff --git a/test/e2e/yarn-ci.e2e.spec.js b/test/e2e/yarn-ci.e2e.spec.js index 47e2120..35047c1 100644 --- a/test/e2e/yarn-ci.e2e.spec.js +++ b/test/e2e/yarn-ci.e2e.spec.js @@ -84,4 +84,45 @@ describe("E2E: yarn coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); + + describe("with SAFE_CHAIN_DIR (custom install directory)", () => { + const CUSTOM_DIR = "/usr/local/.safe-chain"; + let customContainer; + + beforeEach(async () => { + customContainer = new DockerTestContainer(); + await customContainer.start(); + + const setupShell = await customContainer.openShell("zsh"); + await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await setupShell.runCommand("safe-chain setup-ci"); + await setupShell.runCommand( + `echo 'export SAFE_CHAIN_DIR=${CUSTOM_DIR}' >> ~/.zshrc` + ); + await setupShell.runCommand( + `echo 'export PATH="${CUSTOM_DIR}/shims:$PATH"' >> ~/.zshrc` + ); + }); + + afterEach(async () => { + if (customContainer) { + await customContainer.stop(); + customContainer = null; + } + }); + + it("blocks malicious yarn packages when shims are in a custom directory", async () => { + const shell = await customContainer.openShell("zsh"); + const result = await shell.runCommand("yarn add safe-chain-test"); + + assert.ok( + result.output.includes("Malicious changes detected:"), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + }); + }); }); diff --git a/test/e2e/yarn.e2e.spec.js b/test/e2e/yarn.e2e.spec.js index 5e56d12..5b677d6 100644 --- a/test/e2e/yarn.e2e.spec.js +++ b/test/e2e/yarn.e2e.spec.js @@ -125,4 +125,43 @@ describe("E2E: yarn coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); + + describe("with SAFE_CHAIN_DIR (custom install directory)", () => { + const CUSTOM_DIR = "/usr/local/.safe-chain"; + let customContainer; + + beforeEach(async () => { + customContainer = new DockerTestContainer(); + await customContainer.start(); + + // Run setup with the custom dir — init-posix.sh is copied to the custom + // scripts dir, and ~/.zshrc gets a source line pointing there + const setupShell = await customContainer.openShell("zsh"); + await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); + await setupShell.runCommand("safe-chain setup"); + }); + + afterEach(async () => { + if (customContainer) { + await customContainer.stop(); + customContainer = null; + } + }); + + it("blocks malicious yarn packages when scripts are in a custom directory", async () => { + // New shell sources ~/.zshrc → sources init-posix.sh from custom dir + // → defines yarn() shell function that routes through safe-chain + const shell = await customContainer.openShell("zsh"); + const result = await shell.runCommand("yarn add safe-chain-test"); + + assert.ok( + result.output.includes("Malicious changes detected:"), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected malicious package to be blocked. Output:\n${result.output}` + ); + }); + }); }); From b0f392522b78164926377559770aee6fc68675d6 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 14:08:59 -0700 Subject: [PATCH 261/360] Some cleanup --- README.md | 16 +++++++- install-scripts/install-safe-chain.ps1 | 3 +- install-scripts/install-safe-chain.sh | 3 +- install-scripts/uninstall-safe-chain.ps1 | 2 +- install-scripts/uninstall-safe-chain.sh | 3 +- packages/safe-chain/src/config/configFile.js | 4 +- .../src/shell-integration/helpers.js | 1 + .../safe-chain/src/shell-integration/setup.js | 1 - .../supported-shells/bash.js | 16 ++++++++ .../supported-shells/bash.spec.js | 37 +++++++++++++++++++ .../supported-shells/fish.js | 16 ++++++++ .../supported-shells/fish.spec.js | 36 ++++++++++++++++++ .../supported-shells/powershell.js | 14 +++++++ .../supported-shells/powershell.spec.js | 37 +++++++++++++++++++ .../supported-shells/windowsPowershell.js | 14 +++++++ .../windowsPowershell.spec.js | 37 +++++++++++++++++++ .../shell-integration/supported-shells/zsh.js | 16 ++++++++ .../supported-shells/zsh.spec.js | 37 +++++++++++++++++++ .../src/shell-integration/teardown.js | 1 + 19 files changed, 286 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3e73137..ba3ec47 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,19 @@ The base URL should point to a server that mirrors the structure of `https://mal - `/releases/npm.json` (JavaScript new packages list) - `/releases/pypi.json` (Python new packages list) +## Custom Install Directory + +By default, Safe Chain installs itself into `~/.safe-chain`. You can change this by setting `SAFE_CHAIN_DIR` before running the installer. This is useful for system-wide installations (e.g. inside a Docker image) or when you need to avoid conflicts with other tools. + +When set, all Safe Chain data (binary, shims, scripts) is placed under the custom directory instead of `~/.safe-chain`. + +```shell +export SAFE_CHAIN_DIR=/usr/local/.safe-chain +curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh +``` + +> **Note:** CLI argument and config file options are not supported for `SAFE_CHAIN_DIR`. The config file lives inside the Safe Chain directory itself, creating a chicken-and-egg problem, and passing a directory path as a flag to package manager commands (e.g. `npm install express --safe-chain-dir=...`) does not make sense. + # Usage in CI/CD You can protect your CI/CD pipelines from malicious packages by integrating Aikido Safe Chain into your build process. This ensures that any packages installed during your automated builds are checked for malware before installation. @@ -406,6 +419,7 @@ pipeline { environment { // Jenkins does not automatically persist PATH updates from setup-ci, // so add the shims + binary directory explicitly for all stages. + // If you set SAFE_CHAIN_DIR, replace ~/.safe-chain with that path here. PATH = "${env.HOME}/.safe-chain/shims:${env.HOME}/.safe-chain/bin:${env.PATH}" } @@ -461,7 +475,7 @@ To add safe-chain in GitLab pipelines, you need to install it in the image runni # Install safe-chain RUN curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci - # Add safe-chain to PATH + # Add safe-chain to PATH (update paths if you set SAFE_CHAIN_DIR during install) ENV PATH="/root/.safe-chain/shims:/root/.safe-chain/bin:${PATH}" ``` diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index ffe2505..f95fdfd 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -8,7 +8,8 @@ param( ) $Version = $env:SAFE_CHAIN_VERSION # Will be fetched from latest release if not set -$InstallDir = Join-Path $env:USERPROFILE ".safe-chain\bin" +$SafeChainBase = if ($env:SAFE_CHAIN_DIR) { $env:SAFE_CHAIN_DIR } else { Join-Path $env:USERPROFILE ".safe-chain" } +$InstallDir = Join-Path $SafeChainBase "bin" $RepoUrl = "https://github.com/AikidoSec/safe-chain" # Ensure TLS 1.2 is enabled for downloads diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 182cdad..f65b1d7 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -8,7 +8,8 @@ set -e # Exit on error # Configuration VERSION="${SAFE_CHAIN_VERSION:-}" # Will be fetched from latest release if not set -INSTALL_DIR="${HOME}/.safe-chain/bin" +SAFE_CHAIN_BASE="${SAFE_CHAIN_DIR:-${HOME}/.safe-chain}" +INSTALL_DIR="${SAFE_CHAIN_BASE}/bin" REPO_URL="https://github.com/AikidoSec/safe-chain" # Colors for output diff --git a/install-scripts/uninstall-safe-chain.ps1 b/install-scripts/uninstall-safe-chain.ps1 index 3292cdd..32a27a5 100644 --- a/install-scripts/uninstall-safe-chain.ps1 +++ b/install-scripts/uninstall-safe-chain.ps1 @@ -4,7 +4,7 @@ # Use HOME on Unix, USERPROFILE on Windows (PowerShell Core is cross-platform) $HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE } -$DotSafeChain = Join-Path $HomeDir ".safe-chain" +$DotSafeChain = if ($env:SAFE_CHAIN_DIR) { $env:SAFE_CHAIN_DIR } else { Join-Path $HomeDir ".safe-chain" } $InstallDir = Join-Path $DotSafeChain "bin" # Helper functions diff --git a/install-scripts/uninstall-safe-chain.sh b/install-scripts/uninstall-safe-chain.sh index dff6f31..fcb5153 100755 --- a/install-scripts/uninstall-safe-chain.sh +++ b/install-scripts/uninstall-safe-chain.sh @@ -7,7 +7,7 @@ set -e # Exit on error # Configuration -DOT_SAFE_CHAIN="${HOME}/.safe-chain" +DOT_SAFE_CHAIN="${SAFE_CHAIN_DIR:-${HOME}/.safe-chain}" # Colors for output RED='\033[0;31m' @@ -163,6 +163,7 @@ main() { else info "Installation directory $DOT_SAFE_CHAIN does not exist. Nothing to remove." fi + } main "$@" diff --git a/packages/safe-chain/src/config/configFile.js b/packages/safe-chain/src/config/configFile.js index 3fb0f21..1b978ea 100644 --- a/packages/safe-chain/src/config/configFile.js +++ b/packages/safe-chain/src/config/configFile.js @@ -3,6 +3,7 @@ import path from "path"; import os from "os"; import { ui } from "../environment/userInteraction.js"; import { getEcoSystem } from "./settings.js"; +import { getSafeChainDir } from "./environmentVariables.js"; /** * @typedef {Object} SafeChainConfig @@ -304,8 +305,7 @@ function getConfigFilePath() { * @returns {string} */ export function getSafeChainDirectory() { - const homeDir = os.homedir(); - const safeChainDir = path.join(homeDir, ".safe-chain"); + const safeChainDir = getSafeChainDir() ?? path.join(os.homedir(), ".safe-chain"); if (!fs.existsSync(safeChainDir)) { fs.mkdirSync(safeChainDir, { recursive: true }); diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index 7ccfd99..2d66d1d 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -4,6 +4,7 @@ import fs from "fs"; import path from "path"; import { ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; import { getSafeChainDir } from "../config/environmentVariables.js"; +export { getSafeChainDir }; import { safeSpawn } from "../utils/safeSpawn.js"; import { ui } from "../environment/userInteraction.js"; diff --git a/packages/safe-chain/src/shell-integration/setup.js b/packages/safe-chain/src/shell-integration/setup.js index 66c6533..120723a 100644 --- a/packages/safe-chain/src/shell-integration/setup.js +++ b/packages/safe-chain/src/shell-integration/setup.js @@ -122,7 +122,6 @@ function copyStartupFiles() { fs.mkdirSync(targetDir, { recursive: true }); } - // Use absolute path for source const sourcePath = path.join(dirname, "startup-scripts", file); fs.copyFileSync(sourcePath, targetPath); } diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.js index 364323e..4f04c5e 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.js @@ -3,6 +3,7 @@ import { doesExecutableExistOnSystem, removeLinesMatchingPattern, getScriptsDir, + getSafeChainDir, } from "../helpers.js"; import { execSync, spawnSync } from "child_process"; import * as os from "os"; @@ -41,12 +42,27 @@ function teardown(tools) { eol ); + removeLinesMatchingPattern( + startupFile, + /^export\s+SAFE_CHAIN_DIR=.*#\s*Safe-chain/, + eol + ); + return true; } function setup() { const startupFile = getStartupFile(); + const customDir = getSafeChainDir(); + if (customDir) { + addLineToFile( + startupFile, + `export SAFE_CHAIN_DIR="${customDir}" # Safe-chain installation directory`, + eol + ); + } + addLineToFile( startupFile, `source ${path.join(getScriptsDir(), "init-posix.sh")} # Safe-chain bash initialization script`, diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js index f0a56d2..a8cd067 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js @@ -10,6 +10,7 @@ describe("Bash shell integration", () => { let bash; let windowsCygwinPath = ""; let platform = "linux"; + let getSafeChainDirResult = undefined; beforeEach(async () => { // Create temporary startup file for testing @@ -20,6 +21,7 @@ describe("Bash shell integration", () => { namedExports: { doesExecutableExistOnSystem: () => true, getScriptsDir: () => "/test-home/.safe-chain/scripts", + getSafeChainDir: () => getSafeChainDirResult, addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -89,6 +91,7 @@ describe("Bash shell integration", () => { // Reset mocks mock.reset(); platform = "linux"; + getSafeChainDirResult = undefined; }); describe("isInstalled", () => { @@ -200,6 +203,40 @@ describe("Bash shell integration", () => { }); }); + describe("SAFE_CHAIN_DIR", () => { + it("should write export line to rc file when custom dir is set", () => { + getSafeChainDirResult = "/custom/safe-chain"; + bash.setup(); + + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok( + content.includes('export SAFE_CHAIN_DIR="/custom/safe-chain" # Safe-chain installation directory') + ); + }); + + it("should not write export line when no custom dir is set", () => { + getSafeChainDirResult = undefined; + bash.setup(); + + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok(!content.includes("SAFE_CHAIN_DIR")); + }); + + it("should remove export line on teardown", () => { + const initialContent = [ + '#!/bin/bash', + 'export SAFE_CHAIN_DIR="/custom/safe-chain" # Safe-chain installation directory', + 'source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script', + ].join("\n"); + + fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); + + bash.teardown(knownAikidoTools); + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok(!content.includes("SAFE_CHAIN_DIR")); + }); + }); + describe("integration tests", () => { it("should handle complete setup and teardown cycle", () => { const tools = [ diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.js index 5f59826..bac8e7b 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.js @@ -3,6 +3,7 @@ import { doesExecutableExistOnSystem, removeLinesMatchingPattern, getScriptsDir, + getSafeChainDir, } from "../helpers.js"; import { execSync } from "child_process"; import path from "path"; @@ -40,12 +41,27 @@ function teardown(tools) { eol ); + removeLinesMatchingPattern( + startupFile, + /^set\s+-gx\s+SAFE_CHAIN_DIR\s+.*#\s*Safe-chain/, + eol + ); + return true; } function setup() { const startupFile = getStartupFile(); + const customDir = getSafeChainDir(); + if (customDir) { + addLineToFile( + startupFile, + `set -gx SAFE_CHAIN_DIR "${customDir}" # Safe-chain installation directory`, + eol + ); + } + addLineToFile( startupFile, `source ${path.join(getScriptsDir(), "init-fish.fish")} # Safe-chain Fish initialization script`, diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js index 0933b6e..c9918c5 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js @@ -8,6 +8,7 @@ import { knownAikidoTools } from "../helpers.js"; describe("Fish shell integration", () => { let mockStartupFile; let fish; + let getSafeChainDirResult = undefined; beforeEach(async () => { // Create temporary startup file for testing @@ -18,6 +19,7 @@ describe("Fish shell integration", () => { namedExports: { doesExecutableExistOnSystem: () => true, getScriptsDir: () => "/test-home/.safe-chain/scripts", + getSafeChainDir: () => getSafeChainDirResult, addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -53,6 +55,7 @@ describe("Fish shell integration", () => { // Reset mocks mock.reset(); + getSafeChainDirResult = undefined; }); describe("isInstalled", () => { @@ -153,6 +156,39 @@ describe("Fish shell integration", () => { }); }); + describe("SAFE_CHAIN_DIR", () => { + it("should write set line to config file when custom dir is set", () => { + getSafeChainDirResult = "/custom/safe-chain"; + fish.setup(); + + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok( + content.includes('set -gx SAFE_CHAIN_DIR "/custom/safe-chain" # Safe-chain installation directory') + ); + }); + + it("should not write set line when no custom dir is set", () => { + getSafeChainDirResult = undefined; + fish.setup(); + + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok(!content.includes("SAFE_CHAIN_DIR")); + }); + + it("should remove set line on teardown", () => { + const initialContent = [ + 'set -gx SAFE_CHAIN_DIR "/custom/safe-chain" # Safe-chain installation directory', + "source /test-home/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script", + ].join("\n"); + + fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); + + fish.teardown(knownAikidoTools); + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok(!content.includes("SAFE_CHAIN_DIR")); + }); + }); + describe("integration tests", () => { it("should handle complete setup and teardown cycle", () => { const tools = [ diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js index 59aee41..38b0b42 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js @@ -4,6 +4,7 @@ import { removeLinesMatchingPattern, validatePowerShellExecutionPolicy, getScriptsDir, + getSafeChainDir, } from "../helpers.js"; import { execSync } from "child_process"; import path from "path"; @@ -38,6 +39,11 @@ function teardown(tools) { /^\.\s+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/, ); + removeLinesMatchingPattern( + startupFile, + /^\$env:SAFE_CHAIN_DIR\s*=.*#\s*Safe-chain/, + ); + return true; } @@ -52,6 +58,14 @@ async function setup() { const startupFile = getStartupFile(); + const customDir = getSafeChainDir(); + if (customDir) { + addLineToFile( + startupFile, + `$env:SAFE_CHAIN_DIR = '${customDir}' # Safe-chain installation directory`, + ); + } + addLineToFile( startupFile, `. "${path.join(getScriptsDir(), "init-pwsh.ps1")}" # Safe-chain PowerShell initialization script`, diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js index 1d9f65c..97901f1 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js @@ -9,6 +9,7 @@ describe("PowerShell Core shell integration", () => { let mockStartupFile; let powershell; let executionPolicyResult; + let getSafeChainDirResult = undefined; beforeEach(async () => { // Create temporary startup file for testing @@ -26,6 +27,7 @@ describe("PowerShell Core shell integration", () => { mock.module("../helpers.js", { namedExports: { doesExecutableExistOnSystem: () => true, + getSafeChainDir: () => getSafeChainDirResult, addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -63,6 +65,7 @@ describe("PowerShell Core shell integration", () => { // Reset mocks mock.reset(); + getSafeChainDirResult = undefined; }); describe("isInstalled", () => { @@ -206,6 +209,40 @@ describe("PowerShell Core shell integration", () => { }); }); + describe("SAFE_CHAIN_DIR", () => { + it("should write $env:SAFE_CHAIN_DIR line to profile when custom dir is set", async () => { + getSafeChainDirResult = "C:\\custom\\safe-chain"; + await powershell.setup(); + + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok( + content.includes("$env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain' # Safe-chain installation directory") + ); + }); + + it("should not write $env:SAFE_CHAIN_DIR line when no custom dir is set", async () => { + getSafeChainDirResult = undefined; + await powershell.setup(); + + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok(!content.includes("SAFE_CHAIN_DIR")); + }); + + it("should remove $env:SAFE_CHAIN_DIR line on teardown", () => { + const initialContent = [ + "# PowerShell profile", + "$env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain' # Safe-chain installation directory", + '. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script', + ].join("\n"); + + fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); + + powershell.teardown(knownAikidoTools); + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok(!content.includes("SAFE_CHAIN_DIR")); + }); + }); + describe("execution policy", () => { it(`should throw for restricted policies`, async () => { executionPolicyResult = { diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js index 36ab114..506b891 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -4,6 +4,7 @@ import { removeLinesMatchingPattern, validatePowerShellExecutionPolicy, getScriptsDir, + getSafeChainDir, } from "../helpers.js"; import { execSync } from "child_process"; import path from "path"; @@ -38,6 +39,11 @@ function teardown(tools) { /^\.\s+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/, ); + removeLinesMatchingPattern( + startupFile, + /^\$env:SAFE_CHAIN_DIR\s*=.*#\s*Safe-chain/, + ); + return true; } @@ -52,6 +58,14 @@ async function setup() { const startupFile = getStartupFile(); + const customDir = getSafeChainDir(); + if (customDir) { + addLineToFile( + startupFile, + `$env:SAFE_CHAIN_DIR = '${customDir}' # Safe-chain installation directory`, + ); + } + addLineToFile( startupFile, `. "${path.join(getScriptsDir(), "init-pwsh.ps1")}" # Safe-chain PowerShell initialization script`, diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js index 621b380..efb5cc3 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js @@ -9,6 +9,7 @@ describe("Windows PowerShell shell integration", () => { let mockStartupFile; let windowsPowershell; let executionPolicyResult; + let getSafeChainDirResult = undefined; beforeEach(async () => { // Create temporary startup file for testing @@ -26,6 +27,7 @@ describe("Windows PowerShell shell integration", () => { mock.module("../helpers.js", { namedExports: { doesExecutableExistOnSystem: () => true, + getSafeChainDir: () => getSafeChainDirResult, addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -63,6 +65,7 @@ describe("Windows PowerShell shell integration", () => { // Reset mocks mock.reset(); + getSafeChainDirResult = undefined; }); describe("isInstalled", () => { @@ -206,6 +209,40 @@ describe("Windows PowerShell shell integration", () => { }); }); + describe("SAFE_CHAIN_DIR", () => { + it("should write $env:SAFE_CHAIN_DIR line to profile when custom dir is set", async () => { + getSafeChainDirResult = "C:\\custom\\safe-chain"; + await windowsPowershell.setup(); + + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok( + content.includes("$env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain' # Safe-chain installation directory") + ); + }); + + it("should not write $env:SAFE_CHAIN_DIR line when no custom dir is set", async () => { + getSafeChainDirResult = undefined; + await windowsPowershell.setup(); + + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok(!content.includes("SAFE_CHAIN_DIR")); + }); + + it("should remove $env:SAFE_CHAIN_DIR line on teardown", () => { + const initialContent = [ + "# Windows PowerShell profile", + "$env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain' # Safe-chain installation directory", + '. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script', + ].join("\n"); + + fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); + + windowsPowershell.teardown(knownAikidoTools); + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok(!content.includes("SAFE_CHAIN_DIR")); + }); + }); + describe("execution policy", () => { it(`should throw for restricted policies`, async () => { executionPolicyResult = { diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js index 369b445..a340424 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js @@ -3,6 +3,7 @@ import { doesExecutableExistOnSystem, removeLinesMatchingPattern, getScriptsDir, + getSafeChainDir, } from "../helpers.js"; import { execSync } from "child_process"; import path from "path"; @@ -40,12 +41,27 @@ function teardown(tools) { eol ); + removeLinesMatchingPattern( + startupFile, + /^export\s+SAFE_CHAIN_DIR=.*#\s*Safe-chain/, + eol + ); + return true; } function setup() { const startupFile = getStartupFile(); + const customDir = getSafeChainDir(); + if (customDir) { + addLineToFile( + startupFile, + `export SAFE_CHAIN_DIR="${customDir}" # Safe-chain installation directory`, + eol + ); + } + addLineToFile( startupFile, `source ${path.join(getScriptsDir(), "init-posix.sh")} # Safe-chain Zsh initialization script`, diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js index 41e1bd1..4f1ca88 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js @@ -8,6 +8,7 @@ import { knownAikidoTools } from "../helpers.js"; describe("Zsh shell integration", () => { let mockStartupFile; let zsh; + let getSafeChainDirResult = undefined; beforeEach(async () => { // Create temporary startup file for testing @@ -18,6 +19,7 @@ describe("Zsh shell integration", () => { namedExports: { doesExecutableExistOnSystem: () => true, getScriptsDir: () => "/test-home/.safe-chain/scripts", + getSafeChainDir: () => getSafeChainDirResult, addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -53,6 +55,7 @@ describe("Zsh shell integration", () => { // Reset mocks mock.reset(); + getSafeChainDirResult = undefined; }); describe("isInstalled", () => { @@ -171,6 +174,40 @@ describe("Zsh shell integration", () => { }); }); + describe("SAFE_CHAIN_DIR", () => { + it("should write export line to rc file when custom dir is set", () => { + getSafeChainDirResult = "/custom/safe-chain"; + zsh.setup(); + + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok( + content.includes('export SAFE_CHAIN_DIR="/custom/safe-chain" # Safe-chain installation directory') + ); + }); + + it("should not write export line when no custom dir is set", () => { + getSafeChainDirResult = undefined; + zsh.setup(); + + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok(!content.includes("SAFE_CHAIN_DIR")); + }); + + it("should remove export line on teardown", () => { + const initialContent = [ + "#!/bin/zsh", + 'export SAFE_CHAIN_DIR="/custom/safe-chain" # Safe-chain installation directory', + "source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script", + ].join("\n"); + + fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); + + zsh.teardown(knownAikidoTools); + const content = fs.readFileSync(mockStartupFile, "utf-8"); + assert.ok(!content.includes("SAFE_CHAIN_DIR")); + }); + }); + describe("integration tests", () => { it("should handle complete setup and teardown cycle", () => { const tools = [ diff --git a/packages/safe-chain/src/shell-integration/teardown.js b/packages/safe-chain/src/shell-integration/teardown.js index bcf6346..e5f149d 100644 --- a/packages/safe-chain/src/shell-integration/teardown.js +++ b/packages/safe-chain/src/shell-integration/teardown.js @@ -109,4 +109,5 @@ export async function teardownDirectories() { ); } } + } From 1aef941d1cde594a21ccb7d8c912e3c6a8351e35 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 14:13:34 -0700 Subject: [PATCH 262/360] Update README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ba3ec47..0dc3f40 100644 --- a/README.md +++ b/README.md @@ -320,14 +320,14 @@ The base URL should point to a server that mirrors the structure of `https://mal By default, Safe Chain installs itself into `~/.safe-chain`. You can change this by setting `SAFE_CHAIN_DIR` before running the installer. This is useful for system-wide installations (e.g. inside a Docker image) or when you need to avoid conflicts with other tools. -When set, all Safe Chain data (binary, shims, scripts) is placed under the custom directory instead of `~/.safe-chain`. +When set, all Safe Chain data (binary, shims, scripts, config) is placed under the custom directory instead of `~/.safe-chain`. ```shell export SAFE_CHAIN_DIR=/usr/local/.safe-chain curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh ``` -> **Note:** CLI argument and config file options are not supported for `SAFE_CHAIN_DIR`. The config file lives inside the Safe Chain directory itself, creating a chicken-and-egg problem, and passing a directory path as a flag to package manager commands (e.g. `npm install express --safe-chain-dir=...`) does not make sense. +This is a **one-time setting**. `safe-chain setup` automatically persists `SAFE_CHAIN_DIR` to your shell rc files (e.g. `~/.bashrc`, `~/.zshrc`) so that subsequent `safe-chain` commands (including teardown and re-setup) find the correct directory without needing the variable set again. # Usage in CI/CD From 32c95dbb9d3156c1d8229679313352cf12ea9f93 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 14:27:55 -0700 Subject: [PATCH 263/360] Fix WIndows shell + unit tests --- .../safe-chain/src/registryProxy/certUtils.js | 10 ++- .../src/registryProxy/certUtils.spec.js | 71 +++++++++++++++++++ .../templates/windows-wrapper.template.cmd | 8 ++- .../src/shell-integration/setup-ci.spec.js | 6 +- .../supported-shells/bash.js | 38 +++++++--- .../supported-shells/bash.spec.js | 11 +++ .../supported-shells/fish.js | 38 ++++++++-- .../supported-shells/fish.spec.js | 11 +++ .../supported-shells/powershell.js | 34 +++++++-- .../supported-shells/powershell.spec.js | 11 +++ .../supported-shells/windowsPowershell.js | 34 +++++++-- .../windowsPowershell.spec.js | 11 +++ .../shell-integration/supported-shells/zsh.js | 38 +++++++--- .../supported-shells/zsh.spec.js | 11 +++ 14 files changed, 289 insertions(+), 43 deletions(-) create mode 100644 packages/safe-chain/src/registryProxy/certUtils.spec.js diff --git a/packages/safe-chain/src/registryProxy/certUtils.js b/packages/safe-chain/src/registryProxy/certUtils.js index 3c8790c..a4bc0b1 100644 --- a/packages/safe-chain/src/registryProxy/certUtils.js +++ b/packages/safe-chain/src/registryProxy/certUtils.js @@ -2,12 +2,17 @@ import forge from "node-forge"; import path from "path"; import fs from "fs"; import os from "os"; +import { getSafeChainDir } from "../config/environmentVariables.js"; -const certFolder = path.join(os.homedir(), ".safe-chain", "certs"); const ca = loadCa(); const certCache = new Map(); +function getCertFolder() { + const safeChainDir = getSafeChainDir() ?? path.join(os.homedir(), ".safe-chain"); + return path.join(safeChainDir, "certs"); +} + /** * @param {forge.pki.PublicKey} publicKey * @returns {string} @@ -20,7 +25,7 @@ function createKeyIdentifier(publicKey) { } export function getCaCertPath() { - return path.join(certFolder, "ca-cert.pem"); + return path.join(getCertFolder(), "ca-cert.pem"); } /** @@ -112,6 +117,7 @@ export function generateCertForHost(hostname) { } function loadCa() { + const certFolder = getCertFolder(); const keyPath = path.join(certFolder, "ca-key.pem"); const certPath = path.join(certFolder, "ca-cert.pem"); diff --git a/packages/safe-chain/src/registryProxy/certUtils.spec.js b/packages/safe-chain/src/registryProxy/certUtils.spec.js new file mode 100644 index 0000000..ebf8dab --- /dev/null +++ b/packages/safe-chain/src/registryProxy/certUtils.spec.js @@ -0,0 +1,71 @@ +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; + +describe("certUtils", () => { + let originalSafeChainDir; + + beforeEach(() => { + originalSafeChainDir = process.env.SAFE_CHAIN_DIR; + }); + + afterEach(() => { + if (originalSafeChainDir === undefined) { + delete process.env.SAFE_CHAIN_DIR; + } else { + process.env.SAFE_CHAIN_DIR = originalSafeChainDir; + } + + mock.reset(); + }); + + it("stores CA certificates in SAFE_CHAIN_DIR when configured", async () => { + process.env.SAFE_CHAIN_DIR = "/custom/safe-chain"; + + mock.module("fs", { + defaultExport: { + existsSync: () => false, + mkdirSync: () => {}, + writeFileSync: () => {}, + }, + }); + + mock.module("node-forge", { + defaultExport: { + pki: { + getPublicKeyFingerprint: () => "fingerprint", + rsa: { + generateKeyPair: () => ({ + publicKey: "public-key", + privateKey: "private-key", + }), + }, + createCertificate: () => ({ + publicKey: null, + serialNumber: "", + validity: { + notBefore: new Date(), + notAfter: new Date(), + }, + setSubject: () => {}, + setIssuer: () => {}, + setExtensions: () => {}, + sign: () => {}, + }), + privateKeyToPem: () => "private-key-pem", + certificateToPem: () => "certificate-pem", + }, + md: { + sha1: { create: () => "sha1" }, + sha256: { create: () => "sha256" }, + }, + }, + }); + + const { getCaCertPath } = await import("./certUtils.js"); + + assert.strictEqual( + getCaCertPath(), + "/custom/safe-chain/certs/ca-cert.pem", + ); + }); +}); diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd b/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd index 082d553..959b700 100644 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd @@ -3,7 +3,11 @@ REM Generated wrapper for {{PACKAGE_MANAGER}} by safe-chain REM This wrapper intercepts {{PACKAGE_MANAGER}} calls for non-interactive environments REM Remove shim directory from PATH to prevent infinite loops -set "SHIM_DIR=%USERPROFILE%\.safe-chain\shims" +if defined SAFE_CHAIN_DIR ( + set "SHIM_DIR=%SAFE_CHAIN_DIR%\shims" +) else ( + set "SHIM_DIR=%USERPROFILE%\.safe-chain\shims" +) call set "CLEAN_PATH=%%PATH:%SHIM_DIR%;=%%" REM Check if aikido command is available with clean PATH @@ -21,4 +25,4 @@ if %errorlevel%==0 ( REM If we get here, original command was not found echo Error: Could not find original {{PACKAGE_MANAGER}} >&2 exit /b 1 -) \ No newline at end of file +) diff --git a/packages/safe-chain/src/shell-integration/setup-ci.spec.js b/packages/safe-chain/src/shell-integration/setup-ci.spec.js index c0a5ca1..1156173 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.spec.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.spec.js @@ -27,7 +27,7 @@ describe("Setup CI shell integration", () => { ); fs.writeFileSync( path.join(mockTemplateDir, "path-wrappers", "templates", "windows-wrapper.template.cmd"), - "@echo off\nREM Template for {{PACKAGE_MANAGER}}\n{{AIKIDO_COMMAND}} %*\n", + "@echo off\nif defined SAFE_CHAIN_DIR (\n set \"SHIM_DIR=%SAFE_CHAIN_DIR%\\shims\"\n) else (\n set \"SHIM_DIR=%USERPROFILE%\\.safe-chain\\shims\"\n)\n{{AIKIDO_COMMAND}} %*\n", "utf-8" ); @@ -143,6 +143,10 @@ describe("Setup CI shell integration", () => { assert.ok(npmShimContent.includes("aikido-npm"), "npm.cmd should contain aikido-npm"); assert.ok(npmShimContent.includes("@echo off"), "npm.cmd should have Windows batch header"); assert.ok(npmShimContent.includes("%*"), "npm.cmd should use Windows argument passing"); + assert.ok( + npmShimContent.includes("if defined SAFE_CHAIN_DIR"), + "npm.cmd should honor SAFE_CHAIN_DIR when removing shim dir from PATH", + ); // Verify Unix shims were NOT created const unixNpmShim = path.join(mockShimsDir, "npm"); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.js index 4f04c5e..bcf0bc6 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.js @@ -142,19 +142,37 @@ function cygpathw(path) { } function getManualTeardownInstructions() { - return [ - `Remove the following line from your ~/.bashrc file:`, - ` source ~/.safe-chain/scripts/init-posix.sh`, - `Then restart your terminal or run: source ~/.bashrc`, - ]; + const customDir = getSafeChainDir(); + const instructions = [`Remove the following line from your ~/.bashrc file:`]; + + if (customDir) { + instructions.push( + ` export SAFE_CHAIN_DIR="${customDir}"`, + ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, + ); + } else { + instructions.push(` source ~/.safe-chain/scripts/init-posix.sh`); + } + + instructions.push(`Then restart your terminal or run: source ~/.bashrc`); + return instructions; } function getManualSetupInstructions() { - return [ - `Add the following line to your ~/.bashrc file:`, - ` source ~/.safe-chain/scripts/init-posix.sh`, - `Then restart your terminal or run: source ~/.bashrc`, - ]; + const customDir = getSafeChainDir(); + const instructions = [`Add the following line to your ~/.bashrc file:`]; + + if (customDir) { + instructions.push( + ` export SAFE_CHAIN_DIR="${customDir}"`, + ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, + ); + } else { + instructions.push(` source ~/.safe-chain/scripts/init-posix.sh`); + } + + instructions.push(`Then restart your terminal or run: source ~/.bashrc`); + return instructions; } /** diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js index a8cd067..4b25d4b 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js @@ -235,6 +235,17 @@ describe("Bash shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); + + it("should show custom manual setup instructions when custom dir is set", () => { + getSafeChainDirResult = "/custom/safe-chain"; + + assert.deepStrictEqual(bash.getManualSetupInstructions(), [ + "Add the following line to your ~/.bashrc file:", + ' export SAFE_CHAIN_DIR="/custom/safe-chain"', + " source /test-home/.safe-chain/scripts/init-posix.sh", + "Then restart your terminal or run: source ~/.bashrc", + ]); + }); }); describe("integration tests", () => { diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.js index bac8e7b..33aa48c 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.js @@ -85,19 +85,45 @@ function getStartupFile() { } function getManualTeardownInstructions() { - return [ + const customDir = getSafeChainDir(); + const instructions = [ `Remove the following line from your ~/.config/fish/config.fish file:`, - ` source ~/.safe-chain/scripts/init-fish.fish`, - `Then restart your terminal or run: source ~/.config/fish/config.fish`, ]; + + if (customDir) { + instructions.push( + ` set -gx SAFE_CHAIN_DIR "${customDir}"`, + ` source ${path.join(getScriptsDir(), "init-fish.fish")}`, + ); + } else { + instructions.push(` source ~/.safe-chain/scripts/init-fish.fish`); + } + + instructions.push( + `Then restart your terminal or run: source ~/.config/fish/config.fish`, + ); + return instructions; } function getManualSetupInstructions() { - return [ + const customDir = getSafeChainDir(); + const instructions = [ `Add the following line to your ~/.config/fish/config.fish file:`, - ` source ~/.safe-chain/scripts/init-fish.fish`, - `Then restart your terminal or run: source ~/.config/fish/config.fish`, ]; + + if (customDir) { + instructions.push( + ` set -gx SAFE_CHAIN_DIR "${customDir}"`, + ` source ${path.join(getScriptsDir(), "init-fish.fish")}`, + ); + } else { + instructions.push(` source ~/.safe-chain/scripts/init-fish.fish`); + } + + instructions.push( + `Then restart your terminal or run: source ~/.config/fish/config.fish`, + ); + return instructions; } /** diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js index c9918c5..29b6d6e 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js @@ -187,6 +187,17 @@ describe("Fish shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); + + it("should show custom manual setup instructions when custom dir is set", () => { + getSafeChainDirResult = "/custom/safe-chain"; + + assert.deepStrictEqual(fish.getManualSetupInstructions(), [ + "Add the following line to your ~/.config/fish/config.fish file:", + ' set -gx SAFE_CHAIN_DIR "/custom/safe-chain"', + " source /test-home/.safe-chain/scripts/init-fish.fish", + "Then restart your terminal or run: source ~/.config/fish/config.fish", + ]); + }); }); describe("integration tests", () => { diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js index 38b0b42..44fbfe9 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js @@ -88,19 +88,41 @@ function getStartupFile() { } function getManualTeardownInstructions() { - return [ + const customDir = getSafeChainDir(); + const instructions = [ `Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`, - ` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`, - `Then restart your terminal or run: . $PROFILE`, ]; + + if (customDir) { + instructions.push( + ` $env:SAFE_CHAIN_DIR = '${customDir}'`, + ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, + ); + } else { + instructions.push(` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`); + } + + instructions.push(`Then restart your terminal or run: . $PROFILE`); + return instructions; } function getManualSetupInstructions() { - return [ + const customDir = getSafeChainDir(); + const instructions = [ `Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`, - ` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`, - `Then restart your terminal or run: . $PROFILE`, ]; + + if (customDir) { + instructions.push( + ` $env:SAFE_CHAIN_DIR = '${customDir}'`, + ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, + ); + } else { + instructions.push(` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`); + } + + instructions.push(`Then restart your terminal or run: . $PROFILE`); + return instructions; } /** diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js index 97901f1..296abfa 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js @@ -241,6 +241,17 @@ describe("PowerShell Core shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); + + it("should show custom manual setup instructions when custom dir is set", () => { + getSafeChainDirResult = "C:\\custom\\safe-chain"; + + assert.deepStrictEqual(powershell.getManualSetupInstructions(), [ + 'Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):', + " $env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain'", + ' . "/test-home/.safe-chain/scripts/init-pwsh.ps1"', + "Then restart your terminal or run: . $PROFILE", + ]); + }); }); describe("execution policy", () => { diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js index 506b891..e3ed236 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -88,19 +88,41 @@ function getStartupFile() { } function getManualTeardownInstructions() { - return [ + const customDir = getSafeChainDir(); + const instructions = [ `Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`, - ` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`, - `Then restart your terminal or run: . $PROFILE`, ]; + + if (customDir) { + instructions.push( + ` $env:SAFE_CHAIN_DIR = '${customDir}'`, + ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, + ); + } else { + instructions.push(` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`); + } + + instructions.push(`Then restart your terminal or run: . $PROFILE`); + return instructions; } function getManualSetupInstructions() { - return [ + const customDir = getSafeChainDir(); + const instructions = [ `Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`, - ` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`, - `Then restart your terminal or run: . $PROFILE`, ]; + + if (customDir) { + instructions.push( + ` $env:SAFE_CHAIN_DIR = '${customDir}'`, + ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, + ); + } else { + instructions.push(` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`); + } + + instructions.push(`Then restart your terminal or run: . $PROFILE`); + return instructions; } /** diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js index efb5cc3..840f585 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js @@ -241,6 +241,17 @@ describe("Windows PowerShell shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); + + it("should show custom manual teardown instructions when custom dir is set", () => { + getSafeChainDirResult = "C:\\custom\\safe-chain"; + + assert.deepStrictEqual(windowsPowershell.getManualTeardownInstructions(), [ + 'Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):', + " $env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain'", + ' . "/test-home/.safe-chain/scripts/init-pwsh.ps1"', + "Then restart your terminal or run: . $PROFILE", + ]); + }); }); describe("execution policy", () => { diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js index a340424..b2c29e4 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js @@ -85,19 +85,37 @@ function getStartupFile() { } function getManualTeardownInstructions() { - return [ - `Remove the following line from your ~/.zshrc file:`, - ` source ~/.safe-chain/scripts/init-posix.sh`, - `Then restart your terminal or run: source ~/.zshrc`, - ]; + const customDir = getSafeChainDir(); + const instructions = [`Remove the following line from your ~/.zshrc file:`]; + + if (customDir) { + instructions.push( + ` export SAFE_CHAIN_DIR="${customDir}"`, + ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, + ); + } else { + instructions.push(` source ~/.safe-chain/scripts/init-posix.sh`); + } + + instructions.push(`Then restart your terminal or run: source ~/.zshrc`); + return instructions; } function getManualSetupInstructions() { - return [ - `Add the following line to your ~/.zshrc file:`, - ` source ~/.safe-chain/scripts/init-posix.sh`, - `Then restart your terminal or run: source ~/.zshrc`, - ]; + const customDir = getSafeChainDir(); + const instructions = [`Add the following line to your ~/.zshrc file:`]; + + if (customDir) { + instructions.push( + ` export SAFE_CHAIN_DIR="${customDir}"`, + ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, + ); + } else { + instructions.push(` source ~/.safe-chain/scripts/init-posix.sh`); + } + + instructions.push(`Then restart your terminal or run: source ~/.zshrc`); + return instructions; } export default { diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js index 4f1ca88..52e790f 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js @@ -206,6 +206,17 @@ describe("Zsh shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); + + it("should show custom manual teardown instructions when custom dir is set", () => { + getSafeChainDirResult = "/custom/safe-chain"; + + assert.deepStrictEqual(zsh.getManualTeardownInstructions(), [ + "Remove the following line from your ~/.zshrc file:", + ' export SAFE_CHAIN_DIR="/custom/safe-chain"', + " source /test-home/.safe-chain/scripts/init-posix.sh", + "Then restart your terminal or run: source ~/.zshrc", + ]); + }); }); describe("integration tests", () => { From 6628e1d4fd30eea169dece4479458fd6ac25295b Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 14:57:45 -0700 Subject: [PATCH 264/360] Some cleanup --- .../path-wrappers/templates/unix-wrapper.template.sh | 2 +- .../templates/windows-wrapper.template.cmd | 6 +----- .../safe-chain/src/shell-integration/setup-ci.js | 6 ++++-- .../src/shell-integration/setup-ci.spec.js | 12 ++++++++---- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh index 94ed364..5635b1a 100644 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh @@ -4,7 +4,7 @@ # Function to remove shim from PATH (POSIX-compliant) remove_shim_from_path() { - _safe_chain_shims="${SAFE_CHAIN_DIR:-$HOME/.safe-chain}/shims" + _safe_chain_shims="{{SHIMS_DIR}}" echo "$PATH" | sed "s|${_safe_chain_shims}:||g" } diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd b/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd index 959b700..89f538f 100644 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd @@ -3,11 +3,7 @@ REM Generated wrapper for {{PACKAGE_MANAGER}} by safe-chain REM This wrapper intercepts {{PACKAGE_MANAGER}} calls for non-interactive environments REM Remove shim directory from PATH to prevent infinite loops -if defined SAFE_CHAIN_DIR ( - set "SHIM_DIR=%SAFE_CHAIN_DIR%\shims" -) else ( - set "SHIM_DIR=%USERPROFILE%\.safe-chain\shims" -) +set "SHIM_DIR={{SHIMS_DIR}}" call set "CLEAN_PATH=%%PATH:%SHIM_DIR%;=%%" REM Check if aikido command is available with clean PATH diff --git a/packages/safe-chain/src/shell-integration/setup-ci.js b/packages/safe-chain/src/shell-integration/setup-ci.js index 1986bba..0dc32cf 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.js @@ -69,7 +69,8 @@ function createUnixShims(shimsDir) { for (const toolInfo of getToolsToSetup()) { const shimContent = template .replaceAll("{{PACKAGE_MANAGER}}", toolInfo.tool) - .replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand); + .replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand) + .replaceAll("{{SHIMS_DIR}}", shimsDir); const shimPath = path.join(shimsDir, toolInfo.tool); fs.writeFileSync(shimPath, shimContent, "utf-8"); @@ -108,7 +109,8 @@ function createWindowsShims(shimsDir) { for (const toolInfo of getToolsToSetup()) { const shimContent = template .replaceAll("{{PACKAGE_MANAGER}}", toolInfo.tool) - .replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand); + .replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand) + .replaceAll("{{SHIMS_DIR}}", shimsDir); const shimPath = `${shimsDir}/${toolInfo.tool}.cmd`; fs.writeFileSync(shimPath, shimContent, "utf-8"); diff --git a/packages/safe-chain/src/shell-integration/setup-ci.spec.js b/packages/safe-chain/src/shell-integration/setup-ci.spec.js index 1156173..7d092ab 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.spec.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.spec.js @@ -22,12 +22,12 @@ describe("Setup CI shell integration", () => { fs.mkdirSync(path.join(mockTemplateDir, "path-wrappers", "templates"), { recursive: true }); fs.writeFileSync( path.join(mockTemplateDir, "path-wrappers", "templates", "unix-wrapper.template.sh"), - "#!/bin/bash\n# Template for {{PACKAGE_MANAGER}}\nexec {{AIKIDO_COMMAND}} \"$@\"\n", + "#!/bin/bash\n# Template for {{PACKAGE_MANAGER}}\nSHIM_DIR=\"{{SHIMS_DIR}}\"\nexec {{AIKIDO_COMMAND}} \"$@\"\n", "utf-8" ); fs.writeFileSync( path.join(mockTemplateDir, "path-wrappers", "templates", "windows-wrapper.template.cmd"), - "@echo off\nif defined SAFE_CHAIN_DIR (\n set \"SHIM_DIR=%SAFE_CHAIN_DIR%\\shims\"\n) else (\n set \"SHIM_DIR=%USERPROFILE%\\.safe-chain\\shims\"\n)\n{{AIKIDO_COMMAND}} %*\n", + "@echo off\nset \"SHIM_DIR={{SHIMS_DIR}}\"\n{{AIKIDO_COMMAND}} %*\n", "utf-8" ); @@ -120,6 +120,10 @@ describe("Setup CI shell integration", () => { const npmShimContent = fs.readFileSync(npmShimPath, "utf-8"); assert.ok(npmShimContent.includes("aikido-npm"), "npm shim should contain aikido-npm"); assert.ok(npmShimContent.includes("#!/bin/bash"), "npm shim should have bash shebang"); + assert.ok( + npmShimContent.includes(`SHIM_DIR="${mockShimsDir}"`), + "npm shim should embed the generated shims directory", + ); }); it("should create Windows .cmd shims on win32 platform", async () => { @@ -144,8 +148,8 @@ describe("Setup CI shell integration", () => { assert.ok(npmShimContent.includes("@echo off"), "npm.cmd should have Windows batch header"); assert.ok(npmShimContent.includes("%*"), "npm.cmd should use Windows argument passing"); assert.ok( - npmShimContent.includes("if defined SAFE_CHAIN_DIR"), - "npm.cmd should honor SAFE_CHAIN_DIR when removing shim dir from PATH", + npmShimContent.includes(`set "SHIM_DIR=${mockShimsDir}"`), + "npm.cmd should embed the generated shims directory", ); // Verify Unix shims were NOT created From eb9d0bba3ef4153d45684ab3be5b446b0a21f8d1 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 15:16:33 -0700 Subject: [PATCH 265/360] Code Quality --- install-scripts/install-safe-chain.ps1 | 13 +++++++++++++ install-scripts/install-safe-chain.sh | 14 ++++++++++++++ install-scripts/uninstall-safe-chain.ps1 | 13 +++++++++++++ install-scripts/uninstall-safe-chain.sh | 14 ++++++++++++++ .../startup-scripts/init-fish.fish | 6 +++++- .../startup-scripts/init-posix.sh | 8 +++++++- .../startup-scripts/init-pwsh.ps1 | 3 ++- 7 files changed, 68 insertions(+), 3 deletions(-) diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index f95fdfd..fac897b 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -150,6 +150,19 @@ function Remove-VoltaInstallation { # Main installation function Install-SafeChain { + # Validate SAFE_CHAIN_DIR before using it to write files + if ($env:SAFE_CHAIN_DIR) { + if (-not [System.IO.Path]::IsPathRooted($env:SAFE_CHAIN_DIR)) { + Write-Error-Custom "SAFE_CHAIN_DIR must be an absolute path, got: $($env:SAFE_CHAIN_DIR)" + } + if ($env:SAFE_CHAIN_DIR -match '\.\.') { + Write-Error-Custom "SAFE_CHAIN_DIR must not contain path traversal (..)" + } + if ($env:SAFE_CHAIN_DIR -match '^[A-Za-z]:[/\\]?$' -or $env:SAFE_CHAIN_DIR -eq '/') { + Write-Error-Custom "SAFE_CHAIN_DIR cannot be a root or drive-root directory" + } + } + # Show deprecation warning if SAFE_CHAIN_VERSION is set if (-not [string]::IsNullOrWhiteSpace($env:SAFE_CHAIN_VERSION)) { Write-Warn "SAFE_CHAIN_VERSION environment variable is deprecated." diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index f65b1d7..57b06d3 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -247,6 +247,20 @@ parse_arguments() { # Main installation main() { + # Validate SAFE_CHAIN_DIR before using it to write files + if [ -n "${SAFE_CHAIN_DIR}" ]; then + case "${SAFE_CHAIN_DIR}" in + /*) ;; # absolute path — OK + *) error "SAFE_CHAIN_DIR must be an absolute path, got: ${SAFE_CHAIN_DIR}" ;; + esac + case "${SAFE_CHAIN_DIR}" in + *../*|*/..*|..) error "SAFE_CHAIN_DIR must not contain path traversal (..)" ;; + esac + if [ "${SAFE_CHAIN_DIR}" = "/" ]; then + error "SAFE_CHAIN_DIR cannot be the root directory" + fi + fi + # Initialize argument flags USE_CI_SETUP=false diff --git a/install-scripts/uninstall-safe-chain.ps1 b/install-scripts/uninstall-safe-chain.ps1 index 32a27a5..a4f1fc1 100644 --- a/install-scripts/uninstall-safe-chain.ps1 +++ b/install-scripts/uninstall-safe-chain.ps1 @@ -75,6 +75,19 @@ function Remove-VoltaInstallation { # Main uninstallation function Uninstall-SafeChain { + # Validate SAFE_CHAIN_DIR before using it to delete files + if ($env:SAFE_CHAIN_DIR) { + if (-not [System.IO.Path]::IsPathRooted($env:SAFE_CHAIN_DIR)) { + Write-Error-Custom "SAFE_CHAIN_DIR must be an absolute path, got: $($env:SAFE_CHAIN_DIR)" + } + if ($env:SAFE_CHAIN_DIR -match '\.\.') { + Write-Error-Custom "SAFE_CHAIN_DIR must not contain path traversal (..)" + } + if ($env:SAFE_CHAIN_DIR -match '^[A-Za-z]:[/\\]?$' -or $env:SAFE_CHAIN_DIR -eq '/') { + Write-Error-Custom "SAFE_CHAIN_DIR cannot be a root or drive-root directory" + } + } + Write-Info "Uninstalling safe-chain..." # Run teardown if safe-chain is available diff --git a/install-scripts/uninstall-safe-chain.sh b/install-scripts/uninstall-safe-chain.sh index fcb5153..5440730 100755 --- a/install-scripts/uninstall-safe-chain.sh +++ b/install-scripts/uninstall-safe-chain.sh @@ -139,6 +139,20 @@ remove_nvm_installation() { # Main uninstallation main() { + # Validate SAFE_CHAIN_DIR before using it to delete files + if [ -n "${SAFE_CHAIN_DIR}" ]; then + case "${SAFE_CHAIN_DIR}" in + /*) ;; # absolute path — OK + *) error "SAFE_CHAIN_DIR must be an absolute path, got: ${SAFE_CHAIN_DIR}" ;; + esac + case "${SAFE_CHAIN_DIR}" in + *../*|*/..*|..) error "SAFE_CHAIN_DIR must not contain path traversal (..)" ;; + esac + if [ "${SAFE_CHAIN_DIR}" = "/" ]; then + error "SAFE_CHAIN_DIR cannot be the root directory" + fi + fi + SAFE_CHAIN_LOCATION="$DOT_SAFE_CHAIN/bin/safe-chain" if [ -x "$SAFE_CHAIN_LOCATION" ]; then diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish index a705634..11d1d55 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish @@ -1,4 +1,8 @@ -set -l safe_chain_base (if set -q SAFE_CHAIN_DIR; echo $SAFE_CHAIN_DIR; else; echo $HOME/.safe-chain; end) +# Guard against PATH separator injection: reject SAFE_CHAIN_DIR values containing ':' +set -l safe_chain_base $HOME/.safe-chain +if set -q SAFE_CHAIN_DIR; and not string match -q '*:*' -- $SAFE_CHAIN_DIR + set safe_chain_base $SAFE_CHAIN_DIR +end set -gx PATH $PATH $safe_chain_base/bin function npx diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh index b567902..45c6fd9 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh @@ -1,4 +1,10 @@ -export PATH="$PATH:${SAFE_CHAIN_DIR:-$HOME/.safe-chain}/bin" +# Guard against PATH separator injection: reject SAFE_CHAIN_DIR values containing ':' +case "${SAFE_CHAIN_DIR}" in + *:*) _sc_base="${HOME}/.safe-chain" ;; + *) _sc_base="${SAFE_CHAIN_DIR:-${HOME}/.safe-chain}" ;; +esac +export PATH="$PATH:${_sc_base}/bin" +unset _sc_base function npx() { wrapSafeChainCommand "npx" "$@" diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 index bcdd1c6..f814917 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 @@ -2,7 +2,8 @@ # $IsWindows is only available in PowerShell Core 6.0+. If it doesn't exist, assume Windows PowerShell $isWindowsPlatform = if (Test-Path variable:IsWindows) { $IsWindows } else { $true } $pathSeparator = if ($isWindowsPlatform) { ';' } else { ':' } -$safeChainBase = if ($env:SAFE_CHAIN_DIR) { $env:SAFE_CHAIN_DIR } else { Join-Path $HOME '.safe-chain' } +# Guard against PATH separator injection: reject SAFE_CHAIN_DIR values containing the path separator +$safeChainBase = if ($env:SAFE_CHAIN_DIR -and -not $env:SAFE_CHAIN_DIR.Contains($pathSeparator)) { $env:SAFE_CHAIN_DIR } else { Join-Path $HOME '.safe-chain' } $safeChainBin = Join-Path $safeChainBase 'bin' $env:PATH = "$env:PATH$pathSeparator$safeChainBin" From d7400a0bc0beeb10fd1b63b0b105296d8fb7389f Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 15:20:37 -0700 Subject: [PATCH 266/360] Update packages/safe-chain/src/shell-integration/supported-shells/zsh.js Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> --- .../safe-chain/src/shell-integration/supported-shells/zsh.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js index b2c29e4..c9be67f 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js @@ -34,7 +34,7 @@ function teardown(tools) { ); } - // Removes the line that sources the safe-chain zsh initialization script (any path, requires safe-chain comment) + // Remove init script source line to uninstall shell integration; marker ensures only safe-chain-added lines are removed removeLinesMatchingPattern( startupFile, /^source\s+.*init-posix\.sh.*#\s*Safe-chain/, From 8cf41dc4a65ed0d8c0c325604b484513b08a1b8e Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 15:20:53 -0700 Subject: [PATCH 267/360] Update packages/safe-chain/src/shell-integration/supported-shells/bash.js Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> --- .../safe-chain/src/shell-integration/supported-shells/bash.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.js index bcf0bc6..3491bc7 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.js @@ -35,7 +35,7 @@ function teardown(tools) { ); } - // Removes the line that sources the safe-chain bash initialization script (any path, requires safe-chain comment) + // Marker comment ensures only safe-chain-added lines are removed, not user's own source statements removeLinesMatchingPattern( startupFile, /^source\s+.*init-posix\.sh.*#\s*Safe-chain/, From e5c79e5bd6e4a15f490ad802e1aa6d65e0a408d1 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 15:21:05 -0700 Subject: [PATCH 268/360] Update packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> --- .../src/shell-integration/supported-shells/windowsPowershell.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js index e3ed236..041cca7 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -33,7 +33,7 @@ function teardown(tools) { ); } - // Remove the line that sources the safe-chain PowerShell initialization script (any path, requires safe-chain comment) + // Match any installation path but require the Safe-chain marker to avoid removing unrelated user scripts removeLinesMatchingPattern( startupFile, /^\.\s+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/, From 94f77e1330769b2181029091514118d6e965bb35 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 15:25:50 -0700 Subject: [PATCH 269/360] Address more code quality issues --- install-scripts/install-safe-chain.ps1 | 25 +++++++++++----------- install-scripts/install-safe-chain.sh | 27 ++++++++++++------------ install-scripts/uninstall-safe-chain.ps1 | 25 +++++++++++----------- install-scripts/uninstall-safe-chain.sh | 26 +++++++++++------------ 4 files changed, 49 insertions(+), 54 deletions(-) diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index fac897b..2635528 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -9,6 +9,18 @@ param( $Version = $env:SAFE_CHAIN_VERSION # Will be fetched from latest release if not set $SafeChainBase = if ($env:SAFE_CHAIN_DIR) { $env:SAFE_CHAIN_DIR } else { Join-Path $env:USERPROFILE ".safe-chain" } + +# Validate $SafeChainBase before any filesystem operations +if (-not [System.IO.Path]::IsPathRooted($SafeChainBase)) { + Write-Host "[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: $SafeChainBase" -ForegroundColor Red; exit 1 +} +if ($SafeChainBase -match '\.\.') { + Write-Host "[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)" -ForegroundColor Red; exit 1 +} +if ($SafeChainBase -match '^[A-Za-z]:[/\\]?$' -or $SafeChainBase -eq '/') { + Write-Host "[ERROR] SAFE_CHAIN_DIR cannot be a root or drive-root directory" -ForegroundColor Red; exit 1 +} + $InstallDir = Join-Path $SafeChainBase "bin" $RepoUrl = "https://github.com/AikidoSec/safe-chain" @@ -150,19 +162,6 @@ function Remove-VoltaInstallation { # Main installation function Install-SafeChain { - # Validate SAFE_CHAIN_DIR before using it to write files - if ($env:SAFE_CHAIN_DIR) { - if (-not [System.IO.Path]::IsPathRooted($env:SAFE_CHAIN_DIR)) { - Write-Error-Custom "SAFE_CHAIN_DIR must be an absolute path, got: $($env:SAFE_CHAIN_DIR)" - } - if ($env:SAFE_CHAIN_DIR -match '\.\.') { - Write-Error-Custom "SAFE_CHAIN_DIR must not contain path traversal (..)" - } - if ($env:SAFE_CHAIN_DIR -match '^[A-Za-z]:[/\\]?$' -or $env:SAFE_CHAIN_DIR -eq '/') { - Write-Error-Custom "SAFE_CHAIN_DIR cannot be a root or drive-root directory" - } - } - # Show deprecation warning if SAFE_CHAIN_VERSION is set if (-not [string]::IsNullOrWhiteSpace($env:SAFE_CHAIN_VERSION)) { Write-Warn "SAFE_CHAIN_VERSION environment variable is deprecated." diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 57b06d3..e371183 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -9,6 +9,19 @@ set -e # Exit on error # Configuration VERSION="${SAFE_CHAIN_VERSION:-}" # Will be fetched from latest release if not set SAFE_CHAIN_BASE="${SAFE_CHAIN_DIR:-${HOME}/.safe-chain}" + +# Validate SAFE_CHAIN_BASE before any filesystem operations +case "${SAFE_CHAIN_BASE}" in + /*) ;; + *) printf '[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: %s\n' "${SAFE_CHAIN_BASE}" >&2; exit 1 ;; +esac +case "${SAFE_CHAIN_BASE}" in + *../*|*/..*|..) printf '[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)\n' >&2; exit 1 ;; +esac +if [ "${SAFE_CHAIN_BASE}" = "/" ]; then + printf '[ERROR] SAFE_CHAIN_DIR cannot be the root directory\n' >&2; exit 1 +fi + INSTALL_DIR="${SAFE_CHAIN_BASE}/bin" REPO_URL="https://github.com/AikidoSec/safe-chain" @@ -247,20 +260,6 @@ parse_arguments() { # Main installation main() { - # Validate SAFE_CHAIN_DIR before using it to write files - if [ -n "${SAFE_CHAIN_DIR}" ]; then - case "${SAFE_CHAIN_DIR}" in - /*) ;; # absolute path — OK - *) error "SAFE_CHAIN_DIR must be an absolute path, got: ${SAFE_CHAIN_DIR}" ;; - esac - case "${SAFE_CHAIN_DIR}" in - *../*|*/..*|..) error "SAFE_CHAIN_DIR must not contain path traversal (..)" ;; - esac - if [ "${SAFE_CHAIN_DIR}" = "/" ]; then - error "SAFE_CHAIN_DIR cannot be the root directory" - fi - fi - # Initialize argument flags USE_CI_SETUP=false diff --git a/install-scripts/uninstall-safe-chain.ps1 b/install-scripts/uninstall-safe-chain.ps1 index a4f1fc1..f342377 100644 --- a/install-scripts/uninstall-safe-chain.ps1 +++ b/install-scripts/uninstall-safe-chain.ps1 @@ -5,6 +5,18 @@ # Use HOME on Unix, USERPROFILE on Windows (PowerShell Core is cross-platform) $HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE } $DotSafeChain = if ($env:SAFE_CHAIN_DIR) { $env:SAFE_CHAIN_DIR } else { Join-Path $HomeDir ".safe-chain" } + +# Validate $DotSafeChain before any filesystem operations +if (-not [System.IO.Path]::IsPathRooted($DotSafeChain)) { + Write-Host "[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: $DotSafeChain" -ForegroundColor Red; exit 1 +} +if ($DotSafeChain -match '\.\.') { + Write-Host "[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)" -ForegroundColor Red; exit 1 +} +if ($DotSafeChain -match '^[A-Za-z]:[/\\]?$' -or $DotSafeChain -eq '/') { + Write-Host "[ERROR] SAFE_CHAIN_DIR cannot be a root or drive-root directory" -ForegroundColor Red; exit 1 +} + $InstallDir = Join-Path $DotSafeChain "bin" # Helper functions @@ -75,19 +87,6 @@ function Remove-VoltaInstallation { # Main uninstallation function Uninstall-SafeChain { - # Validate SAFE_CHAIN_DIR before using it to delete files - if ($env:SAFE_CHAIN_DIR) { - if (-not [System.IO.Path]::IsPathRooted($env:SAFE_CHAIN_DIR)) { - Write-Error-Custom "SAFE_CHAIN_DIR must be an absolute path, got: $($env:SAFE_CHAIN_DIR)" - } - if ($env:SAFE_CHAIN_DIR -match '\.\.') { - Write-Error-Custom "SAFE_CHAIN_DIR must not contain path traversal (..)" - } - if ($env:SAFE_CHAIN_DIR -match '^[A-Za-z]:[/\\]?$' -or $env:SAFE_CHAIN_DIR -eq '/') { - Write-Error-Custom "SAFE_CHAIN_DIR cannot be a root or drive-root directory" - } - } - Write-Info "Uninstalling safe-chain..." # Run teardown if safe-chain is available diff --git a/install-scripts/uninstall-safe-chain.sh b/install-scripts/uninstall-safe-chain.sh index 5440730..1cd8f9b 100755 --- a/install-scripts/uninstall-safe-chain.sh +++ b/install-scripts/uninstall-safe-chain.sh @@ -9,6 +9,18 @@ set -e # Exit on error # Configuration DOT_SAFE_CHAIN="${SAFE_CHAIN_DIR:-${HOME}/.safe-chain}" +# Validate DOT_SAFE_CHAIN before any filesystem operations +case "${DOT_SAFE_CHAIN}" in + /*) ;; + *) printf '[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: %s\n' "${DOT_SAFE_CHAIN}" >&2; exit 1 ;; +esac +case "${DOT_SAFE_CHAIN}" in + *../*|*/..*|..) printf '[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)\n' >&2; exit 1 ;; +esac +if [ "${DOT_SAFE_CHAIN}" = "/" ]; then + printf '[ERROR] SAFE_CHAIN_DIR cannot be the root directory\n' >&2; exit 1 +fi + # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' @@ -139,20 +151,6 @@ remove_nvm_installation() { # Main uninstallation main() { - # Validate SAFE_CHAIN_DIR before using it to delete files - if [ -n "${SAFE_CHAIN_DIR}" ]; then - case "${SAFE_CHAIN_DIR}" in - /*) ;; # absolute path — OK - *) error "SAFE_CHAIN_DIR must be an absolute path, got: ${SAFE_CHAIN_DIR}" ;; - esac - case "${SAFE_CHAIN_DIR}" in - *../*|*/..*|..) error "SAFE_CHAIN_DIR must not contain path traversal (..)" ;; - esac - if [ "${SAFE_CHAIN_DIR}" = "/" ]; then - error "SAFE_CHAIN_DIR cannot be the root directory" - fi - fi - SAFE_CHAIN_LOCATION="$DOT_SAFE_CHAIN/bin/safe-chain" if [ -x "$SAFE_CHAIN_LOCATION" ]; then From 98dcda78da096296268f21d3d6916931c7b9bc2f Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 15:33:30 -0700 Subject: [PATCH 270/360] Some more cleanup --- .../supported-shells/bash.js | 24 +++++---------- .../supported-shells/fish.js | 30 +++++-------------- .../supported-shells/powershell.js | 28 +++++------------ .../supported-shells/windowsPowershell.js | 28 +++++------------ .../shell-integration/supported-shells/zsh.js | 24 +++++---------- 5 files changed, 40 insertions(+), 94 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.js index 3491bc7..ff2266b 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.js @@ -141,9 +141,10 @@ function cygpathw(path) { } } -function getManualTeardownInstructions() { +/** @param {string} preamble */ +function buildManualInstructions(preamble) { const customDir = getSafeChainDir(); - const instructions = [`Remove the following line from your ~/.bashrc file:`]; + const instructions = [preamble]; if (customDir) { instructions.push( @@ -158,21 +159,12 @@ function getManualTeardownInstructions() { return instructions; } +function getManualTeardownInstructions() { + return buildManualInstructions(`Remove the following line from your ~/.bashrc file:`); +} + function getManualSetupInstructions() { - const customDir = getSafeChainDir(); - const instructions = [`Add the following line to your ~/.bashrc file:`]; - - if (customDir) { - instructions.push( - ` export SAFE_CHAIN_DIR="${customDir}"`, - ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, - ); - } else { - instructions.push(` source ~/.safe-chain/scripts/init-posix.sh`); - } - - instructions.push(`Then restart your terminal or run: source ~/.bashrc`); - return instructions; + return buildManualInstructions(`Add the following line to your ~/.bashrc file:`); } /** diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.js index 33aa48c..a6ffe1e 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.js @@ -84,11 +84,10 @@ function getStartupFile() { } } -function getManualTeardownInstructions() { +/** @param {string} preamble */ +function buildManualInstructions(preamble) { const customDir = getSafeChainDir(); - const instructions = [ - `Remove the following line from your ~/.config/fish/config.fish file:`, - ]; + const instructions = [preamble]; if (customDir) { instructions.push( @@ -105,25 +104,12 @@ function getManualTeardownInstructions() { return instructions; } +function getManualTeardownInstructions() { + return buildManualInstructions(`Remove the following line from your ~/.config/fish/config.fish file:`); +} + function getManualSetupInstructions() { - const customDir = getSafeChainDir(); - const instructions = [ - `Add the following line to your ~/.config/fish/config.fish file:`, - ]; - - if (customDir) { - instructions.push( - ` set -gx SAFE_CHAIN_DIR "${customDir}"`, - ` source ${path.join(getScriptsDir(), "init-fish.fish")}`, - ); - } else { - instructions.push(` source ~/.safe-chain/scripts/init-fish.fish`); - } - - instructions.push( - `Then restart your terminal or run: source ~/.config/fish/config.fish`, - ); - return instructions; + return buildManualInstructions(`Add the following line to your ~/.config/fish/config.fish file:`); } /** diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js index 44fbfe9..906bedd 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js @@ -87,11 +87,10 @@ function getStartupFile() { } } -function getManualTeardownInstructions() { +/** @param {string} preamble */ +function buildManualInstructions(preamble) { const customDir = getSafeChainDir(); - const instructions = [ - `Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`, - ]; + const instructions = [preamble]; if (customDir) { instructions.push( @@ -106,23 +105,12 @@ function getManualTeardownInstructions() { return instructions; } +function getManualTeardownInstructions() { + return buildManualInstructions(`Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`); +} + function getManualSetupInstructions() { - const customDir = getSafeChainDir(); - const instructions = [ - `Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`, - ]; - - if (customDir) { - instructions.push( - ` $env:SAFE_CHAIN_DIR = '${customDir}'`, - ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, - ); - } else { - instructions.push(` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`); - } - - instructions.push(`Then restart your terminal or run: . $PROFILE`); - return instructions; + return buildManualInstructions(`Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`); } /** diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js index 041cca7..e53891e 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -87,11 +87,10 @@ function getStartupFile() { } } -function getManualTeardownInstructions() { +/** @param {string} preamble */ +function buildManualInstructions(preamble) { const customDir = getSafeChainDir(); - const instructions = [ - `Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`, - ]; + const instructions = [preamble]; if (customDir) { instructions.push( @@ -106,23 +105,12 @@ function getManualTeardownInstructions() { return instructions; } +function getManualTeardownInstructions() { + return buildManualInstructions(`Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`); +} + function getManualSetupInstructions() { - const customDir = getSafeChainDir(); - const instructions = [ - `Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`, - ]; - - if (customDir) { - instructions.push( - ` $env:SAFE_CHAIN_DIR = '${customDir}'`, - ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, - ); - } else { - instructions.push(` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`); - } - - instructions.push(`Then restart your terminal or run: . $PROFILE`); - return instructions; + return buildManualInstructions(`Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`); } /** diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js index c9be67f..9b87d86 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js @@ -84,9 +84,10 @@ function getStartupFile() { } } -function getManualTeardownInstructions() { +/** @param {string} preamble */ +function buildManualInstructions(preamble) { const customDir = getSafeChainDir(); - const instructions = [`Remove the following line from your ~/.zshrc file:`]; + const instructions = [preamble]; if (customDir) { instructions.push( @@ -101,21 +102,12 @@ function getManualTeardownInstructions() { return instructions; } +function getManualTeardownInstructions() { + return buildManualInstructions(`Remove the following line from your ~/.zshrc file:`); +} + function getManualSetupInstructions() { - const customDir = getSafeChainDir(); - const instructions = [`Add the following line to your ~/.zshrc file:`]; - - if (customDir) { - instructions.push( - ` export SAFE_CHAIN_DIR="${customDir}"`, - ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, - ); - } else { - instructions.push(` source ~/.safe-chain/scripts/init-posix.sh`); - } - - instructions.push(`Then restart your terminal or run: source ~/.zshrc`); - return instructions; + return buildManualInstructions(`Add the following line to your ~/.zshrc file:`); } export default { From df8be031cb92ab175443b04846ba9cd3e7934ac7 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 15:38:51 -0700 Subject: [PATCH 271/360] Validate ENV VAR --- install-scripts/install-safe-chain.ps1 | 26 +++++++++++++----------- install-scripts/install-safe-chain.sh | 24 ++++++++++++---------- install-scripts/uninstall-safe-chain.ps1 | 26 +++++++++++++----------- install-scripts/uninstall-safe-chain.sh | 25 +++++++++++++---------- 4 files changed, 55 insertions(+), 46 deletions(-) diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index 2635528..4e77df4 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -8,19 +8,21 @@ param( ) $Version = $env:SAFE_CHAIN_VERSION # Will be fetched from latest release if not set + +# Validate SAFE_CHAIN_DIR before use +if ($env:SAFE_CHAIN_DIR) { + if (-not [System.IO.Path]::IsPathRooted($env:SAFE_CHAIN_DIR)) { + Write-Host "[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: $($env:SAFE_CHAIN_DIR)" -ForegroundColor Red; exit 1 + } + if ($env:SAFE_CHAIN_DIR -match '\.\.') { + Write-Host "[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)" -ForegroundColor Red; exit 1 + } + if ($env:SAFE_CHAIN_DIR -match '^[A-Za-z]:[/\\]?$' -or $env:SAFE_CHAIN_DIR -eq '/') { + Write-Host "[ERROR] SAFE_CHAIN_DIR cannot be a root or drive-root directory" -ForegroundColor Red; exit 1 + } +} + $SafeChainBase = if ($env:SAFE_CHAIN_DIR) { $env:SAFE_CHAIN_DIR } else { Join-Path $env:USERPROFILE ".safe-chain" } - -# Validate $SafeChainBase before any filesystem operations -if (-not [System.IO.Path]::IsPathRooted($SafeChainBase)) { - Write-Host "[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: $SafeChainBase" -ForegroundColor Red; exit 1 -} -if ($SafeChainBase -match '\.\.') { - Write-Host "[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)" -ForegroundColor Red; exit 1 -} -if ($SafeChainBase -match '^[A-Za-z]:[/\\]?$' -or $SafeChainBase -eq '/') { - Write-Host "[ERROR] SAFE_CHAIN_DIR cannot be a root or drive-root directory" -ForegroundColor Red; exit 1 -} - $InstallDir = Join-Path $SafeChainBase "bin" $RepoUrl = "https://github.com/AikidoSec/safe-chain" diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index e371183..03923d8 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -8,20 +8,22 @@ set -e # Exit on error # Configuration VERSION="${SAFE_CHAIN_VERSION:-}" # Will be fetched from latest release if not set -SAFE_CHAIN_BASE="${SAFE_CHAIN_DIR:-${HOME}/.safe-chain}" -# Validate SAFE_CHAIN_BASE before any filesystem operations -case "${SAFE_CHAIN_BASE}" in - /*) ;; - *) printf '[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: %s\n' "${SAFE_CHAIN_BASE}" >&2; exit 1 ;; -esac -case "${SAFE_CHAIN_BASE}" in - *../*|*/..*|..) printf '[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)\n' >&2; exit 1 ;; -esac -if [ "${SAFE_CHAIN_BASE}" = "/" ]; then - printf '[ERROR] SAFE_CHAIN_DIR cannot be the root directory\n' >&2; exit 1 +# Validate SAFE_CHAIN_DIR before use +if [ -n "${SAFE_CHAIN_DIR}" ]; then + case "${SAFE_CHAIN_DIR}" in + /*) ;; + *) printf '[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: %s\n' "${SAFE_CHAIN_DIR}" >&2; exit 1 ;; + esac + case "${SAFE_CHAIN_DIR}" in + *../*|*/..*|..) printf '[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)\n' >&2; exit 1 ;; + esac + if [ "${SAFE_CHAIN_DIR}" = "/" ]; then + printf '[ERROR] SAFE_CHAIN_DIR cannot be the root directory\n' >&2; exit 1 + fi fi +SAFE_CHAIN_BASE="${SAFE_CHAIN_DIR:-${HOME}/.safe-chain}" INSTALL_DIR="${SAFE_CHAIN_BASE}/bin" REPO_URL="https://github.com/AikidoSec/safe-chain" diff --git a/install-scripts/uninstall-safe-chain.ps1 b/install-scripts/uninstall-safe-chain.ps1 index f342377..785e58a 100644 --- a/install-scripts/uninstall-safe-chain.ps1 +++ b/install-scripts/uninstall-safe-chain.ps1 @@ -4,19 +4,21 @@ # Use HOME on Unix, USERPROFILE on Windows (PowerShell Core is cross-platform) $HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE } + +# Validate SAFE_CHAIN_DIR before use +if ($env:SAFE_CHAIN_DIR) { + if (-not [System.IO.Path]::IsPathRooted($env:SAFE_CHAIN_DIR)) { + Write-Host "[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: $($env:SAFE_CHAIN_DIR)" -ForegroundColor Red; exit 1 + } + if ($env:SAFE_CHAIN_DIR -match '\.\.') { + Write-Host "[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)" -ForegroundColor Red; exit 1 + } + if ($env:SAFE_CHAIN_DIR -match '^[A-Za-z]:[/\\]?$' -or $env:SAFE_CHAIN_DIR -eq '/') { + Write-Host "[ERROR] SAFE_CHAIN_DIR cannot be a root or drive-root directory" -ForegroundColor Red; exit 1 + } +} + $DotSafeChain = if ($env:SAFE_CHAIN_DIR) { $env:SAFE_CHAIN_DIR } else { Join-Path $HomeDir ".safe-chain" } - -# Validate $DotSafeChain before any filesystem operations -if (-not [System.IO.Path]::IsPathRooted($DotSafeChain)) { - Write-Host "[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: $DotSafeChain" -ForegroundColor Red; exit 1 -} -if ($DotSafeChain -match '\.\.') { - Write-Host "[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)" -ForegroundColor Red; exit 1 -} -if ($DotSafeChain -match '^[A-Za-z]:[/\\]?$' -or $DotSafeChain -eq '/') { - Write-Host "[ERROR] SAFE_CHAIN_DIR cannot be a root or drive-root directory" -ForegroundColor Red; exit 1 -} - $InstallDir = Join-Path $DotSafeChain "bin" # Helper functions diff --git a/install-scripts/uninstall-safe-chain.sh b/install-scripts/uninstall-safe-chain.sh index 1cd8f9b..abde7ca 100755 --- a/install-scripts/uninstall-safe-chain.sh +++ b/install-scripts/uninstall-safe-chain.sh @@ -7,20 +7,23 @@ set -e # Exit on error # Configuration -DOT_SAFE_CHAIN="${SAFE_CHAIN_DIR:-${HOME}/.safe-chain}" -# Validate DOT_SAFE_CHAIN before any filesystem operations -case "${DOT_SAFE_CHAIN}" in - /*) ;; - *) printf '[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: %s\n' "${DOT_SAFE_CHAIN}" >&2; exit 1 ;; -esac -case "${DOT_SAFE_CHAIN}" in - *../*|*/..*|..) printf '[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)\n' >&2; exit 1 ;; -esac -if [ "${DOT_SAFE_CHAIN}" = "/" ]; then - printf '[ERROR] SAFE_CHAIN_DIR cannot be the root directory\n' >&2; exit 1 +# Validate SAFE_CHAIN_DIR before use +if [ -n "${SAFE_CHAIN_DIR}" ]; then + case "${SAFE_CHAIN_DIR}" in + /*) ;; + *) printf '[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: %s\n' "${SAFE_CHAIN_DIR}" >&2; exit 1 ;; + esac + case "${SAFE_CHAIN_DIR}" in + *../*|*/..*|..) printf '[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)\n' >&2; exit 1 ;; + esac + if [ "${SAFE_CHAIN_DIR}" = "/" ]; then + printf '[ERROR] SAFE_CHAIN_DIR cannot be the root directory\n' >&2; exit 1 + fi fi +DOT_SAFE_CHAIN="${SAFE_CHAIN_DIR:-${HOME}/.safe-chain}" + # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' From 2ea5362b072dbc2e797875c8a5a8e22af72856ea Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 15:47:21 -0700 Subject: [PATCH 272/360] Increase timeout for tests --- test/e2e/DockerTestContainer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/DockerTestContainer.js b/test/e2e/DockerTestContainer.js index cd48c4e..4e831d3 100644 --- a/test/e2e/DockerTestContainer.js +++ b/test/e2e/DockerTestContainer.js @@ -128,7 +128,7 @@ export class DockerTestContainer { console.log("Command timeout reached"); resolve({ allData, output: parseShellOutput(allData), command }); ptyProcess.removeListener("data", handleInput); - }, 15000); + }, 30000); function handleInput(data) { allData.push(data); From 9d5503aa5431b9446243332ea2b967ab4d3b3ab2 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 10 Apr 2026 20:38:50 -0700 Subject: [PATCH 273/360] Remove Node 16 from test matrix --- .github/workflows/test-on-pr.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/test-on-pr.yml b/.github/workflows/test-on-pr.yml index e6ef9df..d7e9aab 100644 --- a/.github/workflows/test-on-pr.yml +++ b/.github/workflows/test-on-pr.yml @@ -93,11 +93,6 @@ jobs: npm_version: "latest" yarn_version: "latest" pnpm_version: "latest" - # EOL compatibility testing - Node 16 (EOL Sept 2023) - - node_version: "16" - npm_version: "8.0.0" - yarn_version: "1.22.0" - pnpm_version: "8.0.0" steps: - name: Checkout code From e3077ebd6f6dc02f6af3ab80c20a4d2a1f5308d0 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Sun, 12 Apr 2026 21:24:41 -0700 Subject: [PATCH 274/360] Update endpoint package download link to 1.2.16 --- 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 d3d5dd4..b4bf8aa 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.13/EndpointProtection.pkg" -DOWNLOAD_SHA256="ab68536dad46625aff19897e0191f3b84c8facf36e07852854bb868e46bfe28a" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.16/EndpointProtection.pkg" +DOWNLOAD_SHA256="6c185d247093533e44c1547c10e32bed899b6313b51d8bf74bcf3ddc08d8d824" 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 cfbbc76..350a7f9 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.13/EndpointProtection.msi" -$DownloadSha256 = "9005700b23c8214816642eea741a584c694d19c0eeb26deebf560092f4e5d568" +$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.16/EndpointProtection.msi" +$DownloadSha256 = "5284c7a8078a02439733b02f66158ac6a7cb09bbb9fba38ec2ff8d98b494e637" # Ensure TLS 1.2 is enabled for downloads [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 From d064d46668e2cfc16beca460842a90ddadb6a81f Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 11:01:45 -0700 Subject: [PATCH 275/360] Cleanup --- README.md | 17 ++-- install-scripts/install-safe-chain.ps1 | 46 +++++++--- install-scripts/install-safe-chain.sh | 76 ++++++++++++---- install-scripts/uninstall-safe-chain.ps1 | 76 ++++++++++++---- install-scripts/uninstall-safe-chain.sh | 89 +++++++++++++++---- packages/safe-chain/bin/safe-chain.js | 19 +++- packages/safe-chain/src/config/configFile.js | 4 +- .../src/config/environmentVariables.js | 11 --- .../src/config/environmentVariables.spec.js | 30 ------- .../safe-chain/src/config/safeChainDir.js | 10 +++ packages/safe-chain/src/installLocation.js | 39 ++++++++ .../safe-chain/src/installLocation.spec.js | 51 +++++++++++ .../safe-chain/src/registryProxy/certUtils.js | 6 +- .../src/registryProxy/certUtils.spec.js | 19 ++-- .../src/shell-integration/helpers.js | 9 +- .../src/shell-integration/helpers.spec.js | 43 +-------- .../templates/unix-wrapper.template.sh | 8 +- .../templates/windows-wrapper.template.cmd | 3 +- .../src/shell-integration/setup-ci.js | 6 +- .../startup-scripts/init-fish.fish | 7 +- .../startup-scripts/init-posix.sh | 24 +++-- .../startup-scripts/init-pwsh.ps1 | 3 +- .../supported-shells/bash.js | 23 +---- .../supported-shells/bash.spec.js | 24 ++--- .../supported-shells/fish.js | 23 +---- .../supported-shells/fish.spec.js | 24 ++--- .../supported-shells/powershell.js | 22 +---- .../supported-shells/powershell.spec.js | 24 ++--- .../supported-shells/windowsPowershell.js | 22 +---- .../windowsPowershell.spec.js | 24 ++--- .../shell-integration/supported-shells/zsh.js | 23 +---- .../supported-shells/zsh.spec.js | 24 ++--- 32 files changed, 429 insertions(+), 400 deletions(-) delete mode 100644 packages/safe-chain/src/config/environmentVariables.spec.js create mode 100644 packages/safe-chain/src/config/safeChainDir.js create mode 100644 packages/safe-chain/src/installLocation.js create mode 100644 packages/safe-chain/src/installLocation.spec.js diff --git a/README.md b/README.md index 0dc3f40..6a39aea 100644 --- a/README.md +++ b/README.md @@ -318,16 +318,21 @@ The base URL should point to a server that mirrors the structure of `https://mal ## Custom Install Directory -By default, Safe Chain installs itself into `~/.safe-chain`. You can change this by setting `SAFE_CHAIN_DIR` before running the installer. This is useful for system-wide installations (e.g. inside a Docker image) or when you need to avoid conflicts with other tools. +By default, Safe Chain installs itself into `~/.safe-chain`. You can change this by passing an explicit install directory to the installer. This is useful for system-wide installations (e.g. inside a Docker image) or when you need to avoid conflicts with other tools. When set, all Safe Chain data (binary, shims, scripts, config) is placed under the custom directory instead of `~/.safe-chain`. ```shell -export SAFE_CHAIN_DIR=/usr/local/.safe-chain -curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh +curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --install-dir /usr/local/.safe-chain ``` -This is a **one-time setting**. `safe-chain setup` automatically persists `SAFE_CHAIN_DIR` to your shell rc files (e.g. `~/.bashrc`, `~/.zshrc`) so that subsequent `safe-chain` commands (including teardown and re-setup) find the correct directory without needing the variable set again. +On Windows, use `-InstallDir`: + +```powershell +iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -InstallDir 'C:\ProgramData\safe-chain'" +``` + +This is a one-time installer choice. Runtime shell integration and uninstall now discover the installation from the installed scripts or binary and do not rely on an environment variable. # Usage in CI/CD @@ -419,7 +424,7 @@ pipeline { environment { // Jenkins does not automatically persist PATH updates from setup-ci, // so add the shims + binary directory explicitly for all stages. - // If you set SAFE_CHAIN_DIR, replace ~/.safe-chain with that path here. + // If you installed into a custom directory, replace ~/.safe-chain with that path here. PATH = "${env.HOME}/.safe-chain/shims:${env.HOME}/.safe-chain/bin:${env.PATH}" } @@ -475,7 +480,7 @@ To add safe-chain in GitLab pipelines, you need to install it in the image runni # Install safe-chain RUN curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci - # Add safe-chain to PATH (update paths if you set SAFE_CHAIN_DIR during install) + # Add safe-chain to PATH (update paths if you used a custom install dir) ENV PATH="/root/.safe-chain/shims:/root/.safe-chain/bin:${PATH}" ``` diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index 4e77df4..ec0dcd6 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -4,25 +4,49 @@ param( [switch]$ci, - [switch]$includepython + [switch]$includepython, + [string]$InstallDir ) -$Version = $env:SAFE_CHAIN_VERSION # Will be fetched from latest release if not set +function Test-InstallDir { + param([string]$Dir) -# Validate SAFE_CHAIN_DIR before use -if ($env:SAFE_CHAIN_DIR) { - if (-not [System.IO.Path]::IsPathRooted($env:SAFE_CHAIN_DIR)) { - Write-Host "[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: $($env:SAFE_CHAIN_DIR)" -ForegroundColor Red; exit 1 + if ([string]::IsNullOrWhiteSpace($Dir)) { + return @{ Ok = $true; Normalized = $null } } - if ($env:SAFE_CHAIN_DIR -match '\.\.') { - Write-Host "[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)" -ForegroundColor Red; exit 1 + + if (-not [System.IO.Path]::IsPathRooted($Dir)) { + return @{ Ok = $false; Reason = "-InstallDir must be an absolute path, got: $Dir" } } - if ($env:SAFE_CHAIN_DIR -match '^[A-Za-z]:[/\\]?$' -or $env:SAFE_CHAIN_DIR -eq '/') { - Write-Host "[ERROR] SAFE_CHAIN_DIR cannot be a root or drive-root directory" -ForegroundColor Red; exit 1 + + if ($Dir.Contains([System.IO.Path]::PathSeparator)) { + return @{ Ok = $false; Reason = "-InstallDir must not contain the PATH separator ($([System.IO.Path]::PathSeparator))" } } + + $normalized = [System.IO.Path]::GetFullPath($Dir) + $root = [System.IO.Path]::GetPathRoot($normalized) + if ($normalized.TrimEnd('\', '/') -eq $root.TrimEnd('\', '/')) { + return @{ Ok = $false; Reason = "-InstallDir cannot be a root or drive-root directory" } + } + + $segments = $normalized.Substring($root.Length).Split([char[]]@('\', '/'), [System.StringSplitOptions]::RemoveEmptyEntries) + if ($segments -contains "..") { + return @{ Ok = $false; Reason = "-InstallDir must not contain path traversal segments" } + } + + return @{ Ok = $true; Normalized = $normalized } } -$SafeChainBase = if ($env:SAFE_CHAIN_DIR) { $env:SAFE_CHAIN_DIR } else { Join-Path $env:USERPROFILE ".safe-chain" } +$Version = $env:SAFE_CHAIN_VERSION # Will be fetched from latest release if not set +$SafeChainBase = if ($InstallDir) { $InstallDir } else { Join-Path $env:USERPROFILE ".safe-chain" } + +$installDirValidation = Test-InstallDir -Dir $SafeChainBase +if (-not $installDirValidation.Ok) { + Write-Host "[ERROR] $($installDirValidation.Reason)" -ForegroundColor Red + exit 1 +} + +$SafeChainBase = $installDirValidation.Normalized $InstallDir = Join-Path $SafeChainBase "bin" $RepoUrl = "https://github.com/AikidoSec/safe-chain" diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 03923d8..6a586e7 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -6,24 +6,50 @@ set -e # Exit on error +validate_install_dir() { + dir="$1" + + if [ -z "$dir" ]; then + return 0 + fi + + case "$dir" in + /*) ;; + *) + printf '[ERROR] --install-dir must be an absolute path, got: %s\n' "$dir" >&2 + exit 1 + ;; + esac + + case "$dir" in + *:*) + printf '[ERROR] --install-dir must not contain the PATH separator (:)\n' >&2 + exit 1 + ;; + esac + + if [ "$dir" = "/" ]; then + printf '[ERROR] --install-dir cannot be a root or drive-root directory\n' >&2 + exit 1 + fi + + old_ifs=$IFS + IFS='/' + set -- $dir + IFS=$old_ifs + + for segment in "$@"; do + if [ "$segment" = ".." ]; then + printf '[ERROR] --install-dir must not contain path traversal segments\n' >&2 + exit 1 + fi + done +} + # Configuration VERSION="${SAFE_CHAIN_VERSION:-}" # Will be fetched from latest release if not set +SAFE_CHAIN_BASE="${HOME}/.safe-chain" -# Validate SAFE_CHAIN_DIR before use -if [ -n "${SAFE_CHAIN_DIR}" ]; then - case "${SAFE_CHAIN_DIR}" in - /*) ;; - *) printf '[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: %s\n' "${SAFE_CHAIN_DIR}" >&2; exit 1 ;; - esac - case "${SAFE_CHAIN_DIR}" in - *../*|*/..*|..) printf '[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)\n' >&2; exit 1 ;; - esac - if [ "${SAFE_CHAIN_DIR}" = "/" ]; then - printf '[ERROR] SAFE_CHAIN_DIR cannot be the root directory\n' >&2; exit 1 - fi -fi - -SAFE_CHAIN_BASE="${SAFE_CHAIN_DIR:-${HOME}/.safe-chain}" INSTALL_DIR="${SAFE_CHAIN_BASE}/bin" REPO_URL="https://github.com/AikidoSec/safe-chain" @@ -245,19 +271,33 @@ remove_nvm_installation() { # Parse command-line arguments parse_arguments() { - for arg in "$@"; do - case "$arg" in + while [ $# -gt 0 ]; do + case "$1" in --ci) USE_CI_SETUP=true ;; + --install-dir) + shift + if [ $# -eq 0 ]; then + error "Missing value for --install-dir" + fi + SAFE_CHAIN_BASE="$1" + ;; + --install-dir=*) + SAFE_CHAIN_BASE="${1#--install-dir=}" + ;; --include-python) warn "--include-python is deprecated and ignored. Python ecosystem is now included by default." ;; *) - error "Unknown argument: $arg" + error "Unknown argument: $1" ;; esac + shift done + + validate_install_dir "${SAFE_CHAIN_BASE}" + INSTALL_DIR="${SAFE_CHAIN_BASE}/bin" } # Main installation diff --git a/install-scripts/uninstall-safe-chain.ps1 b/install-scripts/uninstall-safe-chain.ps1 index 785e58a..2aa3798 100644 --- a/install-scripts/uninstall-safe-chain.ps1 +++ b/install-scripts/uninstall-safe-chain.ps1 @@ -5,22 +5,6 @@ # Use HOME on Unix, USERPROFILE on Windows (PowerShell Core is cross-platform) $HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE } -# Validate SAFE_CHAIN_DIR before use -if ($env:SAFE_CHAIN_DIR) { - if (-not [System.IO.Path]::IsPathRooted($env:SAFE_CHAIN_DIR)) { - Write-Host "[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: $($env:SAFE_CHAIN_DIR)" -ForegroundColor Red; exit 1 - } - if ($env:SAFE_CHAIN_DIR -match '\.\.') { - Write-Host "[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)" -ForegroundColor Red; exit 1 - } - if ($env:SAFE_CHAIN_DIR -match '^[A-Za-z]:[/\\]?$' -or $env:SAFE_CHAIN_DIR -eq '/') { - Write-Host "[ERROR] SAFE_CHAIN_DIR cannot be a root or drive-root directory" -ForegroundColor Red; exit 1 - } -} - -$DotSafeChain = if ($env:SAFE_CHAIN_DIR) { $env:SAFE_CHAIN_DIR } else { Join-Path $HomeDir ".safe-chain" } -$InstallDir = Join-Path $DotSafeChain "bin" - # Helper functions function Write-Info { param([string]$Message) @@ -38,6 +22,64 @@ function Write-Error-Custom { exit 1 } +function Get-InstallDirFromBinaryPath { + param([string]$BinaryPath) + + if ([string]::IsNullOrWhiteSpace($BinaryPath)) { + return $null + } + + try { + $resolvedPath = (Resolve-Path -LiteralPath $BinaryPath -ErrorAction Stop).Path + } + catch { + $resolvedPath = [System.IO.Path]::GetFullPath($BinaryPath) + } + + $fileName = [System.IO.Path]::GetFileName($resolvedPath) + if (($fileName -ne "safe-chain") -and ($fileName -ne "safe-chain.exe")) { + return $null + } + + if ($resolvedPath -match '\.(js|cjs|mjs|cmd|ps1)$') { + return $null + } + + $binDir = Split-Path -Parent $resolvedPath + if ((Split-Path -Leaf $binDir) -ne "bin") { + return $null + } + + return (Split-Path -Parent $binDir) +} + +function Get-SafeChainInstallDir { + $command = Get-Command safe-chain -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($command) { + try { + $reportedInstallDir = & safe-chain get-install-dir 2>$null | Select-Object -First 1 + if ($reportedInstallDir) { + $reportedInstallDir = $reportedInstallDir.Trim() + } + if ($reportedInstallDir) { + return $reportedInstallDir + } + } + catch { + # Fall back to deriving the install dir from the discovered command path + } + } + + if ($command -and $command.Path) { + $discoveredInstallDir = Get-InstallDirFromBinaryPath -BinaryPath $command.Path + if ($discoveredInstallDir) { + return $discoveredInstallDir + } + } + + return (Join-Path $HomeDir ".safe-chain") +} + # Check and uninstall npm global package if present function Remove-NpmInstallation { # Check if npm is available @@ -90,6 +132,8 @@ function Remove-VoltaInstallation { # Main uninstallation function Uninstall-SafeChain { Write-Info "Uninstalling safe-chain..." + $DotSafeChain = Get-SafeChainInstallDir + $InstallDir = Join-Path $DotSafeChain "bin" # Run teardown if safe-chain is available # Check for both safe-chain.exe (Windows) and safe-chain (Unix) since PowerShell Core runs on all platforms diff --git a/install-scripts/uninstall-safe-chain.sh b/install-scripts/uninstall-safe-chain.sh index abde7ca..4169e1e 100755 --- a/install-scripts/uninstall-safe-chain.sh +++ b/install-scripts/uninstall-safe-chain.sh @@ -8,22 +8,6 @@ set -e # Exit on error # Configuration -# Validate SAFE_CHAIN_DIR before use -if [ -n "${SAFE_CHAIN_DIR}" ]; then - case "${SAFE_CHAIN_DIR}" in - /*) ;; - *) printf '[ERROR] SAFE_CHAIN_DIR must be an absolute path, got: %s\n' "${SAFE_CHAIN_DIR}" >&2; exit 1 ;; - esac - case "${SAFE_CHAIN_DIR}" in - *../*|*/..*|..) printf '[ERROR] SAFE_CHAIN_DIR must not contain path traversal (..)\n' >&2; exit 1 ;; - esac - if [ "${SAFE_CHAIN_DIR}" = "/" ]; then - printf '[ERROR] SAFE_CHAIN_DIR cannot be the root directory\n' >&2; exit 1 - fi -fi - -DOT_SAFE_CHAIN="${SAFE_CHAIN_DIR:-${HOME}/.safe-chain}" - # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' @@ -49,6 +33,78 @@ command_exists() { command -v "$1" >/dev/null 2>&1 } +resolve_path() { + target="$1" + + while [ -L "$target" ]; do + link_target=$(readlink "$target" 2>/dev/null || echo "") + if [ -z "$link_target" ]; then + break + fi + + case "$link_target" in + /*) target="$link_target" ;; + *) + target="$(dirname "$target")/$link_target" + ;; + esac + done + + target_dir=$(dirname "$target") + target_name=$(basename "$target") + + if cd "$target_dir" 2>/dev/null; then + printf '%s/%s\n' "$(pwd -P)" "$target_name" + else + printf '%s\n' "$target" + fi +} + +derive_install_dir_from_binary() { + binary_path="$1" + + if [ -z "$binary_path" ]; then + return 1 + fi + + resolved_path=$(resolve_path "$binary_path") + binary_name=$(basename "$resolved_path") + case "$binary_name" in + safe-chain|safe-chain.exe) ;; + *) return 1 ;; + esac + + case "$resolved_path" in + *.js|*.cjs|*.mjs|*.cmd|*.ps1) return 1 ;; + esac + + binary_dir=$(dirname "$resolved_path") + if [ "$(basename "$binary_dir")" != "bin" ]; then + return 1 + fi + + dirname "$binary_dir" +} + +get_install_dir() { + if command_exists safe-chain; then + install_dir=$(safe-chain get-install-dir 2>/dev/null || true) + if [ -n "$install_dir" ]; then + printf '%s\n' "$install_dir" + return 0 + fi + + command_path=$(command -v safe-chain) + install_dir=$(derive_install_dir_from_binary "$command_path" || true) + if [ -n "$install_dir" ]; then + printf '%s\n' "$install_dir" + return 0 + fi + fi + + printf '%s\n' "${HOME}/.safe-chain" +} + # Check and uninstall npm global package if present remove_npm_installation() { if ! command_exists npm; then @@ -154,6 +210,7 @@ remove_nvm_installation() { # Main uninstallation main() { + DOT_SAFE_CHAIN=$(get_install_dir) SAFE_CHAIN_LOCATION="$DOT_SAFE_CHAIN/bin/safe-chain" if [ -x "$SAFE_CHAIN_LOCATION" ]; then diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index 8d942e4..43819b9 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -16,6 +16,7 @@ import path from "path"; import { fileURLToPath } from "url"; import fs from "fs"; import { knownAikidoTools } from "../src/shell-integration/helpers.js"; +import { getInstalledSafeChainDir } from "../src/installLocation.js"; /** @type {string} */ // This checks the current file's dirname in a way that's compatible with: @@ -67,6 +68,17 @@ if (tool) { teardownDirectories(); } else if (command === "setup-ci") { setupCi(); +} else if (command === "get-install-dir") { + const installDir = getInstalledSafeChainDir(); + if (!installDir) { + ui.writeError( + "Install directory is only available for packaged safe-chain binaries.", + ); + process.exit(1); + } + + ui.writeInformation(installDir); + process.exit(0); } else if (command === "--version" || command === "-v" || command === "-v") { (async () => { ui.writeInformation(`Current safe-chain version: ${await getVersion()}`); @@ -88,7 +100,7 @@ function writeHelp() { ui.writeInformation( `Available commands: ${chalk.cyan("setup")}, ${chalk.cyan( "teardown", - )}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("help")}, ${chalk.cyan( + )}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("get-install-dir")}, ${chalk.cyan("help")}, ${chalk.cyan( "--version", )}`, ); @@ -108,6 +120,11 @@ function writeHelp() { "safe-chain setup-ci", )}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.`, ); + ui.writeInformation( + `- ${chalk.cyan( + "safe-chain get-install-dir", + )}: Print the install directory for packaged safe-chain binaries.`, + ); ui.writeInformation( `- ${chalk.cyan("safe-chain --version")} (or ${chalk.cyan( "-v", diff --git a/packages/safe-chain/src/config/configFile.js b/packages/safe-chain/src/config/configFile.js index 1b978ea..d340130 100644 --- a/packages/safe-chain/src/config/configFile.js +++ b/packages/safe-chain/src/config/configFile.js @@ -3,7 +3,7 @@ import path from "path"; import os from "os"; import { ui } from "../environment/userInteraction.js"; import { getEcoSystem } from "./settings.js"; -import { getSafeChainDir } from "./environmentVariables.js"; +import { getSafeChainBaseDir } from "./safeChainDir.js"; /** * @typedef {Object} SafeChainConfig @@ -305,7 +305,7 @@ function getConfigFilePath() { * @returns {string} */ export function getSafeChainDirectory() { - const safeChainDir = getSafeChainDir() ?? path.join(os.homedir(), ".safe-chain"); + const safeChainDir = getSafeChainBaseDir(); if (!fs.existsSync(safeChainDir)) { fs.mkdirSync(safeChainDir, { recursive: true }); diff --git a/packages/safe-chain/src/config/environmentVariables.js b/packages/safe-chain/src/config/environmentVariables.js index b76a413..932eff7 100644 --- a/packages/safe-chain/src/config/environmentVariables.js +++ b/packages/safe-chain/src/config/environmentVariables.js @@ -55,14 +55,3 @@ export function getMinimumPackageAgeExclusions() { export function getMalwareListBaseUrl() { return process.env.SAFE_CHAIN_MALWARE_LIST_BASE_URL; } - -/** - * Gets the safe-chain base directory from environment variable. - * When set, all safe-chain data (bin, shims, scripts) will be placed under this directory - * instead of the default ~/.safe-chain, enabling system-wide installations. - * Example: "/usr/local/.safe-chain" - * @returns {string | undefined} - */ -export function getSafeChainDir() { - return process.env.SAFE_CHAIN_DIR; -} diff --git a/packages/safe-chain/src/config/environmentVariables.spec.js b/packages/safe-chain/src/config/environmentVariables.spec.js deleted file mode 100644 index 2cbdd0f..0000000 --- a/packages/safe-chain/src/config/environmentVariables.spec.js +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, it, beforeEach, afterEach } from "node:test"; -import assert from "node:assert"; - -const { getSafeChainDir } = await import("./environmentVariables.js"); - -describe("getSafeChainDir", () => { - let original; - - beforeEach(() => { - original = process.env.SAFE_CHAIN_DIR; - }); - - afterEach(() => { - if (original !== undefined) { - process.env.SAFE_CHAIN_DIR = original; - } else { - delete process.env.SAFE_CHAIN_DIR; - } - }); - - it("returns undefined when SAFE_CHAIN_DIR is not set", () => { - delete process.env.SAFE_CHAIN_DIR; - assert.strictEqual(getSafeChainDir(), undefined); - }); - - it("returns the value of SAFE_CHAIN_DIR when set", () => { - process.env.SAFE_CHAIN_DIR = "/usr/local/.safe-chain"; - assert.strictEqual(getSafeChainDir(), "/usr/local/.safe-chain"); - }); -}); diff --git a/packages/safe-chain/src/config/safeChainDir.js b/packages/safe-chain/src/config/safeChainDir.js new file mode 100644 index 0000000..595300a --- /dev/null +++ b/packages/safe-chain/src/config/safeChainDir.js @@ -0,0 +1,10 @@ +import os from "os"; +import path from "path"; +import { getInstalledSafeChainDir } from "../installLocation.js"; + +/** + * @returns {string} + */ +export function getSafeChainBaseDir() { + return getInstalledSafeChainDir() ?? path.join(os.homedir(), ".safe-chain"); +} diff --git a/packages/safe-chain/src/installLocation.js b/packages/safe-chain/src/installLocation.js new file mode 100644 index 0000000..efe687a --- /dev/null +++ b/packages/safe-chain/src/installLocation.js @@ -0,0 +1,39 @@ +import path from "path"; + +/** + * @param {string} executablePath + * @returns {string | undefined} + */ +export function deriveInstallDirFromExecutablePath(executablePath) { + if (!executablePath) { + return undefined; + } + + const pathLibrary = executablePath.includes("\\") ? path.win32 : path.posix; + const executableDir = pathLibrary.dirname(executablePath); + if (pathLibrary.basename(executableDir) !== "bin") { + return undefined; + } + + return pathLibrary.dirname(executableDir); +} + +/** + * Returns the install directory for a packaged safe-chain binary. + * Custom installation directories only apply to packaged binary installs. + * For npm/global/dev-script executions this intentionally returns undefined, + * which causes callers to fall back to the default ~/.safe-chain layout. + * + * @param {{ isPackaged?: boolean, executablePath?: string }} [options] + * @returns {string | undefined} + */ +export function getInstalledSafeChainDir(options = {}) { + const isPackaged = options.isPackaged ?? Boolean(process.pkg); + if (!isPackaged) { + return undefined; + } + + return deriveInstallDirFromExecutablePath( + options.executablePath ?? process.execPath, + ); +} diff --git a/packages/safe-chain/src/installLocation.spec.js b/packages/safe-chain/src/installLocation.spec.js new file mode 100644 index 0000000..558a05f --- /dev/null +++ b/packages/safe-chain/src/installLocation.spec.js @@ -0,0 +1,51 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { + deriveInstallDirFromExecutablePath, + getInstalledSafeChainDir, +} from "./installLocation.js"; + +describe("deriveInstallDirFromExecutablePath", () => { + it("derives the install dir from a Unix binary path", () => { + assert.strictEqual( + deriveInstallDirFromExecutablePath("/usr/local/.safe-chain/bin/safe-chain"), + "/usr/local/.safe-chain", + ); + }); + + it("derives the install dir from a Windows binary path", () => { + assert.strictEqual( + deriveInstallDirFromExecutablePath("C:\\ProgramData\\safe-chain\\bin\\safe-chain.exe"), + "C:\\ProgramData\\safe-chain", + ); + }); + + it("returns undefined when the executable is not inside a bin directory", () => { + assert.strictEqual( + deriveInstallDirFromExecutablePath("/usr/local/.safe-chain/safe-chain"), + undefined, + ); + }); +}); + +describe("getInstalledSafeChainDir", () => { + it("returns undefined for non-packaged executions", () => { + assert.strictEqual( + getInstalledSafeChainDir({ + isPackaged: false, + executablePath: "/usr/local/.safe-chain/bin/safe-chain", + }), + undefined, + ); + }); + + it("returns the install dir for packaged executions", () => { + assert.strictEqual( + getInstalledSafeChainDir({ + isPackaged: true, + executablePath: "/usr/local/.safe-chain/bin/safe-chain", + }), + "/usr/local/.safe-chain", + ); + }); +}); diff --git a/packages/safe-chain/src/registryProxy/certUtils.js b/packages/safe-chain/src/registryProxy/certUtils.js index a4bc0b1..50fad7b 100644 --- a/packages/safe-chain/src/registryProxy/certUtils.js +++ b/packages/safe-chain/src/registryProxy/certUtils.js @@ -1,16 +1,14 @@ import forge from "node-forge"; import path from "path"; import fs from "fs"; -import os from "os"; -import { getSafeChainDir } from "../config/environmentVariables.js"; +import { getSafeChainBaseDir } from "../config/safeChainDir.js"; const ca = loadCa(); const certCache = new Map(); function getCertFolder() { - const safeChainDir = getSafeChainDir() ?? path.join(os.homedir(), ".safe-chain"); - return path.join(safeChainDir, "certs"); + return path.join(getSafeChainBaseDir(), "certs"); } /** diff --git a/packages/safe-chain/src/registryProxy/certUtils.spec.js b/packages/safe-chain/src/registryProxy/certUtils.spec.js index ebf8dab..c715c8c 100644 --- a/packages/safe-chain/src/registryProxy/certUtils.spec.js +++ b/packages/safe-chain/src/registryProxy/certUtils.spec.js @@ -2,24 +2,23 @@ import { describe, it, beforeEach, afterEach, mock } from "node:test"; import assert from "node:assert"; describe("certUtils", () => { - let originalSafeChainDir; + let installedSafeChainDir; beforeEach(() => { - originalSafeChainDir = process.env.SAFE_CHAIN_DIR; + installedSafeChainDir = undefined; + mock.module("../config/safeChainDir.js", { + namedExports: { + getSafeChainBaseDir: () => installedSafeChainDir ?? "/home/test/.safe-chain", + }, + }); }); afterEach(() => { - if (originalSafeChainDir === undefined) { - delete process.env.SAFE_CHAIN_DIR; - } else { - process.env.SAFE_CHAIN_DIR = originalSafeChainDir; - } - mock.reset(); }); - it("stores CA certificates in SAFE_CHAIN_DIR when configured", async () => { - process.env.SAFE_CHAIN_DIR = "/custom/safe-chain"; + it("stores CA certificates in the packaged install dir when available", async () => { + installedSafeChainDir = "/custom/safe-chain"; mock.module("fs", { defaultExport: { diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index 2d66d1d..3dd73aa 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -3,8 +3,7 @@ import * as os from "os"; import fs from "fs"; import path from "path"; import { ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; -import { getSafeChainDir } from "../config/environmentVariables.js"; -export { getSafeChainDir }; +import { getSafeChainBaseDir } from "../config/safeChainDir.js"; import { safeSpawn } from "../utils/safeSpawn.js"; import { ui } from "../environment/userInteraction.js"; @@ -125,12 +124,10 @@ export function getPackageManagerList() { /** * Returns the safe-chain base directory. - * Uses SAFE_CHAIN_DIR environment variable when set, otherwise defaults to ~/.safe-chain. + * Uses the packaged binary location when available, otherwise defaults to ~/.safe-chain. * @returns {string} */ -export function getSafeChainBaseDir() { - return getSafeChainDir() ?? path.join(os.homedir(), ".safe-chain"); -} +export { getSafeChainBaseDir }; /** * @returns {string} diff --git a/packages/safe-chain/src/shell-integration/helpers.spec.js b/packages/safe-chain/src/shell-integration/helpers.spec.js index 8fd172b..8870451 100644 --- a/packages/safe-chain/src/shell-integration/helpers.spec.js +++ b/packages/safe-chain/src/shell-integration/helpers.spec.js @@ -185,64 +185,23 @@ describe("removeLinesMatchingPatternTests", () => { }); describe("getSafeChainBaseDir / getBinDir / getShimsDir / getScriptsDir", () => { - const customDir = "/usr/local/.safe-chain"; - - let originalSafeChainDir; - - beforeEach(() => { - originalSafeChainDir = process.env.SAFE_CHAIN_DIR; - delete process.env.SAFE_CHAIN_DIR; - }); - - afterEach(() => { - if (originalSafeChainDir !== undefined) { - process.env.SAFE_CHAIN_DIR = originalSafeChainDir; - } else { - delete process.env.SAFE_CHAIN_DIR; - } - }); - - it("defaults base dir to ~/.safe-chain when SAFE_CHAIN_DIR is not set", async () => { + it("defaults base dir to ~/.safe-chain when no packaged install dir is available", async () => { const { getSafeChainBaseDir } = await import("./helpers.js"); assert.strictEqual(getSafeChainBaseDir(), path.join(homedir(), ".safe-chain")); }); - it("uses SAFE_CHAIN_DIR as base dir when set", async () => { - process.env.SAFE_CHAIN_DIR = customDir; - const { getSafeChainBaseDir } = await import("./helpers.js"); - assert.strictEqual(getSafeChainBaseDir(), customDir); - }); - it("getBinDir returns ~/.safe-chain/bin by default", async () => { const { getBinDir } = await import("./helpers.js"); assert.strictEqual(getBinDir(), path.join(homedir(), ".safe-chain", "bin")); }); - it("getBinDir returns custom dir + /bin when SAFE_CHAIN_DIR is set", async () => { - process.env.SAFE_CHAIN_DIR = customDir; - const { getBinDir } = await import("./helpers.js"); - assert.strictEqual(getBinDir(), `${customDir}/bin`); - }); - it("getShimsDir returns ~/.safe-chain/shims by default", async () => { const { getShimsDir } = await import("./helpers.js"); assert.strictEqual(getShimsDir(), path.join(homedir(), ".safe-chain", "shims")); }); - it("getShimsDir returns custom dir + /shims when SAFE_CHAIN_DIR is set", async () => { - process.env.SAFE_CHAIN_DIR = customDir; - const { getShimsDir } = await import("./helpers.js"); - assert.strictEqual(getShimsDir(), `${customDir}/shims`); - }); - it("getScriptsDir returns ~/.safe-chain/scripts by default", async () => { const { getScriptsDir } = await import("./helpers.js"); assert.strictEqual(getScriptsDir(), path.join(homedir(), ".safe-chain", "scripts")); }); - - it("getScriptsDir returns custom dir + /scripts when SAFE_CHAIN_DIR is set", async () => { - process.env.SAFE_CHAIN_DIR = customDir; - const { getScriptsDir } = await import("./helpers.js"); - assert.strictEqual(getScriptsDir(), `${customDir}/scripts`); - }); }); diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh index 5635b1a..9275230 100644 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh @@ -4,7 +4,7 @@ # Function to remove shim from PATH (POSIX-compliant) remove_shim_from_path() { - _safe_chain_shims="{{SHIMS_DIR}}" + _safe_chain_shims=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd -P) echo "$PATH" | sed "s|${_safe_chain_shims}:||g" } @@ -13,11 +13,7 @@ if command -v safe-chain >/dev/null 2>&1; then PATH=$(remove_shim_from_path) exec safe-chain {{PACKAGE_MANAGER}} "$@" else # safe-chain is not reachable — warn the user so they know protection is inactive - if [ -n "$SAFE_CHAIN_DIR" ]; then - printf "\033[43;30mWarning:\033[0m safe-chain is not accessible. Check that '%s/bin' is readable and executable by the current user.\n" "$SAFE_CHAIN_DIR" >&2 - else - printf "\033[43;30mWarning:\033[0m safe-chain is not available to protect you from installing malware. {{PACKAGE_MANAGER}} will run without it.\n" >&2 - fi + printf "\033[43;30mWarning:\033[0m safe-chain is not available to protect you from installing malware. {{PACKAGE_MANAGER}} will run without it.\n" >&2 # Dynamically find original {{PACKAGE_MANAGER}} (excluding this shim directory) original_cmd=$(PATH=$(remove_shim_from_path) command -v {{PACKAGE_MANAGER}}) diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd b/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd index 89f538f..b41fcfb 100644 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd @@ -3,7 +3,8 @@ REM Generated wrapper for {{PACKAGE_MANAGER}} by safe-chain REM This wrapper intercepts {{PACKAGE_MANAGER}} calls for non-interactive environments REM Remove shim directory from PATH to prevent infinite loops -set "SHIM_DIR={{SHIMS_DIR}}" +set "SHIM_DIR=%~dp0" +if "%SHIM_DIR:~-1%"=="\" set "SHIM_DIR=%SHIM_DIR:~0,-1%" call set "CLEAN_PATH=%%PATH:%SHIM_DIR%;=%%" REM Check if aikido command is available with clean PATH diff --git a/packages/safe-chain/src/shell-integration/setup-ci.js b/packages/safe-chain/src/shell-integration/setup-ci.js index 0dc32cf..1986bba 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.js @@ -69,8 +69,7 @@ function createUnixShims(shimsDir) { for (const toolInfo of getToolsToSetup()) { const shimContent = template .replaceAll("{{PACKAGE_MANAGER}}", toolInfo.tool) - .replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand) - .replaceAll("{{SHIMS_DIR}}", shimsDir); + .replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand); const shimPath = path.join(shimsDir, toolInfo.tool); fs.writeFileSync(shimPath, shimContent, "utf-8"); @@ -109,8 +108,7 @@ function createWindowsShims(shimsDir) { for (const toolInfo of getToolsToSetup()) { const shimContent = template .replaceAll("{{PACKAGE_MANAGER}}", toolInfo.tool) - .replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand) - .replaceAll("{{SHIMS_DIR}}", shimsDir); + .replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand); const shimPath = `${shimsDir}/${toolInfo.tool}.cmd`; fs.writeFileSync(shimPath, shimContent, "utf-8"); diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish index 11d1d55..e0cc9ec 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish @@ -1,8 +1,5 @@ -# Guard against PATH separator injection: reject SAFE_CHAIN_DIR values containing ':' -set -l safe_chain_base $HOME/.safe-chain -if set -q SAFE_CHAIN_DIR; and not string match -q '*:*' -- $SAFE_CHAIN_DIR - set safe_chain_base $SAFE_CHAIN_DIR -end +set -l safe_chain_script (status filename) +set -l safe_chain_base (path dirname (path dirname $safe_chain_script)) set -gx PATH $PATH $safe_chain_base/bin function npx diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh index 45c6fd9..4235276 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh @@ -1,10 +1,22 @@ -# Guard against PATH separator injection: reject SAFE_CHAIN_DIR values containing ':' -case "${SAFE_CHAIN_DIR}" in - *:*) _sc_base="${HOME}/.safe-chain" ;; - *) _sc_base="${SAFE_CHAIN_DIR:-${HOME}/.safe-chain}" ;; -esac +_get_safe_chain_script_path() { + if [ -n "${BASH_SOURCE[0]:-}" ]; then + printf '%s\n' "${BASH_SOURCE[0]}" + return + fi + + if [ -n "${ZSH_VERSION:-}" ]; then + eval 'printf "%s\n" "${(%):-%N}"' + return + fi + + printf '%s\n' "$0" +} + +_sc_script_path="$(_get_safe_chain_script_path)" +_sc_scripts_dir=$(CDPATH= cd -- "$(dirname -- "$_sc_script_path")" 2>/dev/null && pwd -P) +_sc_base=$(dirname -- "$_sc_scripts_dir") export PATH="$PATH:${_sc_base}/bin" -unset _sc_base +unset _sc_base _sc_script_path _sc_scripts_dir function npx() { wrapSafeChainCommand "npx" "$@" diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 index f814917..167e5d8 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 @@ -2,8 +2,7 @@ # $IsWindows is only available in PowerShell Core 6.0+. If it doesn't exist, assume Windows PowerShell $isWindowsPlatform = if (Test-Path variable:IsWindows) { $IsWindows } else { $true } $pathSeparator = if ($isWindowsPlatform) { ';' } else { ':' } -# Guard against PATH separator injection: reject SAFE_CHAIN_DIR values containing the path separator -$safeChainBase = if ($env:SAFE_CHAIN_DIR -and -not $env:SAFE_CHAIN_DIR.Contains($pathSeparator)) { $env:SAFE_CHAIN_DIR } else { Join-Path $HOME '.safe-chain' } +$safeChainBase = Split-Path -Parent $PSScriptRoot $safeChainBin = Join-Path $safeChainBase 'bin' $env:PATH = "$env:PATH$pathSeparator$safeChainBin" diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.js index ff2266b..fc56025 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.js @@ -3,7 +3,6 @@ import { doesExecutableExistOnSystem, removeLinesMatchingPattern, getScriptsDir, - getSafeChainDir, } from "../helpers.js"; import { execSync, spawnSync } from "child_process"; import * as os from "os"; @@ -54,15 +53,6 @@ function teardown(tools) { function setup() { const startupFile = getStartupFile(); - const customDir = getSafeChainDir(); - if (customDir) { - addLineToFile( - startupFile, - `export SAFE_CHAIN_DIR="${customDir}" # Safe-chain installation directory`, - eol - ); - } - addLineToFile( startupFile, `source ${path.join(getScriptsDir(), "init-posix.sh")} # Safe-chain bash initialization script`, @@ -143,18 +133,7 @@ function cygpathw(path) { /** @param {string} preamble */ function buildManualInstructions(preamble) { - const customDir = getSafeChainDir(); - const instructions = [preamble]; - - if (customDir) { - instructions.push( - ` export SAFE_CHAIN_DIR="${customDir}"`, - ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, - ); - } else { - instructions.push(` source ~/.safe-chain/scripts/init-posix.sh`); - } - + const instructions = [preamble, ` source ${path.join(getScriptsDir(), "init-posix.sh")}`]; instructions.push(`Then restart your terminal or run: source ~/.bashrc`); return instructions; } diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js index 4b25d4b..4eaaa6f 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js @@ -10,7 +10,6 @@ describe("Bash shell integration", () => { let bash; let windowsCygwinPath = ""; let platform = "linux"; - let getSafeChainDirResult = undefined; beforeEach(async () => { // Create temporary startup file for testing @@ -21,7 +20,6 @@ describe("Bash shell integration", () => { namedExports: { doesExecutableExistOnSystem: () => true, getScriptsDir: () => "/test-home/.safe-chain/scripts", - getSafeChainDir: () => getSafeChainDirResult, addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -91,7 +89,6 @@ describe("Bash shell integration", () => { // Reset mocks mock.reset(); platform = "linux"; - getSafeChainDirResult = undefined; }); describe("isInstalled", () => { @@ -203,26 +200,18 @@ describe("Bash shell integration", () => { }); }); - describe("SAFE_CHAIN_DIR", () => { - it("should write export line to rc file when custom dir is set", () => { - getSafeChainDirResult = "/custom/safe-chain"; + describe("custom install dir", () => { + it("writes only the source line to the rc file", () => { bash.setup(); const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - content.includes('export SAFE_CHAIN_DIR="/custom/safe-chain" # Safe-chain installation directory') + content.includes("source /test-home/.safe-chain/scripts/init-posix.sh") ); - }); - - it("should not write export line when no custom dir is set", () => { - getSafeChainDirResult = undefined; - bash.setup(); - - const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); - it("should remove export line on teardown", () => { + it("removes legacy export lines on teardown", () => { const initialContent = [ '#!/bin/bash', 'export SAFE_CHAIN_DIR="/custom/safe-chain" # Safe-chain installation directory', @@ -236,12 +225,9 @@ describe("Bash shell integration", () => { assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); - it("should show custom manual setup instructions when custom dir is set", () => { - getSafeChainDirResult = "/custom/safe-chain"; - + it("shows source-only manual setup instructions", () => { assert.deepStrictEqual(bash.getManualSetupInstructions(), [ "Add the following line to your ~/.bashrc file:", - ' export SAFE_CHAIN_DIR="/custom/safe-chain"', " source /test-home/.safe-chain/scripts/init-posix.sh", "Then restart your terminal or run: source ~/.bashrc", ]); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.js index a6ffe1e..d5ea308 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.js @@ -3,7 +3,6 @@ import { doesExecutableExistOnSystem, removeLinesMatchingPattern, getScriptsDir, - getSafeChainDir, } from "../helpers.js"; import { execSync } from "child_process"; import path from "path"; @@ -53,15 +52,6 @@ function teardown(tools) { function setup() { const startupFile = getStartupFile(); - const customDir = getSafeChainDir(); - if (customDir) { - addLineToFile( - startupFile, - `set -gx SAFE_CHAIN_DIR "${customDir}" # Safe-chain installation directory`, - eol - ); - } - addLineToFile( startupFile, `source ${path.join(getScriptsDir(), "init-fish.fish")} # Safe-chain Fish initialization script`, @@ -86,18 +76,7 @@ function getStartupFile() { /** @param {string} preamble */ function buildManualInstructions(preamble) { - const customDir = getSafeChainDir(); - const instructions = [preamble]; - - if (customDir) { - instructions.push( - ` set -gx SAFE_CHAIN_DIR "${customDir}"`, - ` source ${path.join(getScriptsDir(), "init-fish.fish")}`, - ); - } else { - instructions.push(` source ~/.safe-chain/scripts/init-fish.fish`); - } - + const instructions = [preamble, ` source ${path.join(getScriptsDir(), "init-fish.fish")}`]; instructions.push( `Then restart your terminal or run: source ~/.config/fish/config.fish`, ); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js index 29b6d6e..9a30f11 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js @@ -8,7 +8,6 @@ import { knownAikidoTools } from "../helpers.js"; describe("Fish shell integration", () => { let mockStartupFile; let fish; - let getSafeChainDirResult = undefined; beforeEach(async () => { // Create temporary startup file for testing @@ -19,7 +18,6 @@ describe("Fish shell integration", () => { namedExports: { doesExecutableExistOnSystem: () => true, getScriptsDir: () => "/test-home/.safe-chain/scripts", - getSafeChainDir: () => getSafeChainDirResult, addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -55,7 +53,6 @@ describe("Fish shell integration", () => { // Reset mocks mock.reset(); - getSafeChainDirResult = undefined; }); describe("isInstalled", () => { @@ -156,26 +153,18 @@ describe("Fish shell integration", () => { }); }); - describe("SAFE_CHAIN_DIR", () => { - it("should write set line to config file when custom dir is set", () => { - getSafeChainDirResult = "/custom/safe-chain"; + describe("custom install dir", () => { + it("writes only the source line to the config file", () => { fish.setup(); const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - content.includes('set -gx SAFE_CHAIN_DIR "/custom/safe-chain" # Safe-chain installation directory') + content.includes("source /test-home/.safe-chain/scripts/init-fish.fish") ); - }); - - it("should not write set line when no custom dir is set", () => { - getSafeChainDirResult = undefined; - fish.setup(); - - const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); - it("should remove set line on teardown", () => { + it("removes legacy set lines on teardown", () => { const initialContent = [ 'set -gx SAFE_CHAIN_DIR "/custom/safe-chain" # Safe-chain installation directory', "source /test-home/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script", @@ -188,12 +177,9 @@ describe("Fish shell integration", () => { assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); - it("should show custom manual setup instructions when custom dir is set", () => { - getSafeChainDirResult = "/custom/safe-chain"; - + it("shows source-only manual setup instructions", () => { assert.deepStrictEqual(fish.getManualSetupInstructions(), [ "Add the following line to your ~/.config/fish/config.fish file:", - ' set -gx SAFE_CHAIN_DIR "/custom/safe-chain"', " source /test-home/.safe-chain/scripts/init-fish.fish", "Then restart your terminal or run: source ~/.config/fish/config.fish", ]); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js index 906bedd..becc3db 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js @@ -4,7 +4,6 @@ import { removeLinesMatchingPattern, validatePowerShellExecutionPolicy, getScriptsDir, - getSafeChainDir, } from "../helpers.js"; import { execSync } from "child_process"; import path from "path"; @@ -58,14 +57,6 @@ async function setup() { const startupFile = getStartupFile(); - const customDir = getSafeChainDir(); - if (customDir) { - addLineToFile( - startupFile, - `$env:SAFE_CHAIN_DIR = '${customDir}' # Safe-chain installation directory`, - ); - } - addLineToFile( startupFile, `. "${path.join(getScriptsDir(), "init-pwsh.ps1")}" # Safe-chain PowerShell initialization script`, @@ -89,18 +80,7 @@ function getStartupFile() { /** @param {string} preamble */ function buildManualInstructions(preamble) { - const customDir = getSafeChainDir(); - const instructions = [preamble]; - - if (customDir) { - instructions.push( - ` $env:SAFE_CHAIN_DIR = '${customDir}'`, - ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, - ); - } else { - instructions.push(` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`); - } - + const instructions = [preamble, ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`]; instructions.push(`Then restart your terminal or run: . $PROFILE`); return instructions; } diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js index 296abfa..16023b5 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js @@ -9,7 +9,6 @@ describe("PowerShell Core shell integration", () => { let mockStartupFile; let powershell; let executionPolicyResult; - let getSafeChainDirResult = undefined; beforeEach(async () => { // Create temporary startup file for testing @@ -27,7 +26,6 @@ describe("PowerShell Core shell integration", () => { mock.module("../helpers.js", { namedExports: { doesExecutableExistOnSystem: () => true, - getSafeChainDir: () => getSafeChainDirResult, addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -65,7 +63,6 @@ describe("PowerShell Core shell integration", () => { // Reset mocks mock.reset(); - getSafeChainDirResult = undefined; }); describe("isInstalled", () => { @@ -209,26 +206,18 @@ describe("PowerShell Core shell integration", () => { }); }); - describe("SAFE_CHAIN_DIR", () => { - it("should write $env:SAFE_CHAIN_DIR line to profile when custom dir is set", async () => { - getSafeChainDirResult = "C:\\custom\\safe-chain"; + describe("custom install dir", () => { + it("writes only the source line to the profile", async () => { await powershell.setup(); const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - content.includes("$env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain' # Safe-chain installation directory") + content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"') ); - }); - - it("should not write $env:SAFE_CHAIN_DIR line when no custom dir is set", async () => { - getSafeChainDirResult = undefined; - await powershell.setup(); - - const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); - it("should remove $env:SAFE_CHAIN_DIR line on teardown", () => { + it("removes legacy env lines on teardown", () => { const initialContent = [ "# PowerShell profile", "$env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain' # Safe-chain installation directory", @@ -242,12 +231,9 @@ describe("PowerShell Core shell integration", () => { assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); - it("should show custom manual setup instructions when custom dir is set", () => { - getSafeChainDirResult = "C:\\custom\\safe-chain"; - + it("shows source-only manual setup instructions", () => { assert.deepStrictEqual(powershell.getManualSetupInstructions(), [ 'Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):', - " $env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain'", ' . "/test-home/.safe-chain/scripts/init-pwsh.ps1"', "Then restart your terminal or run: . $PROFILE", ]); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js index e53891e..4a27fe9 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -4,7 +4,6 @@ import { removeLinesMatchingPattern, validatePowerShellExecutionPolicy, getScriptsDir, - getSafeChainDir, } from "../helpers.js"; import { execSync } from "child_process"; import path from "path"; @@ -58,14 +57,6 @@ async function setup() { const startupFile = getStartupFile(); - const customDir = getSafeChainDir(); - if (customDir) { - addLineToFile( - startupFile, - `$env:SAFE_CHAIN_DIR = '${customDir}' # Safe-chain installation directory`, - ); - } - addLineToFile( startupFile, `. "${path.join(getScriptsDir(), "init-pwsh.ps1")}" # Safe-chain PowerShell initialization script`, @@ -89,18 +80,7 @@ function getStartupFile() { /** @param {string} preamble */ function buildManualInstructions(preamble) { - const customDir = getSafeChainDir(); - const instructions = [preamble]; - - if (customDir) { - instructions.push( - ` $env:SAFE_CHAIN_DIR = '${customDir}'`, - ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, - ); - } else { - instructions.push(` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`); - } - + const instructions = [preamble, ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`]; instructions.push(`Then restart your terminal or run: . $PROFILE`); return instructions; } diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js index 840f585..ac26ca7 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js @@ -9,7 +9,6 @@ describe("Windows PowerShell shell integration", () => { let mockStartupFile; let windowsPowershell; let executionPolicyResult; - let getSafeChainDirResult = undefined; beforeEach(async () => { // Create temporary startup file for testing @@ -27,7 +26,6 @@ describe("Windows PowerShell shell integration", () => { mock.module("../helpers.js", { namedExports: { doesExecutableExistOnSystem: () => true, - getSafeChainDir: () => getSafeChainDirResult, addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -65,7 +63,6 @@ describe("Windows PowerShell shell integration", () => { // Reset mocks mock.reset(); - getSafeChainDirResult = undefined; }); describe("isInstalled", () => { @@ -209,26 +206,18 @@ describe("Windows PowerShell shell integration", () => { }); }); - describe("SAFE_CHAIN_DIR", () => { - it("should write $env:SAFE_CHAIN_DIR line to profile when custom dir is set", async () => { - getSafeChainDirResult = "C:\\custom\\safe-chain"; + describe("custom install dir", () => { + it("writes only the source line to the profile", async () => { await windowsPowershell.setup(); const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - content.includes("$env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain' # Safe-chain installation directory") + content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"') ); - }); - - it("should not write $env:SAFE_CHAIN_DIR line when no custom dir is set", async () => { - getSafeChainDirResult = undefined; - await windowsPowershell.setup(); - - const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); - it("should remove $env:SAFE_CHAIN_DIR line on teardown", () => { + it("removes legacy env lines on teardown", () => { const initialContent = [ "# Windows PowerShell profile", "$env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain' # Safe-chain installation directory", @@ -242,12 +231,9 @@ describe("Windows PowerShell shell integration", () => { assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); - it("should show custom manual teardown instructions when custom dir is set", () => { - getSafeChainDirResult = "C:\\custom\\safe-chain"; - + it("shows source-only manual teardown instructions", () => { assert.deepStrictEqual(windowsPowershell.getManualTeardownInstructions(), [ 'Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):', - " $env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain'", ' . "/test-home/.safe-chain/scripts/init-pwsh.ps1"', "Then restart your terminal or run: . $PROFILE", ]); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js index 9b87d86..3fa775c 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js @@ -3,7 +3,6 @@ import { doesExecutableExistOnSystem, removeLinesMatchingPattern, getScriptsDir, - getSafeChainDir, } from "../helpers.js"; import { execSync } from "child_process"; import path from "path"; @@ -53,15 +52,6 @@ function teardown(tools) { function setup() { const startupFile = getStartupFile(); - const customDir = getSafeChainDir(); - if (customDir) { - addLineToFile( - startupFile, - `export SAFE_CHAIN_DIR="${customDir}" # Safe-chain installation directory`, - eol - ); - } - addLineToFile( startupFile, `source ${path.join(getScriptsDir(), "init-posix.sh")} # Safe-chain Zsh initialization script`, @@ -86,18 +76,7 @@ function getStartupFile() { /** @param {string} preamble */ function buildManualInstructions(preamble) { - const customDir = getSafeChainDir(); - const instructions = [preamble]; - - if (customDir) { - instructions.push( - ` export SAFE_CHAIN_DIR="${customDir}"`, - ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, - ); - } else { - instructions.push(` source ~/.safe-chain/scripts/init-posix.sh`); - } - + const instructions = [preamble, ` source ${path.join(getScriptsDir(), "init-posix.sh")}`]; instructions.push(`Then restart your terminal or run: source ~/.zshrc`); return instructions; } diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js index 52e790f..caa85f4 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js @@ -8,7 +8,6 @@ import { knownAikidoTools } from "../helpers.js"; describe("Zsh shell integration", () => { let mockStartupFile; let zsh; - let getSafeChainDirResult = undefined; beforeEach(async () => { // Create temporary startup file for testing @@ -19,7 +18,6 @@ describe("Zsh shell integration", () => { namedExports: { doesExecutableExistOnSystem: () => true, getScriptsDir: () => "/test-home/.safe-chain/scripts", - getSafeChainDir: () => getSafeChainDirResult, addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -55,7 +53,6 @@ describe("Zsh shell integration", () => { // Reset mocks mock.reset(); - getSafeChainDirResult = undefined; }); describe("isInstalled", () => { @@ -174,26 +171,18 @@ describe("Zsh shell integration", () => { }); }); - describe("SAFE_CHAIN_DIR", () => { - it("should write export line to rc file when custom dir is set", () => { - getSafeChainDirResult = "/custom/safe-chain"; + describe("custom install dir", () => { + it("writes only the source line to the rc file", () => { zsh.setup(); const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - content.includes('export SAFE_CHAIN_DIR="/custom/safe-chain" # Safe-chain installation directory') + content.includes("source /test-home/.safe-chain/scripts/init-posix.sh") ); - }); - - it("should not write export line when no custom dir is set", () => { - getSafeChainDirResult = undefined; - zsh.setup(); - - const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); - it("should remove export line on teardown", () => { + it("removes legacy export lines on teardown", () => { const initialContent = [ "#!/bin/zsh", 'export SAFE_CHAIN_DIR="/custom/safe-chain" # Safe-chain installation directory', @@ -207,12 +196,9 @@ describe("Zsh shell integration", () => { assert.ok(!content.includes("SAFE_CHAIN_DIR")); }); - it("should show custom manual teardown instructions when custom dir is set", () => { - getSafeChainDirResult = "/custom/safe-chain"; - + it("shows source-only manual teardown instructions", () => { assert.deepStrictEqual(zsh.getManualTeardownInstructions(), [ "Remove the following line from your ~/.zshrc file:", - ' export SAFE_CHAIN_DIR="/custom/safe-chain"', " source /test-home/.safe-chain/scripts/init-posix.sh", "Then restart your terminal or run: source ~/.zshrc", ]); From 031c9683b1ed71325e9119283206fe324934be63 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 11:10:16 -0700 Subject: [PATCH 276/360] Some more cleanup --- .../supported-shells/bash.js | 27 ++- .../supported-shells/bash.spec.js | 34 ---- .../supported-shells/fish.js | 29 ++- .../supported-shells/fish.spec.js | 33 ---- .../supported-shells/powershell.js | 26 ++- .../supported-shells/powershell.spec.js | 34 ---- .../supported-shells/windowsPowershell.js | 26 ++- .../windowsPowershell.spec.js | 34 ---- .../shell-integration/supported-shells/zsh.js | 27 ++- .../supported-shells/zsh.spec.js | 34 ---- test/e2e/bun.e2e.spec.js | 34 ---- test/e2e/npm-ci.e2e.spec.js | 42 ----- test/e2e/npm.e2e.spec.js | 34 ---- test/e2e/pip-ci.e2e.spec.js | 39 ---- test/e2e/pip.e2e.spec.js | 35 ---- test/e2e/pipx.e2e.spec.js | 34 ---- test/e2e/pnpm-ci.e2e.spec.js | 40 ----- test/e2e/pnpm.e2e.spec.js | 34 ---- test/e2e/poetry.e2e.spec.js | 41 ----- test/e2e/safe-chain-dir.e2e.spec.js | 166 ------------------ test/e2e/uv.e2e.spec.js | 38 ---- test/e2e/yarn-ci.e2e.spec.js | 40 ----- test/e2e/yarn.e2e.spec.js | 38 ---- 23 files changed, 55 insertions(+), 864 deletions(-) delete mode 100644 test/e2e/safe-chain-dir.e2e.spec.js diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.js index fc56025..5e113bd 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.js @@ -34,19 +34,13 @@ function teardown(tools) { ); } - // Marker comment ensures only safe-chain-added lines are removed, not user's own source statements + // Removes the line that sources the safe-chain bash initialization script. removeLinesMatchingPattern( startupFile, /^source\s+.*init-posix\.sh.*#\s*Safe-chain/, eol ); - removeLinesMatchingPattern( - startupFile, - /^export\s+SAFE_CHAIN_DIR=.*#\s*Safe-chain/, - eol - ); - return true; } @@ -131,19 +125,20 @@ function cygpathw(path) { } } -/** @param {string} preamble */ -function buildManualInstructions(preamble) { - const instructions = [preamble, ` source ${path.join(getScriptsDir(), "init-posix.sh")}`]; - instructions.push(`Then restart your terminal or run: source ~/.bashrc`); - return instructions; -} - function getManualTeardownInstructions() { - return buildManualInstructions(`Remove the following line from your ~/.bashrc file:`); + return [ + `Remove the following line from your ~/.bashrc file:`, + ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, + `Then restart your terminal or run: source ~/.bashrc`, + ]; } function getManualSetupInstructions() { - return buildManualInstructions(`Add the following line to your ~/.bashrc file:`); + return [ + `Add the following line to your ~/.bashrc file:`, + ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, + `Then restart your terminal or run: source ~/.bashrc`, + ]; } /** diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js index 4eaaa6f..f0a56d2 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js @@ -200,40 +200,6 @@ describe("Bash shell integration", () => { }); }); - describe("custom install dir", () => { - it("writes only the source line to the rc file", () => { - bash.setup(); - - const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok( - content.includes("source /test-home/.safe-chain/scripts/init-posix.sh") - ); - assert.ok(!content.includes("SAFE_CHAIN_DIR")); - }); - - it("removes legacy export lines on teardown", () => { - const initialContent = [ - '#!/bin/bash', - 'export SAFE_CHAIN_DIR="/custom/safe-chain" # Safe-chain installation directory', - 'source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script', - ].join("\n"); - - fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); - - bash.teardown(knownAikidoTools); - const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(!content.includes("SAFE_CHAIN_DIR")); - }); - - it("shows source-only manual setup instructions", () => { - assert.deepStrictEqual(bash.getManualSetupInstructions(), [ - "Add the following line to your ~/.bashrc file:", - " source /test-home/.safe-chain/scripts/init-posix.sh", - "Then restart your terminal or run: source ~/.bashrc", - ]); - }); - }); - describe("integration tests", () => { it("should handle complete setup and teardown cycle", () => { const tools = [ diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.js index d5ea308..28323bf 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.js @@ -33,19 +33,13 @@ function teardown(tools) { ); } - // Removes the line that sources the safe-chain fish initialization script (any path, requires safe-chain comment) + // Removes the line that sources the safe-chain fish initialization script. removeLinesMatchingPattern( startupFile, /^source\s+.*init-fish\.fish.*#\s*Safe-chain/, eol ); - removeLinesMatchingPattern( - startupFile, - /^set\s+-gx\s+SAFE_CHAIN_DIR\s+.*#\s*Safe-chain/, - eol - ); - return true; } @@ -74,21 +68,20 @@ function getStartupFile() { } } -/** @param {string} preamble */ -function buildManualInstructions(preamble) { - const instructions = [preamble, ` source ${path.join(getScriptsDir(), "init-fish.fish")}`]; - instructions.push( - `Then restart your terminal or run: source ~/.config/fish/config.fish`, - ); - return instructions; -} - function getManualTeardownInstructions() { - return buildManualInstructions(`Remove the following line from your ~/.config/fish/config.fish file:`); + return [ + `Remove the following line from your ~/.config/fish/config.fish file:`, + ` source ${path.join(getScriptsDir(), "init-fish.fish")}`, + `Then restart your terminal or run: source ~/.config/fish/config.fish`, + ]; } function getManualSetupInstructions() { - return buildManualInstructions(`Add the following line to your ~/.config/fish/config.fish file:`); + return [ + `Add the following line to your ~/.config/fish/config.fish file:`, + ` source ${path.join(getScriptsDir(), "init-fish.fish")}`, + `Then restart your terminal or run: source ~/.config/fish/config.fish`, + ]; } /** diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js index 9a30f11..0933b6e 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js @@ -153,39 +153,6 @@ describe("Fish shell integration", () => { }); }); - describe("custom install dir", () => { - it("writes only the source line to the config file", () => { - fish.setup(); - - const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok( - content.includes("source /test-home/.safe-chain/scripts/init-fish.fish") - ); - assert.ok(!content.includes("SAFE_CHAIN_DIR")); - }); - - it("removes legacy set lines on teardown", () => { - const initialContent = [ - 'set -gx SAFE_CHAIN_DIR "/custom/safe-chain" # Safe-chain installation directory', - "source /test-home/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script", - ].join("\n"); - - fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); - - fish.teardown(knownAikidoTools); - const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(!content.includes("SAFE_CHAIN_DIR")); - }); - - it("shows source-only manual setup instructions", () => { - assert.deepStrictEqual(fish.getManualSetupInstructions(), [ - "Add the following line to your ~/.config/fish/config.fish file:", - " source /test-home/.safe-chain/scripts/init-fish.fish", - "Then restart your terminal or run: source ~/.config/fish/config.fish", - ]); - }); - }); - describe("integration tests", () => { it("should handle complete setup and teardown cycle", () => { const tools = [ diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js index becc3db..d0f5eed 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js @@ -32,17 +32,12 @@ function teardown(tools) { ); } - // Remove the line that sources the safe-chain PowerShell initialization script (any path, requires safe-chain comment) + // Removes the line that sources the safe-chain PowerShell initialization script. removeLinesMatchingPattern( startupFile, /^\.\s+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/, ); - removeLinesMatchingPattern( - startupFile, - /^\$env:SAFE_CHAIN_DIR\s*=.*#\s*Safe-chain/, - ); - return true; } @@ -78,19 +73,20 @@ function getStartupFile() { } } -/** @param {string} preamble */ -function buildManualInstructions(preamble) { - const instructions = [preamble, ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`]; - instructions.push(`Then restart your terminal or run: . $PROFILE`); - return instructions; -} - function getManualTeardownInstructions() { - return buildManualInstructions(`Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`); + return [ + `Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`, + ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, + `Then restart your terminal or run: . $PROFILE`, + ]; } function getManualSetupInstructions() { - return buildManualInstructions(`Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`); + return [ + `Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`, + ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, + `Then restart your terminal or run: . $PROFILE`, + ]; } /** diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js index 16023b5..1d9f65c 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js @@ -206,40 +206,6 @@ describe("PowerShell Core shell integration", () => { }); }); - describe("custom install dir", () => { - it("writes only the source line to the profile", async () => { - await powershell.setup(); - - const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok( - content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"') - ); - assert.ok(!content.includes("SAFE_CHAIN_DIR")); - }); - - it("removes legacy env lines on teardown", () => { - const initialContent = [ - "# PowerShell profile", - "$env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain' # Safe-chain installation directory", - '. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script', - ].join("\n"); - - fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); - - powershell.teardown(knownAikidoTools); - const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(!content.includes("SAFE_CHAIN_DIR")); - }); - - it("shows source-only manual setup instructions", () => { - assert.deepStrictEqual(powershell.getManualSetupInstructions(), [ - 'Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):', - ' . "/test-home/.safe-chain/scripts/init-pwsh.ps1"', - "Then restart your terminal or run: . $PROFILE", - ]); - }); - }); - describe("execution policy", () => { it(`should throw for restricted policies`, async () => { executionPolicyResult = { diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js index 4a27fe9..87c2fae 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -32,17 +32,12 @@ function teardown(tools) { ); } - // Match any installation path but require the Safe-chain marker to avoid removing unrelated user scripts + // Removes the line that sources the safe-chain PowerShell initialization script. removeLinesMatchingPattern( startupFile, /^\.\s+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/, ); - removeLinesMatchingPattern( - startupFile, - /^\$env:SAFE_CHAIN_DIR\s*=.*#\s*Safe-chain/, - ); - return true; } @@ -78,19 +73,20 @@ function getStartupFile() { } } -/** @param {string} preamble */ -function buildManualInstructions(preamble) { - const instructions = [preamble, ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`]; - instructions.push(`Then restart your terminal or run: . $PROFILE`); - return instructions; -} - function getManualTeardownInstructions() { - return buildManualInstructions(`Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`); + return [ + `Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`, + ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, + `Then restart your terminal or run: . $PROFILE`, + ]; } function getManualSetupInstructions() { - return buildManualInstructions(`Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`); + return [ + `Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`, + ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, + `Then restart your terminal or run: . $PROFILE`, + ]; } /** diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js index ac26ca7..621b380 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js @@ -206,40 +206,6 @@ describe("Windows PowerShell shell integration", () => { }); }); - describe("custom install dir", () => { - it("writes only the source line to the profile", async () => { - await windowsPowershell.setup(); - - const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok( - content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"') - ); - assert.ok(!content.includes("SAFE_CHAIN_DIR")); - }); - - it("removes legacy env lines on teardown", () => { - const initialContent = [ - "# Windows PowerShell profile", - "$env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain' # Safe-chain installation directory", - '. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script', - ].join("\n"); - - fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); - - windowsPowershell.teardown(knownAikidoTools); - const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(!content.includes("SAFE_CHAIN_DIR")); - }); - - it("shows source-only manual teardown instructions", () => { - assert.deepStrictEqual(windowsPowershell.getManualTeardownInstructions(), [ - 'Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):', - ' . "/test-home/.safe-chain/scripts/init-pwsh.ps1"', - "Then restart your terminal or run: . $PROFILE", - ]); - }); - }); - describe("execution policy", () => { it(`should throw for restricted policies`, async () => { executionPolicyResult = { diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js index 3fa775c..c1c1232 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js @@ -33,19 +33,13 @@ function teardown(tools) { ); } - // Remove init script source line to uninstall shell integration; marker ensures only safe-chain-added lines are removed + // Removes the line that sources the safe-chain zsh initialization script. removeLinesMatchingPattern( startupFile, /^source\s+.*init-posix\.sh.*#\s*Safe-chain/, eol ); - removeLinesMatchingPattern( - startupFile, - /^export\s+SAFE_CHAIN_DIR=.*#\s*Safe-chain/, - eol - ); - return true; } @@ -74,19 +68,20 @@ function getStartupFile() { } } -/** @param {string} preamble */ -function buildManualInstructions(preamble) { - const instructions = [preamble, ` source ${path.join(getScriptsDir(), "init-posix.sh")}`]; - instructions.push(`Then restart your terminal or run: source ~/.zshrc`); - return instructions; -} - function getManualTeardownInstructions() { - return buildManualInstructions(`Remove the following line from your ~/.zshrc file:`); + return [ + `Remove the following line from your ~/.zshrc file:`, + ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, + `Then restart your terminal or run: source ~/.zshrc`, + ]; } function getManualSetupInstructions() { - return buildManualInstructions(`Add the following line to your ~/.zshrc file:`); + return [ + `Add the following line to your ~/.zshrc file:`, + ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, + `Then restart your terminal or run: source ~/.zshrc`, + ]; } export default { diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js index caa85f4..41e1bd1 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js @@ -171,40 +171,6 @@ describe("Zsh shell integration", () => { }); }); - describe("custom install dir", () => { - it("writes only the source line to the rc file", () => { - zsh.setup(); - - const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok( - content.includes("source /test-home/.safe-chain/scripts/init-posix.sh") - ); - assert.ok(!content.includes("SAFE_CHAIN_DIR")); - }); - - it("removes legacy export lines on teardown", () => { - const initialContent = [ - "#!/bin/zsh", - 'export SAFE_CHAIN_DIR="/custom/safe-chain" # Safe-chain installation directory', - "source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script", - ].join("\n"); - - fs.writeFileSync(mockStartupFile, initialContent, "utf-8"); - - zsh.teardown(knownAikidoTools); - const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(!content.includes("SAFE_CHAIN_DIR")); - }); - - it("shows source-only manual teardown instructions", () => { - assert.deepStrictEqual(zsh.getManualTeardownInstructions(), [ - "Remove the following line from your ~/.zshrc file:", - " source /test-home/.safe-chain/scripts/init-posix.sh", - "Then restart your terminal or run: source ~/.zshrc", - ]); - }); - }); - describe("integration tests", () => { it("should handle complete setup and teardown cycle", () => { const tools = [ diff --git a/test/e2e/bun.e2e.spec.js b/test/e2e/bun.e2e.spec.js index 1de6100..27a8923 100644 --- a/test/e2e/bun.e2e.spec.js +++ b/test/e2e/bun.e2e.spec.js @@ -79,38 +79,4 @@ describe("E2E: bun coverage", () => { ); }); - describe("with SAFE_CHAIN_DIR (custom install directory)", () => { - const CUSTOM_DIR = "/usr/local/.safe-chain"; - let customContainer; - - beforeEach(async () => { - customContainer = new DockerTestContainer(); - await customContainer.start(); - - const setupShell = await customContainer.openShell("bash"); - await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await setupShell.runCommand("safe-chain setup"); - }); - - afterEach(async () => { - if (customContainer) { - await customContainer.stop(); - customContainer = null; - } - }); - - it("blocks malicious bun packages when scripts are in a custom directory", async () => { - const shell = await customContainer.openShell("bash"); - const result = await shell.runCommand("bunx safe-chain-test"); - - assert.ok( - result.output.includes("blocked 1 malicious package downloads"), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - }); - }); }); diff --git a/test/e2e/npm-ci.e2e.spec.js b/test/e2e/npm-ci.e2e.spec.js index cc3349b..9cb0886 100644 --- a/test/e2e/npm-ci.e2e.spec.js +++ b/test/e2e/npm-ci.e2e.spec.js @@ -103,46 +103,4 @@ describe("E2E: npm coverage using PATH", () => { ); }); - describe("with SAFE_CHAIN_DIR (custom install directory)", () => { - const CUSTOM_DIR = "/usr/local/.safe-chain"; - let customContainer; - - beforeEach(async () => { - customContainer = new DockerTestContainer(); - await customContainer.start(); - - const setupShell = await customContainer.openShell("zsh"); - await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await setupShell.runCommand("safe-chain setup-ci"); - // Persist SAFE_CHAIN_DIR and the custom shims dir in .zshrc so new shells - // inherit both (shims need SAFE_CHAIN_DIR to strip themselves from PATH) - await setupShell.runCommand( - `echo 'export SAFE_CHAIN_DIR=${CUSTOM_DIR}' >> ~/.zshrc` - ); - await setupShell.runCommand( - `echo 'export PATH="${CUSTOM_DIR}/shims:$PATH"' >> ~/.zshrc` - ); - }); - - afterEach(async () => { - if (customContainer) { - await customContainer.stop(); - customContainer = null; - } - }); - - it("blocks malicious npm packages when shims are in a custom directory", async () => { - const shell = await customContainer.openShell("zsh"); - const result = await shell.runCommand("npm i safe-chain-test"); - - assert.ok( - result.output.includes("Malicious changes detected:"), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - }); - }); }); diff --git a/test/e2e/npm.e2e.spec.js b/test/e2e/npm.e2e.spec.js index d86af3c..c07b648 100644 --- a/test/e2e/npm.e2e.spec.js +++ b/test/e2e/npm.e2e.spec.js @@ -120,38 +120,4 @@ describe("E2E: npm coverage", () => { ); }); - describe("with SAFE_CHAIN_DIR (custom install directory)", () => { - const CUSTOM_DIR = "/usr/local/.safe-chain"; - let customContainer; - - beforeEach(async () => { - customContainer = new DockerTestContainer(); - await customContainer.start(); - - const setupShell = await customContainer.openShell("zsh"); - await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await setupShell.runCommand("safe-chain setup"); - }); - - afterEach(async () => { - if (customContainer) { - await customContainer.stop(); - customContainer = null; - } - }); - - it("blocks malicious npm packages when scripts are in a custom directory", async () => { - const shell = await customContainer.openShell("zsh"); - const result = await shell.runCommand("npm i safe-chain-test"); - - assert.ok( - result.output.includes("Malicious changes detected:"), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - }); - }); }); diff --git a/test/e2e/pip-ci.e2e.spec.js b/test/e2e/pip-ci.e2e.spec.js index e1a7aed..7857ef2 100644 --- a/test/e2e/pip-ci.e2e.spec.js +++ b/test/e2e/pip-ci.e2e.spec.js @@ -205,43 +205,4 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => { }); } - describe("with SAFE_CHAIN_DIR (custom install directory)", () => { - const CUSTOM_DIR = "/usr/local/.safe-chain"; - let customContainer; - - beforeEach(async () => { - customContainer = new DockerTestContainer(); - await customContainer.start(); - - const setupShell = await customContainer.openShell("zsh"); - await setupShell.runCommand("pip3 cache purge"); - await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await setupShell.runCommand("safe-chain setup-ci"); - await setupShell.runCommand( - `echo 'export SAFE_CHAIN_DIR=${CUSTOM_DIR}' >> ~/.zshrc` - ); - await setupShell.runCommand( - `echo 'export PATH="${CUSTOM_DIR}/shims:$PATH"' >> ~/.zshrc` - ); - }); - - afterEach(async () => { - if (customContainer) { - await customContainer.stop(); - customContainer = null; - } - }); - - it("intercepts pip3 install when shims are in a custom directory", async () => { - const shell = await customContainer.openShell("zsh"); - const result = await shell.runCommand( - "pip3 install --break-system-packages certifi --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Expected pip3 to be protected with SAFE_CHAIN_DIR. Output:\n${result.output}` - ); - }); - }); }); diff --git a/test/e2e/pip.e2e.spec.js b/test/e2e/pip.e2e.spec.js index 684ee4f..c86e1cd 100644 --- a/test/e2e/pip.e2e.spec.js +++ b/test/e2e/pip.e2e.spec.js @@ -845,39 +845,4 @@ describe("E2E: pip coverage", () => { ); }); - describe("with SAFE_CHAIN_DIR (custom install directory)", () => { - const CUSTOM_DIR = "/usr/local/.safe-chain"; - let customContainer; - - beforeEach(async () => { - customContainer = new DockerTestContainer(); - await customContainer.start(); - - const setupShell = await customContainer.openShell("zsh"); - await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await setupShell.runCommand("safe-chain setup"); - await setupShell.runCommand("pip3 cache purge"); - }); - - afterEach(async () => { - if (customContainer) { - await customContainer.stop(); - customContainer = null; - } - }); - - it("intercepts pip3 install when scripts are in a custom directory", async () => { - // New shell sources ~/.zshrc → sources init-posix.sh from custom dir - // → defines pip3() shell function that routes through safe-chain - const shell = await customContainer.openShell("zsh"); - const result = await shell.runCommand( - "pip3 install --break-system-packages requests --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("no malware found."), - `Expected pip3 to be protected with SAFE_CHAIN_DIR. Output:\n${result.output}` - ); - }); - }); }); diff --git a/test/e2e/pipx.e2e.spec.js b/test/e2e/pipx.e2e.spec.js index 489d8c6..8278bb4 100644 --- a/test/e2e/pipx.e2e.spec.js +++ b/test/e2e/pipx.e2e.spec.js @@ -198,38 +198,4 @@ describe("E2E: pipx coverage", () => { ); }); - describe("with SAFE_CHAIN_DIR (custom install directory)", () => { - const CUSTOM_DIR = "/usr/local/.safe-chain"; - let customContainer; - - beforeEach(async () => { - customContainer = new DockerTestContainer(); - await customContainer.start(); - - const setupShell = await customContainer.openShell("zsh"); - await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await setupShell.runCommand("safe-chain setup"); - }); - - afterEach(async () => { - if (customContainer) { - await customContainer.stop(); - customContainer = null; - } - }); - - it("blocks malicious pipx packages when scripts are in a custom directory", async () => { - const shell = await customContainer.openShell("zsh"); - const result = await shell.runCommand("pipx install safe-chain-pi-test"); - - assert.ok( - result.output.includes("blocked by safe-chain"), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - }); - }); }); diff --git a/test/e2e/pnpm-ci.e2e.spec.js b/test/e2e/pnpm-ci.e2e.spec.js index 391001e..edba881 100644 --- a/test/e2e/pnpm-ci.e2e.spec.js +++ b/test/e2e/pnpm-ci.e2e.spec.js @@ -123,44 +123,4 @@ describe("E2E: pnpm coverage", () => { ); }); - describe("with SAFE_CHAIN_DIR (custom install directory)", () => { - const CUSTOM_DIR = "/usr/local/.safe-chain"; - let customContainer; - - beforeEach(async () => { - customContainer = new DockerTestContainer(); - await customContainer.start(); - - const setupShell = await customContainer.openShell("zsh"); - await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await setupShell.runCommand("safe-chain setup-ci"); - await setupShell.runCommand( - `echo 'export SAFE_CHAIN_DIR=${CUSTOM_DIR}' >> ~/.zshrc` - ); - await setupShell.runCommand( - `echo 'export PATH="${CUSTOM_DIR}/shims:$PATH"' >> ~/.zshrc` - ); - }); - - afterEach(async () => { - if (customContainer) { - await customContainer.stop(); - customContainer = null; - } - }); - - it("blocks malicious pnpm packages when shims are in a custom directory", async () => { - const shell = await customContainer.openShell("zsh"); - const result = await shell.runCommand("pnpm add safe-chain-test"); - - assert.ok( - result.output.includes("Malicious changes detected:"), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - }); - }); }); diff --git a/test/e2e/pnpm.e2e.spec.js b/test/e2e/pnpm.e2e.spec.js index 90ef57c..1c8d5ab 100644 --- a/test/e2e/pnpm.e2e.spec.js +++ b/test/e2e/pnpm.e2e.spec.js @@ -140,38 +140,4 @@ describe("E2E: pnpm coverage", () => { ); }); - describe("with SAFE_CHAIN_DIR (custom install directory)", () => { - const CUSTOM_DIR = "/usr/local/.safe-chain"; - let customContainer; - - beforeEach(async () => { - customContainer = new DockerTestContainer(); - await customContainer.start(); - - const setupShell = await customContainer.openShell("zsh"); - await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await setupShell.runCommand("safe-chain setup"); - }); - - afterEach(async () => { - if (customContainer) { - await customContainer.stop(); - customContainer = null; - } - }); - - it("blocks malicious pnpm packages when scripts are in a custom directory", async () => { - const shell = await customContainer.openShell("zsh"); - const result = await shell.runCommand("pnpm add safe-chain-test"); - - assert.ok( - result.output.includes("Malicious changes detected:"), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - }); - }); }); diff --git a/test/e2e/poetry.e2e.spec.js b/test/e2e/poetry.e2e.spec.js index 072d1b6..96761bc 100644 --- a/test/e2e/poetry.e2e.spec.js +++ b/test/e2e/poetry.e2e.spec.js @@ -423,45 +423,4 @@ describe("E2E: poetry coverage", () => { ); }); - describe("with SAFE_CHAIN_DIR (custom install directory)", () => { - const CUSTOM_DIR = "/usr/local/.safe-chain"; - let customContainer; - - beforeEach(async () => { - customContainer = new DockerTestContainer(); - await customContainer.start(); - - const setupShell = await customContainer.openShell("zsh"); - await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await setupShell.runCommand("safe-chain setup"); - await setupShell.runCommand("command poetry cache clear pypi --all -n"); - }); - - afterEach(async () => { - if (customContainer) { - await customContainer.stop(); - customContainer = null; - } - }); - - it("blocks malicious poetry packages when scripts are in a custom directory", async () => { - const shell = await customContainer.openShell("zsh"); - await shell.runCommand("mkdir /tmp/test-poetry-custom-dir"); - await shell.runCommand( - "cd /tmp/test-poetry-custom-dir && poetry init --no-interaction" - ); - const result = await shell.runCommand( - "cd /tmp/test-poetry-custom-dir && poetry add safe-chain-pi-test" - ); - - assert.ok( - result.output.includes("blocked by safe-chain"), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - }); - }); }); diff --git a/test/e2e/safe-chain-dir.e2e.spec.js b/test/e2e/safe-chain-dir.e2e.spec.js deleted file mode 100644 index e738949..0000000 --- a/test/e2e/safe-chain-dir.e2e.spec.js +++ /dev/null @@ -1,166 +0,0 @@ -import { describe, it, before, beforeEach, afterEach } from "node:test"; -import { DockerTestContainer } from "./DockerTestContainer.js"; -import assert from "node:assert"; - -const CUSTOM_DIR = "/usr/local/.safe-chain"; - -describe("E2E: SAFE_CHAIN_DIR support", () => { - let container; - - before(async () => { - DockerTestContainer.buildImage(); - }); - - beforeEach(async () => { - container = new DockerTestContainer(); - await container.start(); - }); - - afterEach(async () => { - if (container) { - await container.stop(); - container = null; - } - }); - - it("setup-ci installs shims in the custom directory when SAFE_CHAIN_DIR is set", async () => { - const shell = await container.openShell("bash"); - await shell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await shell.runCommand("safe-chain setup-ci"); - - // Shims should be in the custom dir - const customShimResult = await shell.runCommand( - `test -f ${CUSTOM_DIR}/shims/npm && echo "EXISTS"` - ); - assert.ok( - customShimResult.output.includes("EXISTS"), - `Expected npm shim at ${CUSTOM_DIR}/shims/npm. Output:\n${customShimResult.output}` - ); - - // Default location should NOT have been created - const defaultShimResult = await shell.runCommand( - `test -d $HOME/.safe-chain/shims && echo "EXISTS" || echo "ABSENT"` - ); - assert.ok( - defaultShimResult.output.includes("ABSENT"), - `Expected default shims dir to be absent. Output:\n${defaultShimResult.output}` - ); - }); - - it("setup-ci writes the custom directory path to GITHUB_PATH when SAFE_CHAIN_DIR is set", async () => { - const shell = await container.openShell("bash"); - await shell.runCommand("export GITHUB_PATH=/tmp/github_path"); - await shell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await shell.runCommand("safe-chain setup-ci"); - - const result = await shell.runCommand("cat /tmp/github_path"); - assert.ok( - result.output.includes(`${CUSTOM_DIR}/shims`), - `Expected GITHUB_PATH to contain custom shims dir. Output:\n${result.output}` - ); - assert.ok( - result.output.includes(`${CUSTOM_DIR}/bin`), - `Expected GITHUB_PATH to contain custom bin dir. Output:\n${result.output}` - ); - }); - - it("setup writes the custom path to ~/.bashrc when SAFE_CHAIN_DIR is set", async () => { - const shell = await container.openShell("bash"); - await shell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await shell.runCommand("safe-chain setup"); - - const result = await shell.runCommand("cat ~/.bashrc"); - - assert.ok( - result.output.includes(`source ${CUSTOM_DIR}/scripts/init-posix.sh`), - `Expected ~/.bashrc to contain custom scripts path. Output:\n${result.output}` - ); - assert.ok( - !result.output.includes("source ~/.safe-chain/scripts/init-posix.sh"), - `Expected ~/.bashrc to NOT contain default path. Output:\n${result.output}` - ); - }); - - it("setup with SAFE_CHAIN_DIR still protects npm in a new shell session", async () => { - // Run setup with the custom dir - const setupShell = await container.openShell("bash"); - await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await setupShell.runCommand("safe-chain setup"); - - // Open a fresh shell — it will source ~/.bashrc which sources init-posix.sh - // from the custom dir, defining the npm wrapper function - const projectShell = await container.openShell("bash"); - await projectShell.runCommand("cd /testapp"); - const result = await projectShell.runCommand( - "npm i axios@1.13.0 --safe-chain-logging=verbose" - ); - - // "Safe-chain: Package" appears before npm downloads — confirms interception happened - assert.ok( - result.output.includes("Safe-chain: Package"), - `Expected npm to be protected after setup with SAFE_CHAIN_DIR. Output:\n${result.output}` - ); - }); - - it("teardown removes the custom SAFE_CHAIN_DIR source line from ~/.bashrc", async () => { - const shell = await container.openShell("bash"); - await shell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await shell.runCommand("safe-chain setup"); - await shell.runCommand("safe-chain teardown"); - - const result = await shell.runCommand("cat ~/.bashrc"); - assert.ok( - !result.output.includes(`source ${CUSTOM_DIR}/scripts/init-posix.sh`), - `Expected custom source line to be removed from ~/.bashrc. Output:\n${result.output}` - ); - }); - - it("safe-chain protects a non-root user when installed to a shared dir with SAFE_CHAIN_DIR", async () => { - // Step 1: create a non-root user inside the container - container.dockerExec("useradd -m safeuser"); - - // Step 2: as root, run setup-ci with the shared SAFE_CHAIN_DIR - const rootShell = await container.openShell("bash"); - await rootShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await rootShell.runCommand("safe-chain setup-ci"); - - // Step 3: simulate what install-safe-chain.sh does — place the safe-chain binary - // in SAFE_CHAIN_DIR/bin. In Docker tests safe-chain is installed via npm/Volta, - // so we symlink it there. - container.dockerExec(`mkdir -p ${CUSTOM_DIR}/bin`); - container.dockerExec( - `ln -sf \\$(which safe-chain) ${CUSTOM_DIR}/bin/safe-chain` - ); - - // Step 4: make npm accessible to all users (in real Dockerfiles npm is installed - // before the user switch; here Volta manages it for root, so we symlink it). - container.dockerExec("ln -sf \\$(which npm) /usr/local/bin/npm"); - - // Step 5: make the shared safe-chain dir readable + executable by all users - container.dockerExec(`chmod -R a+rx ${CUSTOM_DIR}`); - - // Step 6: Volta installs under /root/.volta which is only accessible to root by - // default. /root/ itself is mode 700, so safeuser can't traverse into it even - // if .volta/ is world-readable. Fix both levels. Safe in a throw-away container. - container.dockerExec("chmod a+x /root && chmod -R a+rX /root/.volta"); - - // Step 7: as the non-root user, set SAFE_CHAIN_DIR and PATH, then run npm. - // SAFE_CHAIN_DIR must be set so the shim knows which dir to strip from PATH - // when invoking the real npm (prevents infinite loop). - const userShell = await container.openShell("bash", { user: "safeuser" }); - await userShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - // Reuse root's Volta dir so safeuser doesn't trigger a slow first-run setup - await userShell.runCommand("export VOLTA_HOME=/root/.volta"); - await userShell.runCommand( - `export PATH="${CUSTOM_DIR}/shims:${CUSTOM_DIR}/bin:$PATH"` - ); - const result = await userShell.runCommand( - "npm i axios@1.13.0 --safe-chain-logging=verbose" - ); - - assert.ok( - result.output.includes("Safe-chain: Scanned"), - `Expected safe-chain to protect non-root user. Output:\n${result.output}` - ); - }); -}); diff --git a/test/e2e/uv.e2e.spec.js b/test/e2e/uv.e2e.spec.js index ad24f6e..d7254c2 100644 --- a/test/e2e/uv.e2e.spec.js +++ b/test/e2e/uv.e2e.spec.js @@ -570,42 +570,4 @@ describe("E2E: uv coverage", () => { ); }); - describe("with SAFE_CHAIN_DIR (custom install directory)", () => { - const CUSTOM_DIR = "/usr/local/.safe-chain"; - let customContainer; - - beforeEach(async () => { - customContainer = new DockerTestContainer(); - await customContainer.start(); - - const setupShell = await customContainer.openShell("zsh"); - await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await setupShell.runCommand("safe-chain setup"); - await setupShell.runCommand("uv cache clean"); - }); - - afterEach(async () => { - if (customContainer) { - await customContainer.stop(); - customContainer = null; - } - }); - - it("blocks malicious uv packages when scripts are in a custom directory", async () => { - const shell = await customContainer.openShell("zsh"); - await shell.runCommand("uv init test-project-custom-dir"); - const result = await shell.runCommand( - "cd test-project-custom-dir && uv add safe-chain-pi-test" - ); - - assert.ok( - result.output.includes("blocked 1 malicious package downloads:"), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - }); - }); }); diff --git a/test/e2e/yarn-ci.e2e.spec.js b/test/e2e/yarn-ci.e2e.spec.js index 35047c1..3740207 100644 --- a/test/e2e/yarn-ci.e2e.spec.js +++ b/test/e2e/yarn-ci.e2e.spec.js @@ -85,44 +85,4 @@ describe("E2E: yarn coverage", () => { ); }); - describe("with SAFE_CHAIN_DIR (custom install directory)", () => { - const CUSTOM_DIR = "/usr/local/.safe-chain"; - let customContainer; - - beforeEach(async () => { - customContainer = new DockerTestContainer(); - await customContainer.start(); - - const setupShell = await customContainer.openShell("zsh"); - await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await setupShell.runCommand("safe-chain setup-ci"); - await setupShell.runCommand( - `echo 'export SAFE_CHAIN_DIR=${CUSTOM_DIR}' >> ~/.zshrc` - ); - await setupShell.runCommand( - `echo 'export PATH="${CUSTOM_DIR}/shims:$PATH"' >> ~/.zshrc` - ); - }); - - afterEach(async () => { - if (customContainer) { - await customContainer.stop(); - customContainer = null; - } - }); - - it("blocks malicious yarn packages when shims are in a custom directory", async () => { - const shell = await customContainer.openShell("zsh"); - const result = await shell.runCommand("yarn add safe-chain-test"); - - assert.ok( - result.output.includes("Malicious changes detected:"), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - }); - }); }); diff --git a/test/e2e/yarn.e2e.spec.js b/test/e2e/yarn.e2e.spec.js index 5b677d6..7fe2533 100644 --- a/test/e2e/yarn.e2e.spec.js +++ b/test/e2e/yarn.e2e.spec.js @@ -126,42 +126,4 @@ describe("E2E: yarn coverage", () => { ); }); - describe("with SAFE_CHAIN_DIR (custom install directory)", () => { - const CUSTOM_DIR = "/usr/local/.safe-chain"; - let customContainer; - - beforeEach(async () => { - customContainer = new DockerTestContainer(); - await customContainer.start(); - - // Run setup with the custom dir — init-posix.sh is copied to the custom - // scripts dir, and ~/.zshrc gets a source line pointing there - const setupShell = await customContainer.openShell("zsh"); - await setupShell.runCommand(`export SAFE_CHAIN_DIR=${CUSTOM_DIR}`); - await setupShell.runCommand("safe-chain setup"); - }); - - afterEach(async () => { - if (customContainer) { - await customContainer.stop(); - customContainer = null; - } - }); - - it("blocks malicious yarn packages when scripts are in a custom directory", async () => { - // New shell sources ~/.zshrc → sources init-posix.sh from custom dir - // → defines yarn() shell function that routes through safe-chain - const shell = await customContainer.openShell("zsh"); - const result = await shell.runCommand("yarn add safe-chain-test"); - - assert.ok( - result.output.includes("Malicious changes detected:"), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Expected malicious package to be blocked. Output:\n${result.output}` - ); - }); - }); }); From 72dc7dcf3acfa2bd3f3dd9e88860325f74064f52 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 11:13:03 -0700 Subject: [PATCH 277/360] Fix spacing --- test/e2e/bun.e2e.spec.js | 1 - test/e2e/npm-ci.e2e.spec.js | 1 - test/e2e/npm.e2e.spec.js | 1 - test/e2e/pip-ci.e2e.spec.js | 1 - test/e2e/pip.e2e.spec.js | 1 - test/e2e/pipx.e2e.spec.js | 1 - test/e2e/pnpm-ci.e2e.spec.js | 1 - test/e2e/pnpm.e2e.spec.js | 1 - test/e2e/poetry.e2e.spec.js | 1 - test/e2e/uv.e2e.spec.js | 1 - test/e2e/yarn-ci.e2e.spec.js | 1 - test/e2e/yarn.e2e.spec.js | 1 - 12 files changed, 12 deletions(-) diff --git a/test/e2e/bun.e2e.spec.js b/test/e2e/bun.e2e.spec.js index 27a8923..fb6e99a 100644 --- a/test/e2e/bun.e2e.spec.js +++ b/test/e2e/bun.e2e.spec.js @@ -78,5 +78,4 @@ describe("E2E: bun coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); - }); diff --git a/test/e2e/npm-ci.e2e.spec.js b/test/e2e/npm-ci.e2e.spec.js index 9cb0886..1698759 100644 --- a/test/e2e/npm-ci.e2e.spec.js +++ b/test/e2e/npm-ci.e2e.spec.js @@ -102,5 +102,4 @@ describe("E2E: npm coverage using PATH", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); - }); diff --git a/test/e2e/npm.e2e.spec.js b/test/e2e/npm.e2e.spec.js index c07b648..e8ba7c8 100644 --- a/test/e2e/npm.e2e.spec.js +++ b/test/e2e/npm.e2e.spec.js @@ -119,5 +119,4 @@ describe("E2E: npm coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); - }); diff --git a/test/e2e/pip-ci.e2e.spec.js b/test/e2e/pip-ci.e2e.spec.js index 7857ef2..49db6ce 100644 --- a/test/e2e/pip-ci.e2e.spec.js +++ b/test/e2e/pip-ci.e2e.spec.js @@ -204,5 +204,4 @@ describe("E2E: safe-chain setup-ci command for pip/pip3", () => { ); }); } - }); diff --git a/test/e2e/pip.e2e.spec.js b/test/e2e/pip.e2e.spec.js index c86e1cd..b06978f 100644 --- a/test/e2e/pip.e2e.spec.js +++ b/test/e2e/pip.e2e.spec.js @@ -844,5 +844,4 @@ describe("E2E: pip coverage", () => { `python -m pip SHOULD go through safe-chain. Output was:\n${result.output}` ); }); - }); diff --git a/test/e2e/pipx.e2e.spec.js b/test/e2e/pipx.e2e.spec.js index 8278bb4..a554aa6 100644 --- a/test/e2e/pipx.e2e.spec.js +++ b/test/e2e/pipx.e2e.spec.js @@ -197,5 +197,4 @@ describe("E2E: pipx coverage", () => { `Expected exit message. Output was:\n${result.output}` ); }); - }); diff --git a/test/e2e/pnpm-ci.e2e.spec.js b/test/e2e/pnpm-ci.e2e.spec.js index edba881..a56bb77 100644 --- a/test/e2e/pnpm-ci.e2e.spec.js +++ b/test/e2e/pnpm-ci.e2e.spec.js @@ -122,5 +122,4 @@ describe("E2E: pnpm coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); - }); diff --git a/test/e2e/pnpm.e2e.spec.js b/test/e2e/pnpm.e2e.spec.js index 1c8d5ab..a15250a 100644 --- a/test/e2e/pnpm.e2e.spec.js +++ b/test/e2e/pnpm.e2e.spec.js @@ -139,5 +139,4 @@ describe("E2E: pnpm coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); - }); diff --git a/test/e2e/poetry.e2e.spec.js b/test/e2e/poetry.e2e.spec.js index 96761bc..58b74fd 100644 --- a/test/e2e/poetry.e2e.spec.js +++ b/test/e2e/poetry.e2e.spec.js @@ -422,5 +422,4 @@ describe("E2E: poetry coverage", () => { `Expected env list output. Output was:\n${envListResult.output}` ); }); - }); diff --git a/test/e2e/uv.e2e.spec.js b/test/e2e/uv.e2e.spec.js index d7254c2..9d5f3b9 100644 --- a/test/e2e/uv.e2e.spec.js +++ b/test/e2e/uv.e2e.spec.js @@ -569,5 +569,4 @@ describe("E2E: uv coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); - }); diff --git a/test/e2e/yarn-ci.e2e.spec.js b/test/e2e/yarn-ci.e2e.spec.js index 3740207..47e2120 100644 --- a/test/e2e/yarn-ci.e2e.spec.js +++ b/test/e2e/yarn-ci.e2e.spec.js @@ -84,5 +84,4 @@ describe("E2E: yarn coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); - }); diff --git a/test/e2e/yarn.e2e.spec.js b/test/e2e/yarn.e2e.spec.js index 7fe2533..5e56d12 100644 --- a/test/e2e/yarn.e2e.spec.js +++ b/test/e2e/yarn.e2e.spec.js @@ -125,5 +125,4 @@ describe("E2E: yarn coverage", () => { `Output did not include expected text. Output was:\n${result.output}` ); }); - }); From f07d0ea888288893ca2939ae52840b21d2f8beca Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 11:17:02 -0700 Subject: [PATCH 278/360] Update packages/safe-chain/src/shell-integration/supported-shells/bash.js Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> --- .../safe-chain/src/shell-integration/supported-shells/bash.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.js index 5e113bd..4c3334c 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.js @@ -34,7 +34,7 @@ function teardown(tools) { ); } - // Removes the line that sources the safe-chain bash initialization script. + // Remove sourcing line to disable safe-chain shell integration removeLinesMatchingPattern( startupFile, /^source\s+.*init-posix\.sh.*#\s*Safe-chain/, From 5bbf3da576b708dd548a779846e759c12a6e6dae Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 11:17:15 -0700 Subject: [PATCH 279/360] Update packages/safe-chain/src/shell-integration/supported-shells/fish.js Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> --- .../safe-chain/src/shell-integration/supported-shells/fish.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.js index 28323bf..29bc485 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.js @@ -33,7 +33,7 @@ function teardown(tools) { ); } - // Removes the line that sources the safe-chain fish initialization script. + // Remove sourcing line to prevent safe-chain initialization in future shell sessions removeLinesMatchingPattern( startupFile, /^source\s+.*init-fish\.fish.*#\s*Safe-chain/, From f2bdd28ae69a161b45c2a3abdaafff7b90988451 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 11:17:27 -0700 Subject: [PATCH 280/360] Update packages/safe-chain/src/shell-integration/supported-shells/powershell.js Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> --- .../src/shell-integration/supported-shells/powershell.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js index d0f5eed..3340bb4 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js @@ -32,7 +32,7 @@ function teardown(tools) { ); } - // Removes the line that sources the safe-chain PowerShell initialization script. + // Remove sourcing line to prevent shell from loading safe-chain after uninstallation removeLinesMatchingPattern( startupFile, /^\.\s+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/, From 32408c65830bd233675cafcf5316439bc79a0bf8 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 11:17:39 -0700 Subject: [PATCH 281/360] Update packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> --- .../src/shell-integration/supported-shells/windowsPowershell.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js index 87c2fae..d458027 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -32,7 +32,7 @@ function teardown(tools) { ); } - // Removes the line that sources the safe-chain PowerShell initialization script. + // Remove sourcing line to clean up safe-chain integration from the shell profile removeLinesMatchingPattern( startupFile, /^\.\s+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/, From 56a54b8683acbd0442ba776f896b04e7ebdfa5ad Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 11:17:51 -0700 Subject: [PATCH 282/360] Update packages/safe-chain/src/shell-integration/supported-shells/zsh.js Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> --- .../safe-chain/src/shell-integration/supported-shells/zsh.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js index c1c1232..18917fd 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js @@ -33,7 +33,7 @@ function teardown(tools) { ); } - // Removes the line that sources the safe-chain zsh initialization script. + // Remove sourcing line to complete shell integration cleanup removeLinesMatchingPattern( startupFile, /^source\s+.*init-posix\.sh.*#\s*Safe-chain/, From dec9e82ee9783931c6774609508af12620cdc38c Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 11:32:51 -0700 Subject: [PATCH 283/360] Some more improvements --- install-scripts/install-safe-chain.ps1 | 89 ++++++++------- install-scripts/install-safe-chain.sh | 109 +++++++++++------- install-scripts/uninstall-safe-chain.ps1 | 135 +++++++++++++---------- install-scripts/uninstall-safe-chain.sh | 88 +++++++++++---- 4 files changed, 257 insertions(+), 164 deletions(-) diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index ec0dcd6..3c43861 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -137,6 +137,53 @@ function Get-Architecture { } } +function Write-VersionDeprecationWarning { + if ([string]::IsNullOrWhiteSpace($env:SAFE_CHAIN_VERSION)) { + return + } + + Write-Warn "SAFE_CHAIN_VERSION environment variable is deprecated." + Write-Warn "" + Write-Warn "Please use direct download URLs for version pinning instead:" + Write-Warn "" + if ($ci) { + Write-Warn " iex `"& { `$(iwr 'https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1' -UseBasicParsing) } -ci`"" + } else { + Write-Warn " iex (iwr `"https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1`" -UseBasicParsing)" + } + Write-Warn "" +} + +function Get-BinaryName { + param([string]$Architecture) + + return "safe-chain-win-$Architecture.exe" +} + +function Invoke-SafeChainSetup { + param( + [string]$BinaryPath, + [string]$InstallDirectory + ) + + $setupCmd = if ($ci) { "setup-ci" } else { "setup" } + + Write-Info "Running safe-chain $setupCmd..." + try { + $env:Path = "$env:Path;$InstallDirectory" + & $BinaryPath $setupCmd + + if ($LASTEXITCODE -ne 0) { + Write-Warn "safe-chain was installed but setup encountered issues." + Write-Warn "You can run 'safe-chain $setupCmd' manually later." + } + } + catch { + Write-Warn "safe-chain was installed but setup encountered issues: $_" + Write-Warn "You can run 'safe-chain $setupCmd' manually later." + } +} + # Check and uninstall npm global package if present function Remove-NpmInstallation { # Check if npm is available @@ -188,19 +235,7 @@ function Remove-VoltaInstallation { # Main installation function Install-SafeChain { - # Show deprecation warning if SAFE_CHAIN_VERSION is set - if (-not [string]::IsNullOrWhiteSpace($env:SAFE_CHAIN_VERSION)) { - Write-Warn "SAFE_CHAIN_VERSION environment variable is deprecated." - Write-Warn "" - Write-Warn "Please use direct download URLs for version pinning instead:" - Write-Warn "" - if ($ci) { - Write-Warn " iex `"& { `$(iwr 'https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1' -UseBasicParsing) } -ci`"" - } else { - Write-Warn " iex (iwr `"https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1`" -UseBasicParsing)" - } - Write-Warn "" - } + Write-VersionDeprecationWarning # Fetch latest version if VERSION is not set if ([string]::IsNullOrWhiteSpace($Version)) { @@ -231,7 +266,7 @@ function Install-SafeChain { # Detect platform $arch = Get-Architecture - $binaryName = "safe-chain-win-$arch.exe" + $binaryName = Get-BinaryName -Architecture $arch Write-Info "Detected architecture: $arch" @@ -277,31 +312,7 @@ function Install-SafeChain { Write-Info "Binary installed to: $finalFile" - # Build setup command based on parameters - $setupCmd = if ($ci) { "setup-ci" } else { "setup" } - $setupArgs = @() - - # Execute safe-chain setup - Write-Info "Running safe-chain $setupCmd $(if ($setupArgs) { $setupArgs -join ' ' })..." - try { - $env:Path = "$env:Path;$InstallDir" - - if ($setupArgs) { - & $finalFile $setupCmd $setupArgs - } - else { - & $finalFile $setupCmd - } - - if ($LASTEXITCODE -ne 0) { - Write-Warn "safe-chain was installed but setup encountered issues." - Write-Warn "You can run 'safe-chain $setupCmd $(if ($setupArgs) { $setupArgs -join ' ' })' manually later." - } - } - catch { - Write-Warn "safe-chain was installed but setup encountered issues: $_" - Write-Warn "You can run 'safe-chain $setupCmd $(if ($setupArgs) { $setupArgs -join ' ' })' manually later." - } + Invoke-SafeChainSetup -BinaryPath $finalFile -InstallDirectory $InstallDir } # Run installation diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 6a586e7..242dcf2 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -168,6 +168,68 @@ download() { fi } +warn_deprecated_version_env() { + if [ -z "$SAFE_CHAIN_VERSION" ]; then + return + fi + + warn "SAFE_CHAIN_VERSION environment variable is deprecated." + warn "" + warn "Please use direct download URLs for version pinning instead:" + warn "" + if [ "$USE_CI_SETUP" = "true" ]; then + warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh -s -- --ci" + else + warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh" + fi + warn "" +} + +ensure_version() { + if [ -n "$VERSION" ]; then + return + fi + + info "Fetching latest release version..." + VERSION=$(fetch_latest_version) +} + +get_binary_name() { + os="$1" + arch="$2" + + if [ "$os" = "win" ]; then + printf 'safe-chain-%s-%s.exe\n' "$os" "$arch" + else + printf 'safe-chain-%s-%s\n' "$os" "$arch" + fi +} + +get_final_binary_path() { + os="$1" + + if [ "$os" = "win" ]; then + printf '%s/safe-chain.exe\n' "$INSTALL_DIR" + else + printf '%s/safe-chain\n' "$INSTALL_DIR" + fi +} + +run_setup_command() { + final_file="$1" + + setup_cmd="setup" + if [ "$USE_CI_SETUP" = "true" ]; then + setup_cmd="setup-ci" + fi + + info "Running safe-chain $setup_cmd..." + if ! "$final_file" "$setup_cmd"; then + warn "safe-chain was installed but setup encountered issues." + warn "You can run 'safe-chain $setup_cmd' manually later." + fi +} + # Check and uninstall npm global package if present remove_npm_installation() { if ! command_exists npm; then @@ -308,25 +370,9 @@ main() { # Parse command-line arguments parse_arguments "$@" - # Show deprecation warning if SAFE_CHAIN_VERSION is set - if [ -n "$SAFE_CHAIN_VERSION" ]; then - warn "SAFE_CHAIN_VERSION environment variable is deprecated." - warn "" - warn "Please use direct download URLs for version pinning instead:" - warn "" - if [ "$USE_CI_SETUP" = "true" ]; then - warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh -s -- --ci" - else - warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh" - fi - warn "" - fi + warn_deprecated_version_env - # Fetch latest version if VERSION is not set - if [ -z "$VERSION" ]; then - info "Fetching latest release version..." - VERSION=$(fetch_latest_version) - fi + ensure_version # Check if the requested version is already installed if is_version_installed "$VERSION"; then @@ -350,11 +396,7 @@ main() { # Detect platform OS=$(detect_os) ARCH=$(detect_arch) - if [ "$OS" = "win" ]; then - BINARY_NAME="safe-chain-${OS}-${ARCH}.exe" - else - BINARY_NAME="safe-chain-${OS}-${ARCH}" - fi + BINARY_NAME=$(get_binary_name "$OS" "$ARCH") info "Detected platform: ${OS}-${ARCH}" @@ -372,11 +414,7 @@ main() { download "$DOWNLOAD_URL" "$TEMP_FILE" # Rename and make executable - if [ "$OS" = "win" ]; then - FINAL_FILE="${INSTALL_DIR}/safe-chain.exe" - else - FINAL_FILE="${INSTALL_DIR}/safe-chain" - fi + FINAL_FILE=$(get_final_binary_path "$OS") mv "$TEMP_FILE" "$FINAL_FILE" || error "Failed to move binary to $FINAL_FILE" if [ "$OS" != "win" ]; then chmod +x "$FINAL_FILE" || error "Failed to make binary executable" @@ -384,20 +422,7 @@ main() { info "Binary installed to: $FINAL_FILE" - # Build setup command based on arguments - SETUP_CMD="setup" - SETUP_ARGS="" - - if [ "$USE_CI_SETUP" = "true" ]; then - SETUP_CMD="setup-ci" - fi - - # Execute safe-chain setup - info "Running safe-chain $SETUP_CMD $SETUP_ARGS..." - if ! "$FINAL_FILE" $SETUP_CMD $SETUP_ARGS; then - warn "safe-chain was installed but setup encountered issues." - warn "You can run 'safe-chain $SETUP_CMD $SETUP_ARGS' manually later." - fi + run_setup_command "$FINAL_FILE" } main "$@" diff --git a/install-scripts/uninstall-safe-chain.ps1 b/install-scripts/uninstall-safe-chain.ps1 index 2aa3798..fea98f2 100644 --- a/install-scripts/uninstall-safe-chain.ps1 +++ b/install-scripts/uninstall-safe-chain.ps1 @@ -53,23 +53,39 @@ function Get-InstallDirFromBinaryPath { return (Split-Path -Parent $binDir) } -function Get-SafeChainInstallDir { - $command = Get-Command safe-chain -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($command) { - try { - $reportedInstallDir = & safe-chain get-install-dir 2>$null | Select-Object -First 1 - if ($reportedInstallDir) { - $reportedInstallDir = $reportedInstallDir.Trim() - } - if ($reportedInstallDir) { - return $reportedInstallDir - } - } - catch { - # Fall back to deriving the install dir from the discovered command path - } +function Get-SafeChainCommand { + return Get-Command safe-chain -ErrorAction SilentlyContinue | Select-Object -First 1 +} + +function Get-ReportedInstallDir { + $command = Get-SafeChainCommand + if (-not $command) { + return $null } + try { + $reportedInstallDir = & safe-chain get-install-dir 2>$null | Select-Object -First 1 + if ($reportedInstallDir) { + $reportedInstallDir = $reportedInstallDir.Trim() + } + if ($reportedInstallDir) { + return $reportedInstallDir + } + } + catch { + return $null + } + + return $null +} + +function Get-SafeChainInstallDir { + $reportedInstallDir = Get-ReportedInstallDir + if ($reportedInstallDir) { + return $reportedInstallDir + } + + $command = Get-SafeChainCommand if ($command -and $command.Path) { $discoveredInstallDir = Get-InstallDirFromBinaryPath -BinaryPath $command.Path if ($discoveredInstallDir) { @@ -80,6 +96,49 @@ function Get-SafeChainInstallDir { return (Join-Path $HomeDir ".safe-chain") } +function Find-SafeChainBinary { + param([string]$DotSafeChain) + + $safeChainExe = Join-Path $DotSafeChain "bin/safe-chain.exe" + $safeChainBin = Join-Path $DotSafeChain "bin/safe-chain" + + if (Test-Path $safeChainExe) { + return $safeChainExe + } + + if (Test-Path $safeChainBin) { + return $safeChainBin + } + + $command = Get-SafeChainCommand + if ($command) { + return $command.Source + } + + return $null +} + +function Invoke-SafeChainTeardown { + param([string]$SafeChainPath) + + if (-not $SafeChainPath) { + Write-Warn "safe-chain command not found. Proceeding with uninstallation." + return + } + + Write-Info "Running safe-chain teardown..." + try { + & $SafeChainPath teardown + if ($LASTEXITCODE -ne 0) { + Write-Warn "safe-chain teardown encountered issues, continuing with uninstallation..." + } + } + catch { + Write-Warn "safe-chain teardown encountered issues: $_" + Write-Warn "Continuing with uninstallation..." + } +} + # Check and uninstall npm global package if present function Remove-NpmInstallation { # Check if npm is available @@ -133,50 +192,8 @@ function Remove-VoltaInstallation { function Uninstall-SafeChain { Write-Info "Uninstalling safe-chain..." $DotSafeChain = Get-SafeChainInstallDir - $InstallDir = Join-Path $DotSafeChain "bin" - - # Run teardown if safe-chain is available - # Check for both safe-chain.exe (Windows) and safe-chain (Unix) since PowerShell Core runs on all platforms - $safeChainExe = Join-Path $InstallDir "safe-chain.exe" - $safeChainBin = Join-Path $InstallDir "safe-chain" - - $safeChainPath = $null - if (Test-Path $safeChainExe) { - $safeChainPath = $safeChainExe - } - elseif (Test-Path $safeChainBin) { - $safeChainPath = $safeChainBin - } - - if ($safeChainPath) { - Write-Info "Running safe-chain teardown..." - try { - & $safeChainPath teardown - if ($LASTEXITCODE -ne 0) { - Write-Warn "safe-chain teardown encountered issues, continuing with uninstallation..." - } - } - catch { - Write-Warn "safe-chain teardown encountered issues: $_" - Write-Warn "Continuing with uninstallation..." - } - } - elseif (Get-Command safe-chain -ErrorAction SilentlyContinue) { - Write-Info "Running safe-chain teardown..." - try { - safe-chain teardown - if ($LASTEXITCODE -ne 0) { - Write-Warn "safe-chain teardown encountered issues, continuing with uninstallation..." - } - } - catch { - Write-Warn "safe-chain teardown encountered issues: $_" - Write-Warn "Continuing with uninstallation..." - } - } - else { - Write-Warn "safe-chain command not found. Proceeding with uninstallation." - } + $safeChainPath = Find-SafeChainBinary -DotSafeChain $DotSafeChain + Invoke-SafeChainTeardown -SafeChainPath $safeChainPath # Remove npm and Volta installations Remove-NpmInstallation diff --git a/install-scripts/uninstall-safe-chain.sh b/install-scripts/uninstall-safe-chain.sh index 4169e1e..89bb270 100755 --- a/install-scripts/uninstall-safe-chain.sh +++ b/install-scripts/uninstall-safe-chain.sh @@ -87,24 +87,74 @@ derive_install_dir_from_binary() { } get_install_dir() { - if command_exists safe-chain; then - install_dir=$(safe-chain get-install-dir 2>/dev/null || true) - if [ -n "$install_dir" ]; then - printf '%s\n' "$install_dir" - return 0 - fi + reported_install_dir=$(get_reported_install_dir) + if [ -n "$reported_install_dir" ]; then + printf '%s\n' "$reported_install_dir" + return 0 + fi - command_path=$(command -v safe-chain) - install_dir=$(derive_install_dir_from_binary "$command_path" || true) - if [ -n "$install_dir" ]; then - printf '%s\n' "$install_dir" - return 0 - fi + command_path=$(get_safe_chain_command_path) + install_dir=$(derive_install_dir_from_binary "$command_path" || true) + if [ -n "$install_dir" ]; then + printf '%s\n' "$install_dir" + return 0 fi printf '%s\n' "${HOME}/.safe-chain" } +get_safe_chain_command_path() { + if ! command_exists safe-chain; then + return 1 + fi + + command -v safe-chain +} + +get_reported_install_dir() { + if ! command_exists safe-chain; then + return 1 + fi + + install_dir=$(safe-chain get-install-dir 2>/dev/null || true) + if [ -n "$install_dir" ]; then + printf '%s\n' "$install_dir" + return 0 + fi + + return 1 +} + +find_installed_safe_chain_binary() { + dot_safe_chain="$1" + + safe_chain_location="$dot_safe_chain/bin/safe-chain" + if [ -x "$safe_chain_location" ]; then + printf '%s\n' "$safe_chain_location" + return 0 + fi + + command_path=$(get_safe_chain_command_path || true) + if [ -n "$command_path" ]; then + printf '%s\n' "$command_path" + return 0 + fi + + return 1 +} + +run_safe_chain_teardown() { + safe_chain_command="$1" + + if [ -z "$safe_chain_command" ]; then + warn "safe-chain command not found. Proceeding with uninstallation." + return + fi + + info "Running safe-chain teardown..." + "$safe_chain_command" teardown || warn "safe-chain teardown encountered issues, continuing with uninstallation..." +} + # Check and uninstall npm global package if present remove_npm_installation() { if ! command_exists npm; then @@ -211,17 +261,8 @@ remove_nvm_installation() { # Main uninstallation main() { DOT_SAFE_CHAIN=$(get_install_dir) - SAFE_CHAIN_LOCATION="$DOT_SAFE_CHAIN/bin/safe-chain" - - if [ -x "$SAFE_CHAIN_LOCATION" ]; then - info "Running safe-chain teardown..." - "$SAFE_CHAIN_LOCATION" teardown || warn "safe-chain teardown encountered issues, continuing with uninstallation..." - elif command_exists safe-chain; then - info "Running safe-chain teardown..." - safe-chain teardown || warn "safe-chain teardown encountered issues, continuing with uninstallation..." - else - warn "safe-chain command not found. Proceeding with uninstallation." - fi + SAFE_CHAIN_COMMAND=$(find_installed_safe_chain_binary "$DOT_SAFE_CHAIN" || true) + run_safe_chain_teardown "$SAFE_CHAIN_COMMAND" # Check for existing safe-chain installation through nvm, volta, or npm remove_npm_installation @@ -235,7 +276,6 @@ main() { else info "Installation directory $DOT_SAFE_CHAIN does not exist. Nothing to remove." fi - } main "$@" From 60732c5b6aba0283551c8b80bc21bea628c33b25 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 12:21:31 -0700 Subject: [PATCH 284/360] Test --- .../src/shell-integration/setup-ci.spec.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/setup-ci.spec.js b/packages/safe-chain/src/shell-integration/setup-ci.spec.js index 7d092ab..de570f5 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.spec.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.spec.js @@ -22,12 +22,12 @@ describe("Setup CI shell integration", () => { fs.mkdirSync(path.join(mockTemplateDir, "path-wrappers", "templates"), { recursive: true }); fs.writeFileSync( path.join(mockTemplateDir, "path-wrappers", "templates", "unix-wrapper.template.sh"), - "#!/bin/bash\n# Template for {{PACKAGE_MANAGER}}\nSHIM_DIR=\"{{SHIMS_DIR}}\"\nexec {{AIKIDO_COMMAND}} \"$@\"\n", + "#!/bin/bash\n# Template for {{PACKAGE_MANAGER}}\n_safe_chain_shims=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" 2>/dev/null && pwd -P)\nexec {{AIKIDO_COMMAND}} \"$@\"\n", "utf-8" ); fs.writeFileSync( path.join(mockTemplateDir, "path-wrappers", "templates", "windows-wrapper.template.cmd"), - "@echo off\nset \"SHIM_DIR={{SHIMS_DIR}}\"\n{{AIKIDO_COMMAND}} %*\n", + "@echo off\nset \"SHIM_DIR=%~dp0\"\n{{AIKIDO_COMMAND}} %*\n", "utf-8" ); @@ -121,8 +121,8 @@ describe("Setup CI shell integration", () => { assert.ok(npmShimContent.includes("aikido-npm"), "npm shim should contain aikido-npm"); assert.ok(npmShimContent.includes("#!/bin/bash"), "npm shim should have bash shebang"); assert.ok( - npmShimContent.includes(`SHIM_DIR="${mockShimsDir}"`), - "npm shim should embed the generated shims directory", + npmShimContent.includes("_safe_chain_shims=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" 2>/dev/null && pwd -P)"), + "npm shim should derive the shims directory from its own location", ); }); @@ -148,8 +148,8 @@ describe("Setup CI shell integration", () => { assert.ok(npmShimContent.includes("@echo off"), "npm.cmd should have Windows batch header"); assert.ok(npmShimContent.includes("%*"), "npm.cmd should use Windows argument passing"); assert.ok( - npmShimContent.includes(`set "SHIM_DIR=${mockShimsDir}"`), - "npm.cmd should embed the generated shims directory", + npmShimContent.includes('set "SHIM_DIR=%~dp0"'), + "npm.cmd should derive the shims directory from its own location", ); // Verify Unix shims were NOT created From 38a8130f4a331e4d9e5171202b899bf28dddddc0 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 13:32:55 -0700 Subject: [PATCH 285/360] Some fixes --- packages/safe-chain/src/installLocation.js | 5 ++++- .../src/shell-integration/startup-scripts/init-posix.sh | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/safe-chain/src/installLocation.js b/packages/safe-chain/src/installLocation.js index efe687a..52125be 100644 --- a/packages/safe-chain/src/installLocation.js +++ b/packages/safe-chain/src/installLocation.js @@ -1,5 +1,8 @@ import path from "path"; +/** @type {NodeJS.Process & { pkg?: unknown }} */ +const processWithPkg = process; + /** * @param {string} executablePath * @returns {string | undefined} @@ -28,7 +31,7 @@ export function deriveInstallDirFromExecutablePath(executablePath) { * @returns {string | undefined} */ export function getInstalledSafeChainDir(options = {}) { - const isPackaged = options.isPackaged ?? Boolean(process.pkg); + const isPackaged = options.isPackaged ?? Boolean(processWithPkg.pkg); if (!isPackaged) { return undefined; } diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh index 4235276..ebc10c4 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh @@ -5,7 +5,7 @@ _get_safe_chain_script_path() { fi if [ -n "${ZSH_VERSION:-}" ]; then - eval 'printf "%s\n" "${(%):-%N}"' + eval 'printf "%s\n" "${(%):-%x}"' return fi From 8dbeab8dac6c2528c2a39c85954f1cb141fa4b27 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 13:45:20 -0700 Subject: [PATCH 286/360] Address code quality --- install-scripts/uninstall-safe-chain.ps1 | 27 ++++++++++++++++-------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/install-scripts/uninstall-safe-chain.ps1 b/install-scripts/uninstall-safe-chain.ps1 index fea98f2..1304247 100644 --- a/install-scripts/uninstall-safe-chain.ps1 +++ b/install-scripts/uninstall-safe-chain.ps1 @@ -57,14 +57,28 @@ function Get-SafeChainCommand { return Get-Command safe-chain -ErrorAction SilentlyContinue | Select-Object -First 1 } -function Get-ReportedInstallDir { +function Get-ValidatedSafeChainCommandPath { $command = Get-SafeChainCommand - if (-not $command) { + if (-not $command -or [string]::IsNullOrWhiteSpace($command.Path)) { + return $null + } + + $installDir = Get-InstallDirFromBinaryPath -BinaryPath $command.Path + if (-not $installDir) { + return $null + } + + return $command.Path +} + +function Get-ReportedInstallDir { + $safeChainPath = Get-ValidatedSafeChainCommandPath + if (-not $safeChainPath) { return $null } try { - $reportedInstallDir = & safe-chain get-install-dir 2>$null | Select-Object -First 1 + $reportedInstallDir = & $safeChainPath get-install-dir 2>$null | Select-Object -First 1 if ($reportedInstallDir) { $reportedInstallDir = $reportedInstallDir.Trim() } @@ -110,12 +124,7 @@ function Find-SafeChainBinary { return $safeChainBin } - $command = Get-SafeChainCommand - if ($command) { - return $command.Source - } - - return $null + return Get-ValidatedSafeChainCommandPath } function Invoke-SafeChainTeardown { From 1076d6bea820b643b43303ea6f0918c6f46d0eb4 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 14:05:02 -0700 Subject: [PATCH 287/360] Undo timeout change --- test/e2e/DockerTestContainer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/DockerTestContainer.js b/test/e2e/DockerTestContainer.js index 4e831d3..cd48c4e 100644 --- a/test/e2e/DockerTestContainer.js +++ b/test/e2e/DockerTestContainer.js @@ -128,7 +128,7 @@ export class DockerTestContainer { console.log("Command timeout reached"); resolve({ allData, output: parseShellOutput(allData), command }); ptyProcess.removeListener("data", handleInput); - }, 30000); + }, 15000); function handleInput(data) { allData.push(data); From e54869ddd054658f3d6c694d600a048b1ce87dcb Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 14:40:42 -0700 Subject: [PATCH 288/360] Code Quality --- install-scripts/install-safe-chain.ps1 | 2 +- .../templates/unix-wrapper.template.sh | 4 ++++ .../startup-scripts/init-fish.fish | 3 ++- .../startup-scripts/init-posix.sh | 24 +++++++------------ 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index 3c43861..f870123 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -38,7 +38,7 @@ function Test-InstallDir { } $Version = $env:SAFE_CHAIN_VERSION # Will be fetched from latest release if not set -$SafeChainBase = if ($InstallDir) { $InstallDir } else { Join-Path $env:USERPROFILE ".safe-chain" } +$SafeChainBase = if ($InstallDir) { $InstallDir } else { Join-Path $HOME ".safe-chain" } $installDirValidation = Test-InstallDir -Dir $SafeChainBase if (-not $installDirValidation.Ok) { diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh index 9275230..2547a01 100644 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh @@ -5,6 +5,10 @@ # Function to remove shim from PATH (POSIX-compliant) remove_shim_from_path() { _safe_chain_shims=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd -P) + if [ -z "$_safe_chain_shims" ]; then + echo "$PATH" + return + fi echo "$PATH" | sed "s|${_safe_chain_shims}:||g" } diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish index e0cc9ec..4469d3f 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish @@ -1,5 +1,6 @@ set -l safe_chain_script (status filename) -set -l safe_chain_base (path dirname (path dirname $safe_chain_script)) +set -l safe_chain_scripts_dir (dirname $safe_chain_script) +set -l safe_chain_base (dirname $safe_chain_scripts_dir) set -gx PATH $PATH $safe_chain_base/bin function npx diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh index ebc10c4..b79a31c 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh @@ -1,18 +1,12 @@ -_get_safe_chain_script_path() { - if [ -n "${BASH_SOURCE[0]:-}" ]; then - printf '%s\n' "${BASH_SOURCE[0]}" - return - fi - - if [ -n "${ZSH_VERSION:-}" ]; then - eval 'printf "%s\n" "${(%):-%x}"' - return - fi - - printf '%s\n' "$0" -} - -_sc_script_path="$(_get_safe_chain_script_path)" +if [ -n "${BASH_SOURCE[0]:-}" ]; then + _sc_script_path="${BASH_SOURCE[0]}" +elif [ -n "${ZSH_VERSION:-}" ]; then + # ${(%):-%x} uses Zsh prompt expansion to get the sourced file's path. + # eval is required so other shells don't try to parse the Zsh-specific syntax. + eval '_sc_script_path="${(%):-%x}"' +else + _sc_script_path="$0" +fi _sc_scripts_dir=$(CDPATH= cd -- "$(dirname -- "$_sc_script_path")" 2>/dev/null && pwd -P) _sc_base=$(dirname -- "$_sc_scripts_dir") export PATH="$PATH:${_sc_base}/bin" From 50623cfc9a69cc7f1c1691fd79a5adf77e343126 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 15:02:41 -0700 Subject: [PATCH 289/360] Fix empty arg --- install-scripts/install-safe-chain.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 242dcf2..2335ae3 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -343,10 +343,16 @@ parse_arguments() { if [ $# -eq 0 ]; then error "Missing value for --install-dir" fi + if [ -z "$1" ]; then + error "--install-dir must not be empty" + fi SAFE_CHAIN_BASE="$1" ;; --install-dir=*) SAFE_CHAIN_BASE="${1#--install-dir=}" + if [ -z "$SAFE_CHAIN_BASE" ]; then + error "--install-dir must not be empty" + fi ;; --include-python) warn "--include-python is deprecated and ignored. Python ecosystem is now included by default." From 7dd68cea12e3fb10d57fa8f2110eb4fc165a52c5 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 15:10:52 -0700 Subject: [PATCH 290/360] Clean up readme --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6a39aea..a7e504f 100644 --- a/README.md +++ b/README.md @@ -322,18 +322,18 @@ By default, Safe Chain installs itself into `~/.safe-chain`. You can change this When set, all Safe Chain data (binary, shims, scripts, config) is placed under the custom directory instead of `~/.safe-chain`. +### Unix/Linux/macOS + ```shell curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --install-dir /usr/local/.safe-chain ``` -On Windows, use `-InstallDir`: +### Windows ```powershell iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -InstallDir 'C:\ProgramData\safe-chain'" ``` -This is a one-time installer choice. Runtime shell integration and uninstall now discover the installation from the installed scripts or binary and do not rely on an environment variable. - # Usage in CI/CD You can protect your CI/CD pipelines from malicious packages by integrating Aikido Safe Chain into your build process. This ensures that any packages installed during your automated builds are checked for malware before installation. From f3ae77f12acd33eeb0a5abb8f20f128afb49a5ce Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 15:21:49 -0700 Subject: [PATCH 291/360] Quality issue --- install-scripts/uninstall-safe-chain.sh | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/install-scripts/uninstall-safe-chain.sh b/install-scripts/uninstall-safe-chain.sh index 89bb270..7a7cb7d 100755 --- a/install-scripts/uninstall-safe-chain.sh +++ b/install-scripts/uninstall-safe-chain.sh @@ -111,12 +111,27 @@ get_safe_chain_command_path() { command -v safe-chain } -get_reported_install_dir() { - if ! command_exists safe-chain; then +get_validated_safe_chain_command_path() { + command_path=$(get_safe_chain_command_path || true) + if [ -z "$command_path" ]; then return 1 fi - install_dir=$(safe-chain get-install-dir 2>/dev/null || true) + install_dir=$(derive_install_dir_from_binary "$command_path" || true) + if [ -z "$install_dir" ]; then + return 1 + fi + + printf '%s\n' "$command_path" +} + +get_reported_install_dir() { + safe_chain_path=$(get_validated_safe_chain_command_path || true) + if [ -z "$safe_chain_path" ]; then + return 1 + fi + + install_dir=$("$safe_chain_path" get-install-dir 2>/dev/null || true) if [ -n "$install_dir" ]; then printf '%s\n' "$install_dir" return 0 @@ -134,7 +149,7 @@ find_installed_safe_chain_binary() { return 0 fi - command_path=$(get_safe_chain_command_path || true) + command_path=$(get_validated_safe_chain_command_path || true) if [ -n "$command_path" ]; then printf '%s\n' "$command_path" return 0 From 63b7a5ee5ef94b31e1504bb07680760881aed774 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 13 Apr 2026 21:40:53 -0700 Subject: [PATCH 292/360] Add better doc --- install-scripts/install-safe-chain.ps1 | 8 ++++++++ install-scripts/install-safe-chain.sh | 9 +++++++++ install-scripts/uninstall-safe-chain.ps1 | 14 ++++++++++++++ install-scripts/uninstall-safe-chain.sh | 16 ++++++++++++++++ 4 files changed, 47 insertions(+) diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index f870123..0d7b745 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -8,6 +8,8 @@ param( [string]$InstallDir ) +# Validates and normalizes the requested install directory. +# Rejects non-absolute, root, PATH-like, and traversal-containing paths. function Test-InstallDir { param([string]$Dir) @@ -137,6 +139,8 @@ function Get-Architecture { } } +# Emits the deprecation warning for SAFE_CHAIN_VERSION and prints the version-pinned install command. +# Returns immediately when no version was provided through the environment. function Write-VersionDeprecationWarning { if ([string]::IsNullOrWhiteSpace($env:SAFE_CHAIN_VERSION)) { return @@ -154,12 +158,16 @@ function Write-VersionDeprecationWarning { Write-Warn "" } +# Builds the Windows release binary filename for the detected architecture. +# Centralizes binary name generation for the download step. function Get-BinaryName { param([string]$Architecture) return "safe-chain-win-$Architecture.exe" } +# 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 { param( [string]$BinaryPath, diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 2335ae3..763dab6 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -6,6 +6,8 @@ set -e # Exit on error +# Validates a user-provided install dir and exits on unsafe values. +# Rejects relative paths, root paths, PATH separators, and traversal segments. validate_install_dir() { dir="$1" @@ -168,6 +170,8 @@ download() { fi } +# Prints the deprecation warning for SAFE_CHAIN_VERSION and the replacement install command. +# Returns immediately when no version was pinned through the environment. warn_deprecated_version_env() { if [ -z "$SAFE_CHAIN_VERSION" ]; then return @@ -185,6 +189,8 @@ warn_deprecated_version_env() { warn "" } +# Ensures VERSION is populated before installation continues. +# Fetches the latest release only when no explicit version was provided. ensure_version() { if [ -n "$VERSION" ]; then return @@ -194,6 +200,7 @@ ensure_version() { VERSION=$(fetch_latest_version) } +# Returns the release binary filename for the detected OS and architecture. get_binary_name() { os="$1" arch="$2" @@ -205,6 +212,8 @@ get_binary_name() { fi } +# Returns the final installation path for the downloaded safe-chain binary. +# Uses INSTALL_DIR and the platform-specific executable name. get_final_binary_path() { os="$1" diff --git a/install-scripts/uninstall-safe-chain.ps1 b/install-scripts/uninstall-safe-chain.ps1 index 1304247..6e24d5d 100644 --- a/install-scripts/uninstall-safe-chain.ps1 +++ b/install-scripts/uninstall-safe-chain.ps1 @@ -22,6 +22,8 @@ function Write-Error-Custom { exit 1 } +# Derives the safe-chain base install directory from a resolved binary path. +# Rejects wrapper scripts and paths that do not match the packaged bin layout. function Get-InstallDirFromBinaryPath { param([string]$BinaryPath) @@ -53,10 +55,14 @@ function Get-InstallDirFromBinaryPath { return (Split-Path -Parent $binDir) } +# Returns the first safe-chain command found on PATH, if any. +# Used as the starting point for install-dir discovery. function Get-SafeChainCommand { return Get-Command safe-chain -ErrorAction SilentlyContinue | Select-Object -First 1 } +# Returns the safe-chain command path only when it points to a valid packaged binary install. +# Prevents teardown from invoking arbitrary wrappers or scripts from PATH. function Get-ValidatedSafeChainCommandPath { $command = Get-SafeChainCommand if (-not $command -or [string]::IsNullOrWhiteSpace($command.Path)) { @@ -71,6 +77,8 @@ function Get-ValidatedSafeChainCommandPath { return $command.Path } +# Invokes the validated safe-chain binary with get-install-dir and returns the reported base directory. +# Safely returns $null when the command is unavailable or the lookup fails. function Get-ReportedInstallDir { $safeChainPath = Get-ValidatedSafeChainCommandPath if (-not $safeChainPath) { @@ -93,6 +101,8 @@ function Get-ReportedInstallDir { return $null } +# Determines the safe-chain base install directory for uninstall. +# Prefers the binary-reported location, then derives it from PATH, then falls back to the default home-dir layout. function Get-SafeChainInstallDir { $reportedInstallDir = Get-ReportedInstallDir if ($reportedInstallDir) { @@ -110,6 +120,8 @@ function Get-SafeChainInstallDir { return (Join-Path $HomeDir ".safe-chain") } +# Finds the installed safe-chain binary inside the resolved install directory. +# Falls back to a validated safe-chain command when the expected file is missing. function Find-SafeChainBinary { param([string]$DotSafeChain) @@ -127,6 +139,8 @@ function Find-SafeChainBinary { return Get-ValidatedSafeChainCommandPath } +# Runs safe-chain teardown before removing the installation directory. +# Converts teardown failures into warnings so uninstall can still complete. function Invoke-SafeChainTeardown { param([string]$SafeChainPath) diff --git a/install-scripts/uninstall-safe-chain.sh b/install-scripts/uninstall-safe-chain.sh index 7a7cb7d..abe235f 100755 --- a/install-scripts/uninstall-safe-chain.sh +++ b/install-scripts/uninstall-safe-chain.sh @@ -33,6 +33,8 @@ command_exists() { command -v "$1" >/dev/null 2>&1 } +# Resolves a path to its canonical filesystem location when possible. +# Follows symlinks so binary validation can inspect the real installed path. resolve_path() { target="$1" @@ -60,6 +62,8 @@ resolve_path() { fi } +# Derives the safe-chain base install directory from a packaged binary path. +# Rejects wrapper scripts and paths that do not match the expected bin layout. derive_install_dir_from_binary() { binary_path="$1" @@ -86,6 +90,8 @@ derive_install_dir_from_binary() { dirname "$binary_dir" } +# Determines the installed safe-chain base directory for uninstall. +# Prefers the binary-reported location, then infers it from PATH, then falls back to ~/.safe-chain. get_install_dir() { reported_install_dir=$(get_reported_install_dir) if [ -n "$reported_install_dir" ]; then @@ -103,6 +109,8 @@ get_install_dir() { printf '%s\n' "${HOME}/.safe-chain" } +# Returns the current safe-chain command path from PATH. +# Fails when safe-chain is not currently resolvable. get_safe_chain_command_path() { if ! command_exists safe-chain; then return 1 @@ -111,6 +119,8 @@ get_safe_chain_command_path() { command -v safe-chain } +# Returns the safe-chain command path only when it resolves to a valid packaged binary install. +# Prevents the uninstaller from invoking arbitrary PATH entries. get_validated_safe_chain_command_path() { command_path=$(get_safe_chain_command_path || true) if [ -z "$command_path" ]; then @@ -125,6 +135,8 @@ get_validated_safe_chain_command_path() { printf '%s\n' "$command_path" } +# Asks the validated safe-chain binary for its install directory via get-install-dir. +# Returns nothing if the command is unavailable or the lookup fails. get_reported_install_dir() { safe_chain_path=$(get_validated_safe_chain_command_path || true) if [ -z "$safe_chain_path" ]; then @@ -140,6 +152,8 @@ get_reported_install_dir() { return 1 } +# Locates the installed safe-chain binary to use for teardown. +# Checks the discovered install dir first, then falls back to a validated PATH entry. find_installed_safe_chain_binary() { dot_safe_chain="$1" @@ -158,6 +172,8 @@ find_installed_safe_chain_binary() { return 1 } +# Runs safe-chain teardown before removing files. +# Continues with uninstall even if teardown is unavailable or fails. run_safe_chain_teardown() { safe_chain_command="$1" From 14c8abffea02a18e310cd0a3ce2125a02c4848d7 Mon Sep 17 00:00:00 2001 From: Stephen Benjamin Date: Tue, 17 Mar 2026 17:12:42 -0400 Subject: [PATCH 293/360] Add uvx support Add uvx as a supported package manager so that `uvx` commands are routed through safe-chain's MITM proxy for malware detection, just like `uv`. Previously, `uvx` bypassed all safe-chain protections. The uvx package manager reuses the existing uv command runner since uvx is functionally equivalent to `uv tool run`. Fixes #268 Co-Authored-By: Claude Opus 4.6 --- README.md | 9 +++++---- docs/shell-integration.md | 8 ++++---- npm-shrinkwrap.json | 2 ++ packages/safe-chain/bin/aikido-uvx.js | 16 ++++++++++++++++ packages/safe-chain/package.json | 3 ++- .../packagemanager/currentPackageManager.js | 3 +++ .../uvx/createUvxPackageManager.js | 18 ++++++++++++++++++ .../uvx/createUvxPackageManager.spec.js | 14 ++++++++++++++ .../src/shell-integration/helpers.js | 6 ++++++ .../startup-scripts/init-fish.fish | 4 ++++ .../startup-scripts/init-posix.sh | 4 ++++ .../startup-scripts/init-pwsh.ps1 | 4 ++++ 12 files changed, 82 insertions(+), 9 deletions(-) create mode 100755 packages/safe-chain/bin/aikido-uvx.js create mode 100644 packages/safe-chain/src/packagemanager/uvx/createUvxPackageManager.js create mode 100644 packages/safe-chain/src/packagemanager/uvx/createUvxPackageManager.spec.js diff --git a/README.md b/README.md index 3e73137..1bc4858 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Aikido Safe Chain supports the following package managers: - 📦 **pip3** - 📦 **uv** - 📦 **poetry** +- 📦 **uvx** - 📦 **pipx** # Usage @@ -66,7 +67,7 @@ You can find all available versions on the [releases page](https://github.com/Ai ### Verify the installation 1. **❗Restart your terminal** to start using the Aikido Safe Chain. - - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, poetry, uv and pipx are loaded correctly. If you do not restart your terminal, the aliases will not be available. + - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, poetry, uv, uvx and pipx are loaded correctly. If you do not restart your terminal, the aliases will not be available. 2. **Verify the installation** by running the verification command: @@ -97,7 +98,7 @@ You can find all available versions on the [releases page](https://github.com/Ai - The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware. -When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry` and `pipx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command. +When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command. You can check the installed version by running: @@ -109,7 +110,7 @@ safe-chain --version ### Malware Blocking -The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, uv, poetry or pipx commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. +The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, uv, uvx, poetry or pipx commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. ### Minimum package age @@ -128,7 +129,7 @@ By default, the minimum package age is 48 hours. This provides an additional sec ### Shell Integration -The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and Python package managers (pip, uv, poetry, pipx). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support: +The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and Python package managers (pip, uv, uvx, poetry, pipx). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support: - ✅ **Bash** - ✅ **Zsh** diff --git a/docs/shell-integration.md b/docs/shell-integration.md index 6b08fac..2e36d0a 100644 --- a/docs/shell-integration.md +++ b/docs/shell-integration.md @@ -2,7 +2,7 @@ ## Overview -The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry`, `pipx`) with Aikido's security scanning functionality. It also intercepts Python module invocations for pip when available: `python -m pip`, `python -m pip3`, `python3 -m pip`, `python3 -m pip3`. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents. +The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry`, `pipx`) with Aikido's security scanning functionality. It also intercepts Python module invocations for pip when available: `python -m pip`, `python -m pip3`, `python3 -m pip`, `python3 -m pip3`. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents. ## Supported Shells @@ -28,7 +28,7 @@ This command: - Copies necessary startup scripts to Safe Chain's installation directory (`~/.safe-chain/scripts`) - Detects all supported shells on your system -- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry` and `pipx` +- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` - Adds lightweight interceptors so `python -m pip[...]` and `python3 -m pip[...]` route through Safe Chain when invoked by name ❗ After running this command, **you must restart your terminal** for the changes to take effect. This ensures that the startup scripts are sourced correctly. @@ -78,7 +78,7 @@ The system modifies the following files to source Safe Chain startup scripts: This means the shell functions are working but the Aikido commands aren't installed or available in your PATH: - Make sure Aikido Safe Chain is properly installed on your system -- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-bun`, `aikido-bunx`, `aikido-pip`, `aikido-pip3`, `aikido-uv`, `aikido-poetry` and `aikido-pipx` commands exist +- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-bun`, `aikido-bunx`, `aikido-pip`, `aikido-pip3`, `aikido-uv`, `aikido-uvx`, `aikido-poetry` and `aikido-pipx` commands exist - Check that these commands are in your system's PATH ### Manual Verification @@ -121,7 +121,7 @@ npm() { } ``` -Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry` and `pipx` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes. +Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes. To intercept Python module invocations for pip without altering Python itself, you can add small forwarding functions: diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index c852d4f..9b8fc33 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -2417,6 +2417,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3138,6 +3139,7 @@ "aikido-python": "bin/aikido-python.js", "aikido-python3": "bin/aikido-python3.js", "aikido-uv": "bin/aikido-uv.js", + "aikido-uvx": "bin/aikido-uvx.js", "aikido-yarn": "bin/aikido-yarn.js", "safe-chain": "bin/safe-chain.js" }, diff --git a/packages/safe-chain/bin/aikido-uvx.js b/packages/safe-chain/bin/aikido-uvx.js new file mode 100755 index 0000000..10bb9f3 --- /dev/null +++ b/packages/safe-chain/bin/aikido-uvx.js @@ -0,0 +1,16 @@ +#!/usr/bin/env node + +import { main } from "../src/main.js"; +import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js"; + +// Set eco system +setEcoSystem(ECOSYSTEM_PY); + +initializePackageManager("uvx"); + +(async () => { + // Pass through only user-supplied uvx args + var exitCode = await main(process.argv.slice(2)); + process.exit(exitCode); +})(); diff --git a/packages/safe-chain/package.json b/packages/safe-chain/package.json index 3d527cb..8530b68 100644 --- a/packages/safe-chain/package.json +++ b/packages/safe-chain/package.json @@ -16,6 +16,7 @@ "aikido-bun": "bin/aikido-bun.js", "aikido-bunx": "bin/aikido-bunx.js", "aikido-uv": "bin/aikido-uv.js", + "aikido-uvx": "bin/aikido-uvx.js", "aikido-pip": "bin/aikido-pip.js", "aikido-pip3": "bin/aikido-pip3.js", "aikido-python": "bin/aikido-python.js", @@ -36,7 +37,7 @@ "keywords": [], "author": "Aikido Security", "license": "AGPL-3.0-or-later", - "description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), and [pip](https://pip.pypa.io/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, bunx, uv, or pip/pip3 from downloading or running the malware.", + "description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), and [pip](https://pip.pypa.io/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, bunx, uv, uvx, or pip/pip3 from downloading or running the malware.", "dependencies": { "certifi": "14.5.15", "chalk": "5.4.1", diff --git a/packages/safe-chain/src/packagemanager/currentPackageManager.js b/packages/safe-chain/src/packagemanager/currentPackageManager.js index af297dc..2291fd1 100644 --- a/packages/safe-chain/src/packagemanager/currentPackageManager.js +++ b/packages/safe-chain/src/packagemanager/currentPackageManager.js @@ -13,6 +13,7 @@ import { createPipPackageManager } from "./pip/createPackageManager.js"; import { createUvPackageManager } from "./uv/createUvPackageManager.js"; import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js"; import { createPipXPackageManager } from "./pipx/createPipXPackageManager.js"; +import { createUvxPackageManager } from "./uvx/createUvxPackageManager.js"; /** * @type {{packageManagerName: PackageManager | null}} @@ -60,6 +61,8 @@ export function initializePackageManager(packageManagerName, context) { state.packageManagerName = createPipPackageManager(context); } else if (packageManagerName === "uv") { state.packageManagerName = createUvPackageManager(); + } else if (packageManagerName === "uvx") { + state.packageManagerName = createUvxPackageManager(); } else if (packageManagerName === "poetry") { state.packageManagerName = createPoetryPackageManager(); } else if (packageManagerName === "pipx") { diff --git a/packages/safe-chain/src/packagemanager/uvx/createUvxPackageManager.js b/packages/safe-chain/src/packagemanager/uvx/createUvxPackageManager.js new file mode 100644 index 0000000..18a7089 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/uvx/createUvxPackageManager.js @@ -0,0 +1,18 @@ +import { runUv } from "../uv/runUvCommand.js"; + +/** + * @returns {import("../currentPackageManager.js").PackageManager} + */ +export function createUvxPackageManager() { + return { + /** + * @param {string[]} args + */ + runCommand: (args) => { + return runUv("uvx", args); + }, + // For uvx, rely solely on MITM + isSupportedCommand: () => false, + getDependencyUpdatesForCommand: () => [], + }; +} diff --git a/packages/safe-chain/src/packagemanager/uvx/createUvxPackageManager.spec.js b/packages/safe-chain/src/packagemanager/uvx/createUvxPackageManager.spec.js new file mode 100644 index 0000000..6eb87a0 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/uvx/createUvxPackageManager.spec.js @@ -0,0 +1,14 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { createUvxPackageManager } from "./createUvxPackageManager.js"; + +test("createUvxPackageManager returns valid package manager interface", () => { + const pm = createUvxPackageManager(); + + assert.ok(pm); + assert.strictEqual(typeof pm.runCommand, "function"); + assert.strictEqual(typeof pm.isSupportedCommand, "function"); + assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function"); + assert.strictEqual(pm.isSupportedCommand(), false); + assert.deepStrictEqual(pm.getDependencyUpdatesForCommand(), []); +}); diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index 18ba52e..dd86462 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -66,6 +66,12 @@ export const knownAikidoTools = [ ecoSystem: ECOSYSTEM_PY, internalPackageManagerName: "uv", }, + { + tool: "uvx", + aikidoCommand: "aikido-uvx", + ecoSystem: ECOSYSTEM_PY, + internalPackageManagerName: "uvx", + }, { tool: "pip", aikidoCommand: "aikido-pip", diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish index 13463f6..fdb501f 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish @@ -51,6 +51,10 @@ function uv wrapSafeChainCommand "uv" $argv end +function uvx + wrapSafeChainCommand "uvx" $argv +end + function poetry wrapSafeChainCommand "poetry" $argv end diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh index ebaaf3c..ea09ef0 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh @@ -47,6 +47,10 @@ function uv() { wrapSafeChainCommand "uv" "$@" } +function uvx() { + wrapSafeChainCommand "uvx" "$@" +} + function poetry() { wrapSafeChainCommand "poetry" "$@" } diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 index f82d0fc..4cdefee 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 @@ -52,6 +52,10 @@ function uv { Invoke-WrappedCommand "uv" $args $MyInvocation.Line $MyInvocation.OffsetInLine } +function uvx { + Invoke-WrappedCommand "uvx" $args $MyInvocation.Line $MyInvocation.OffsetInLine +} + function poetry { Invoke-WrappedCommand "poetry" $args $MyInvocation.Line $MyInvocation.OffsetInLine } From 8e4f036ce9b07ee43676951041baa120f0536ecb Mon Sep 17 00:00:00 2001 From: Stephen Benjamin Date: Wed, 8 Apr 2026 15:52:35 -0400 Subject: [PATCH 294/360] Add e2e test for UVX --- test/e2e/uvx.e2e.spec.js | 132 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 test/e2e/uvx.e2e.spec.js diff --git a/test/e2e/uvx.e2e.spec.js b/test/e2e/uvx.e2e.spec.js new file mode 100644 index 0000000..12dfc0f --- /dev/null +++ b/test/e2e/uvx.e2e.spec.js @@ -0,0 +1,132 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +describe("E2E: uvx coverage", () => { + let container; + + before(async () => { + DockerTestContainer.buildImage(); + }); + + beforeEach(async () => { + container = new DockerTestContainer(); + await container.start(); + + const installationShell = await container.openShell("zsh"); + await installationShell.runCommand("safe-chain setup"); + + // Clear uv cache + await installationShell.runCommand("uv cache clean"); + }); + + afterEach(async () => { + if (container) { + await container.stop(); + container = null; + } + }); + + it(`successfully runs a known safe tool with uvx`, async () => { + const shell = await container.openShell("zsh"); + + const result = await shell.runCommand( + "uvx ruff --version --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found.") || /ruff/i.test(result.output), + `Expected safe tool to run successfully. Output was:\n${result.output}` + ); + }); + + it(`safe-chain blocks malicious packages via uvx`, async () => { + const shell = await container.openShell("zsh"); + + const result = await shell.runCommand( + "uvx safe-chain-pi-test" + ); + + assert.ok( + result.output.includes("blocked by safe-chain"), + `Expected malicious package to be blocked. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected exit message. Output was:\n${result.output}` + ); + }); + + it(`uvx with --from flag runs a safe tool`, async () => { + const shell = await container.openShell("zsh"); + + const result = await shell.runCommand( + "uvx --from ruff ruff --version --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found.") || /ruff/i.test(result.output), + `Expected safe tool to run successfully with --from. Output was:\n${result.output}` + ); + }); + + it(`uvx with --from flag blocks malicious packages`, async () => { + const shell = await container.openShell("zsh"); + + const result = await shell.runCommand( + "uvx --from safe-chain-pi-test some-command" + ); + + assert.ok( + result.output.includes("blocked by safe-chain"), + `Expected malicious package to be blocked with --from. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected exit message. Output was:\n${result.output}` + ); + }); + + it(`uvx with specific version runs successfully`, async () => { + const shell = await container.openShell("zsh"); + + const result = await shell.runCommand( + "uvx ruff@0.4.0 --version --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found.") || /ruff/i.test(result.output), + `Expected safe tool with version to run. Output was:\n${result.output}` + ); + }); + + it(`uvx with --with flag for additional dependencies`, async () => { + const shell = await container.openShell("zsh"); + + const result = await shell.runCommand( + "uvx --with requests ruff --version --safe-chain-logging=verbose" + ); + + assert.ok( + result.output.includes("no malware found.") || /ruff/i.test(result.output), + `Expected safe tool with --with dependency to run. Output was:\n${result.output}` + ); + }); + + it(`uvx with --with flag blocks malicious additional dependencies`, async () => { + const shell = await container.openShell("zsh"); + + const result = await shell.runCommand( + "uvx --with safe-chain-pi-test ruff --version" + ); + + assert.ok( + result.output.includes("blocked by safe-chain"), + `Expected malicious --with dependency to be blocked. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Expected exit message. Output was:\n${result.output}` + ); + }); +}); From 43fe715b088c95f054e5cff49dac615420461069 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Tue, 14 Apr 2026 11:08:04 -0700 Subject: [PATCH 295/360] Update install-scripts/install-safe-chain.sh Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> --- install-scripts/install-safe-chain.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 763dab6..da7d3c0 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -200,7 +200,7 @@ ensure_version() { VERSION=$(fetch_latest_version) } -# Returns the release binary filename for the detected OS and architecture. +# Constructs platform-specific binary filename to match GitHub release asset naming convention. get_binary_name() { os="$1" arch="$2" From 6ff2ee33674e6a4f64422aad4e56ec6ffef89bd7 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Tue, 14 Apr 2026 11:30:29 -0700 Subject: [PATCH 296/360] Adapt per review --- install-scripts/install-safe-chain.ps1 | 10 ++-- .../safe-chain/src/config/safeChainDir.js | 47 +++++++++++++++++++ .../safe-chain/src/registryProxy/certUtils.js | 10 ++-- .../src/registryProxy/certUtils.spec.js | 1 + .../src/shell-integration/helpers.js | 29 ------------ .../src/shell-integration/helpers.spec.js | 13 +++-- .../src/shell-integration/setup-ci.js | 36 ++++---------- .../src/shell-integration/setup-ci.spec.js | 23 +++------ .../safe-chain/src/shell-integration/setup.js | 24 ++-------- .../supported-shells/bash.js | 2 +- .../supported-shells/bash.spec.js | 7 ++- .../supported-shells/fish.js | 2 +- .../supported-shells/fish.spec.js | 7 ++- .../supported-shells/powershell.js | 2 +- .../supported-shells/powershell.spec.js | 5 ++ .../supported-shells/windowsPowershell.js | 2 +- .../windowsPowershell.spec.js | 5 ++ .../shell-integration/supported-shells/zsh.js | 2 +- .../supported-shells/zsh.spec.js | 7 ++- .../src/shell-integration/teardown.js | 3 +- 20 files changed, 118 insertions(+), 119 deletions(-) diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index 0d7b745..a11edf6 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -25,17 +25,17 @@ function Test-InstallDir { return @{ Ok = $false; Reason = "-InstallDir must not contain the PATH separator ($([System.IO.Path]::PathSeparator))" } } + $inputSegments = $Dir.Split([char[]]@('\', '/'), [System.StringSplitOptions]::RemoveEmptyEntries) + if ($inputSegments -contains "..") { + return @{ Ok = $false; Reason = "-InstallDir must not contain path traversal segments" } + } + $normalized = [System.IO.Path]::GetFullPath($Dir) $root = [System.IO.Path]::GetPathRoot($normalized) if ($normalized.TrimEnd('\', '/') -eq $root.TrimEnd('\', '/')) { return @{ Ok = $false; Reason = "-InstallDir cannot be a root or drive-root directory" } } - $segments = $normalized.Substring($root.Length).Split([char[]]@('\', '/'), [System.StringSplitOptions]::RemoveEmptyEntries) - if ($segments -contains "..") { - return @{ Ok = $false; Reason = "-InstallDir must not contain path traversal segments" } - } - return @{ Ok = $true; Normalized = $normalized } } diff --git a/packages/safe-chain/src/config/safeChainDir.js b/packages/safe-chain/src/config/safeChainDir.js index 595300a..6762d0b 100644 --- a/packages/safe-chain/src/config/safeChainDir.js +++ b/packages/safe-chain/src/config/safeChainDir.js @@ -1,5 +1,6 @@ import os from "os"; import path from "path"; +import { fileURLToPath } from "url"; import { getInstalledSafeChainDir } from "../installLocation.js"; /** @@ -8,3 +9,49 @@ import { getInstalledSafeChainDir } from "../installLocation.js"; export function getSafeChainBaseDir() { return getInstalledSafeChainDir() ?? path.join(os.homedir(), ".safe-chain"); } + +/** + * @returns {string} + */ +export function getBinDir() { + return path.join(getSafeChainBaseDir(), "bin"); +} + +/** + * @returns {string} + */ +export function getShimsDir() { + return path.join(getSafeChainBaseDir(), "shims"); +} + +/** + * @returns {string} + */ +export function getScriptsDir() { + return path.join(getSafeChainBaseDir(), "scripts"); +} + +/** + * @returns {string} + */ +export function getCertsDir() { + return path.join(getSafeChainBaseDir(), "certs"); +} + +/** + * @param {string} moduleUrl + * @param {string} fileName + * @returns {string} + */ +export function getStartupScriptSourcePath(moduleUrl, fileName) { + return path.join(path.dirname(fileURLToPath(moduleUrl)), "startup-scripts", fileName); +} + +/** + * @param {string} moduleUrl + * @param {string} fileName + * @returns {string} + */ +export function getPathWrapperTemplatePath(moduleUrl, fileName) { + return path.join(path.dirname(fileURLToPath(moduleUrl)), "path-wrappers", "templates", fileName); +} diff --git a/packages/safe-chain/src/registryProxy/certUtils.js b/packages/safe-chain/src/registryProxy/certUtils.js index 50fad7b..3918177 100644 --- a/packages/safe-chain/src/registryProxy/certUtils.js +++ b/packages/safe-chain/src/registryProxy/certUtils.js @@ -1,16 +1,12 @@ import forge from "node-forge"; import path from "path"; import fs from "fs"; -import { getSafeChainBaseDir } from "../config/safeChainDir.js"; +import { getCertsDir } from "../config/safeChainDir.js"; const ca = loadCa(); const certCache = new Map(); -function getCertFolder() { - return path.join(getSafeChainBaseDir(), "certs"); -} - /** * @param {forge.pki.PublicKey} publicKey * @returns {string} @@ -23,7 +19,7 @@ function createKeyIdentifier(publicKey) { } export function getCaCertPath() { - return path.join(getCertFolder(), "ca-cert.pem"); + return path.join(getCertsDir(), "ca-cert.pem"); } /** @@ -115,7 +111,7 @@ export function generateCertForHost(hostname) { } function loadCa() { - const certFolder = getCertFolder(); + const certFolder = getCertsDir(); const keyPath = path.join(certFolder, "ca-key.pem"); const certPath = path.join(certFolder, "ca-cert.pem"); diff --git a/packages/safe-chain/src/registryProxy/certUtils.spec.js b/packages/safe-chain/src/registryProxy/certUtils.spec.js index c715c8c..4bf8c95 100644 --- a/packages/safe-chain/src/registryProxy/certUtils.spec.js +++ b/packages/safe-chain/src/registryProxy/certUtils.spec.js @@ -9,6 +9,7 @@ describe("certUtils", () => { mock.module("../config/safeChainDir.js", { namedExports: { getSafeChainBaseDir: () => installedSafeChainDir ?? "/home/test/.safe-chain", + getCertsDir: () => `${installedSafeChainDir ?? "/home/test/.safe-chain"}/certs`, }, }); }); diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index 3dd73aa..e763a5f 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -3,7 +3,6 @@ import * as os from "os"; import fs from "fs"; import path from "path"; import { ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; -import { getSafeChainBaseDir } from "../config/safeChainDir.js"; import { safeSpawn } from "../utils/safeSpawn.js"; import { ui } from "../environment/userInteraction.js"; @@ -122,34 +121,6 @@ export function getPackageManagerList() { return `${tools.join(", ")}, and ${lastTool} commands`; } -/** - * Returns the safe-chain base directory. - * Uses the packaged binary location when available, otherwise defaults to ~/.safe-chain. - * @returns {string} - */ -export { getSafeChainBaseDir }; - -/** - * @returns {string} - */ -export function getBinDir() { - return path.join(getSafeChainBaseDir(), "bin"); -} - -/** - * @returns {string} - */ -export function getShimsDir() { - return path.join(getSafeChainBaseDir(), "shims"); -} - -/** - * @returns {string} - */ -export function getScriptsDir() { - return path.join(getSafeChainBaseDir(), "scripts"); -} - /** * @param {string} executableName * diff --git a/packages/safe-chain/src/shell-integration/helpers.spec.js b/packages/safe-chain/src/shell-integration/helpers.spec.js index 8870451..e93a690 100644 --- a/packages/safe-chain/src/shell-integration/helpers.spec.js +++ b/packages/safe-chain/src/shell-integration/helpers.spec.js @@ -186,22 +186,27 @@ describe("removeLinesMatchingPatternTests", () => { describe("getSafeChainBaseDir / getBinDir / getShimsDir / getScriptsDir", () => { it("defaults base dir to ~/.safe-chain when no packaged install dir is available", async () => { - const { getSafeChainBaseDir } = await import("./helpers.js"); + const { getSafeChainBaseDir } = await import("../config/safeChainDir.js"); assert.strictEqual(getSafeChainBaseDir(), path.join(homedir(), ".safe-chain")); }); it("getBinDir returns ~/.safe-chain/bin by default", async () => { - const { getBinDir } = await import("./helpers.js"); + const { getBinDir } = await import("../config/safeChainDir.js"); assert.strictEqual(getBinDir(), path.join(homedir(), ".safe-chain", "bin")); }); it("getShimsDir returns ~/.safe-chain/shims by default", async () => { - const { getShimsDir } = await import("./helpers.js"); + const { getShimsDir } = await import("../config/safeChainDir.js"); assert.strictEqual(getShimsDir(), path.join(homedir(), ".safe-chain", "shims")); }); it("getScriptsDir returns ~/.safe-chain/scripts by default", async () => { - const { getScriptsDir } = await import("./helpers.js"); + const { getScriptsDir } = await import("../config/safeChainDir.js"); assert.strictEqual(getScriptsDir(), path.join(homedir(), ".safe-chain", "scripts")); }); + + it("getCertsDir returns ~/.safe-chain/certs by default", async () => { + const { getCertsDir } = await import("../config/safeChainDir.js"); + assert.strictEqual(getCertsDir(), path.join(homedir(), ".safe-chain", "certs")); + }); }); diff --git a/packages/safe-chain/src/shell-integration/setup-ci.js b/packages/safe-chain/src/shell-integration/setup-ci.js index 1986bba..f9e6767 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.js @@ -1,24 +1,14 @@ import chalk from "chalk"; import { ui } from "../environment/userInteraction.js"; -import { getPackageManagerList, knownAikidoTools, getShimsDir, getBinDir } from "./helpers.js"; +import { getPackageManagerList, knownAikidoTools } from "./helpers.js"; +import { + getShimsDir, + getBinDir, + getPathWrapperTemplatePath, +} from "../config/safeChainDir.js"; import fs from "fs"; import os from "os"; import path from "path"; -import { fileURLToPath } from "url"; - -/** @type {string} */ -// This checks the current file's dirname in a way that's compatible with: -// - Modulejs (import.meta.url) -// - ES modules (__dirname) -// This is needed because safe-chain's npm package is built using ES modules, -// but building the binaries requires commonjs. -let dirname; -if (import.meta.url) { - const filename = fileURLToPath(import.meta.url); - dirname = path.dirname(filename); -} else { - dirname = __dirname; -} /** * Loops over the detected shells and calls the setup function for each. @@ -50,12 +40,7 @@ export async function setupCi() { */ function createUnixShims(shimsDir) { // Read the template file - const templatePath = path.resolve( - dirname, - "path-wrappers", - "templates", - "unix-wrapper.template.sh" - ); + const templatePath = getPathWrapperTemplatePath(import.meta.url, "unix-wrapper.template.sh"); if (!fs.existsSync(templatePath)) { ui.writeError(`Template file not found: ${templatePath}`); @@ -89,12 +74,7 @@ function createUnixShims(shimsDir) { */ function createWindowsShims(shimsDir) { // Read the template file - const templatePath = path.resolve( - dirname, - "path-wrappers", - "templates", - "windows-wrapper.template.cmd" - ); + const templatePath = getPathWrapperTemplatePath(import.meta.url, "windows-wrapper.template.cmd"); if (!fs.existsSync(templatePath)) { ui.writeError(`Windows template file not found: ${templatePath}`); diff --git a/packages/safe-chain/src/shell-integration/setup-ci.spec.js b/packages/safe-chain/src/shell-integration/setup-ci.spec.js index de570f5..7af41d6 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.spec.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.spec.js @@ -50,8 +50,15 @@ describe("Setup CI shell integration", () => { { tool: "yarn", aikidoCommand: "aikido-yarn" }, ], getPackageManagerList: () => "npm, yarn", + }, + }); + + mock.module("../config/safeChainDir.js", { + namedExports: { getShimsDir: () => mockShimsDir, getBinDir: () => path.join(mockHomeDir, ".safe-chain", "bin"), + getPathWrapperTemplatePath: (_moduleUrl, fileName) => + path.join(mockTemplateDir, "path-wrappers", "templates", fileName), }, }); @@ -64,22 +71,6 @@ describe("Setup CI shell integration", () => { }, }); - // Mock path module to resolve templates correctly - mock.module("path", { - namedExports: { - join: path.join, - dirname: () => mockTemplateDir, - resolve: (...args) => path.resolve(mockTemplateDir, ...args.slice(1)), - }, - }); - - // Mock fileURLToPath - mock.module("url", { - namedExports: { - fileURLToPath: () => path.join(mockTemplateDir, "setup-ci.js"), - }, - }); - // Import setupCi module after mocking setupCi = (await import("./setup-ci.js")).setupCi; }); diff --git a/packages/safe-chain/src/shell-integration/setup.js b/packages/safe-chain/src/shell-integration/setup.js index 120723a..04534df 100644 --- a/packages/safe-chain/src/shell-integration/setup.js +++ b/packages/safe-chain/src/shell-integration/setup.js @@ -1,28 +1,10 @@ import chalk from "chalk"; import { ui } from "../environment/userInteraction.js"; import { detectShells } from "./shellDetection.js"; -import { - knownAikidoTools, - getPackageManagerList, - getScriptsDir, -} from "./helpers.js"; +import { knownAikidoTools, getPackageManagerList } from "./helpers.js"; +import { getScriptsDir, getStartupScriptSourcePath } from "../config/safeChainDir.js"; import fs from "fs"; import path from "path"; -import { fileURLToPath } from "url"; - -/** @type {string} */ -// This checks the current file's dirname in a way that's compatible with: -// - Modulejs (import.meta.url) -// - ES modules (__dirname) -// This is needed because safe-chain's npm package is built using ES modules, -// but building the binaries requires commonjs. -let dirname; -if (import.meta.url) { - const filename = fileURLToPath(import.meta.url); - dirname = path.dirname(filename); -} else { - dirname = __dirname; -} /** * Loops over the detected shells and calls the setup function for each. @@ -122,7 +104,7 @@ function copyStartupFiles() { fs.mkdirSync(targetDir, { recursive: true }); } - const sourcePath = path.join(dirname, "startup-scripts", file); + const sourcePath = getStartupScriptSourcePath(import.meta.url, file); fs.copyFileSync(sourcePath, targetPath); } } diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.js index 4c3334c..e106928 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.js @@ -2,8 +2,8 @@ import { addLineToFile, doesExecutableExistOnSystem, removeLinesMatchingPattern, - getScriptsDir, } from "../helpers.js"; +import { getScriptsDir } from "../../config/safeChainDir.js"; import { execSync, spawnSync } from "child_process"; import * as os from "os"; import path from "path"; diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js index f0a56d2..a6b09a0 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js @@ -19,7 +19,6 @@ describe("Bash shell integration", () => { mock.module("../helpers.js", { namedExports: { doesExecutableExistOnSystem: () => true, - getScriptsDir: () => "/test-home/.safe-chain/scripts", addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -36,6 +35,12 @@ describe("Bash shell integration", () => { }, }); + mock.module("../../config/safeChainDir.js", { + namedExports: { + getScriptsDir: () => "/test-home/.safe-chain/scripts", + }, + }); + // Mock child_process execSync mock.module("child_process", { namedExports: { diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.js index 29bc485..95c867b 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.js @@ -2,8 +2,8 @@ import { addLineToFile, doesExecutableExistOnSystem, removeLinesMatchingPattern, - getScriptsDir, } from "../helpers.js"; +import { getScriptsDir } from "../../config/safeChainDir.js"; import { execSync } from "child_process"; import path from "path"; diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js index 0933b6e..c1c5715 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js @@ -17,7 +17,6 @@ describe("Fish shell integration", () => { mock.module("../helpers.js", { namedExports: { doesExecutableExistOnSystem: () => true, - getScriptsDir: () => "/test-home/.safe-chain/scripts", addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -34,6 +33,12 @@ describe("Fish shell integration", () => { }, }); + mock.module("../../config/safeChainDir.js", { + namedExports: { + getScriptsDir: () => "/test-home/.safe-chain/scripts", + }, + }); + // Mock child_process execSync mock.module("child_process", { namedExports: { diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js index 3340bb4..2717e36 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js @@ -3,8 +3,8 @@ import { doesExecutableExistOnSystem, removeLinesMatchingPattern, validatePowerShellExecutionPolicy, - getScriptsDir, } from "../helpers.js"; +import { getScriptsDir } from "../../config/safeChainDir.js"; import { execSync } from "child_process"; import path from "path"; diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js index 1d9f65c..b14c73f 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js @@ -40,6 +40,11 @@ describe("PowerShell Core shell integration", () => { fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8"); }, validatePowerShellExecutionPolicy: () => executionPolicyResult, + }, + }); + + mock.module("../../config/safeChainDir.js", { + namedExports: { getScriptsDir: () => "/test-home/.safe-chain/scripts", }, }); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js index d458027..7213d38 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -3,8 +3,8 @@ import { doesExecutableExistOnSystem, removeLinesMatchingPattern, validatePowerShellExecutionPolicy, - getScriptsDir, } from "../helpers.js"; +import { getScriptsDir } from "../../config/safeChainDir.js"; import { execSync } from "child_process"; import path from "path"; diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js index 621b380..277a3f7 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js @@ -40,6 +40,11 @@ describe("Windows PowerShell shell integration", () => { fs.writeFileSync(filePath, filteredLines.join("\n"), "utf-8"); }, validatePowerShellExecutionPolicy: () => executionPolicyResult, + }, + }); + + mock.module("../../config/safeChainDir.js", { + namedExports: { getScriptsDir: () => "/test-home/.safe-chain/scripts", }, }); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js index 18917fd..c3e8d73 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js @@ -2,8 +2,8 @@ import { addLineToFile, doesExecutableExistOnSystem, removeLinesMatchingPattern, - getScriptsDir, } from "../helpers.js"; +import { getScriptsDir } from "../../config/safeChainDir.js"; import { execSync } from "child_process"; import path from "path"; diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js index 41e1bd1..50af5ca 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js @@ -17,7 +17,6 @@ describe("Zsh shell integration", () => { mock.module("../helpers.js", { namedExports: { doesExecutableExistOnSystem: () => true, - getScriptsDir: () => "/test-home/.safe-chain/scripts", addLineToFile: (filePath, line) => { if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, "", "utf-8"); @@ -34,6 +33,12 @@ describe("Zsh shell integration", () => { }, }); + mock.module("../../config/safeChainDir.js", { + namedExports: { + getScriptsDir: () => "/test-home/.safe-chain/scripts", + }, + }); + // Mock child_process execSync mock.module("child_process", { namedExports: { diff --git a/packages/safe-chain/src/shell-integration/teardown.js b/packages/safe-chain/src/shell-integration/teardown.js index e5f149d..cdeeae2 100644 --- a/packages/safe-chain/src/shell-integration/teardown.js +++ b/packages/safe-chain/src/shell-integration/teardown.js @@ -1,7 +1,8 @@ import chalk from "chalk"; import { ui } from "../environment/userInteraction.js"; import { detectShells } from "./shellDetection.js"; -import { knownAikidoTools, getPackageManagerList, getShimsDir, getScriptsDir } from "./helpers.js"; +import { knownAikidoTools, getPackageManagerList } from "./helpers.js"; +import { getShimsDir, getScriptsDir } from "../config/safeChainDir.js"; import fs from "fs"; /** From bafa997a701a2f29da9a5e00536758c4bf2aac85 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Tue, 14 Apr 2026 16:02:46 -0700 Subject: [PATCH 297/360] Some fixes --- install-scripts/uninstall-safe-chain.sh | 4 ++-- .../safe-chain/src/config/safeChainDir.js | 22 +++++++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/install-scripts/uninstall-safe-chain.sh b/install-scripts/uninstall-safe-chain.sh index abe235f..d215405 100755 --- a/install-scripts/uninstall-safe-chain.sh +++ b/install-scripts/uninstall-safe-chain.sh @@ -93,13 +93,13 @@ derive_install_dir_from_binary() { # Determines the installed safe-chain base directory for uninstall. # Prefers the binary-reported location, then infers it from PATH, then falls back to ~/.safe-chain. get_install_dir() { - reported_install_dir=$(get_reported_install_dir) + reported_install_dir=$(get_reported_install_dir || true) if [ -n "$reported_install_dir" ]; then printf '%s\n' "$reported_install_dir" return 0 fi - command_path=$(get_safe_chain_command_path) + command_path=$(get_safe_chain_command_path || true) install_dir=$(derive_install_dir_from_binary "$command_path" || true) if [ -n "$install_dir" ]; then printf '%s\n' "$install_dir" diff --git a/packages/safe-chain/src/config/safeChainDir.js b/packages/safe-chain/src/config/safeChainDir.js index 6762d0b..4d4f013 100644 --- a/packages/safe-chain/src/config/safeChainDir.js +++ b/packages/safe-chain/src/config/safeChainDir.js @@ -39,19 +39,33 @@ export function getCertsDir() { } /** - * @param {string} moduleUrl + * Resolves the directory of the calling module. + * Falls back to __dirname when import.meta.url is unavailable (pkg CJS binary). + * @param {string | undefined} moduleUrl + * @returns {string} + */ +function resolveModuleDir(moduleUrl) { + if (moduleUrl) { + return path.dirname(fileURLToPath(moduleUrl)); + } + // eslint-disable-next-line no-undef + return __dirname; +} + +/** + * @param {string | undefined} moduleUrl * @param {string} fileName * @returns {string} */ export function getStartupScriptSourcePath(moduleUrl, fileName) { - return path.join(path.dirname(fileURLToPath(moduleUrl)), "startup-scripts", fileName); + return path.join(resolveModuleDir(moduleUrl), "startup-scripts", fileName); } /** - * @param {string} moduleUrl + * @param {string | undefined} moduleUrl * @param {string} fileName * @returns {string} */ export function getPathWrapperTemplatePath(moduleUrl, fileName) { - return path.join(path.dirname(fileURLToPath(moduleUrl)), "path-wrappers", "templates", fileName); + return path.join(resolveModuleDir(moduleUrl), "path-wrappers", "templates", fileName); } From a68cf97f89776918654f4981aac83b2eeb7968d2 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Tue, 14 Apr 2026 16:14:05 -0700 Subject: [PATCH 298/360] One more fix --- .../templates/unix-wrapper.template.sh | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh index 2547a01..5b318ff 100644 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh @@ -4,12 +4,19 @@ # Function to remove shim from PATH (POSIX-compliant) remove_shim_from_path() { - _safe_chain_shims=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd -P) - if [ -z "$_safe_chain_shims" ]; then + _safe_chain_phys=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd -P) + if [ -z "$_safe_chain_phys" ]; then echo "$PATH" return fi - echo "$PATH" | sed "s|${_safe_chain_shims}:||g" + _path=$(echo "$PATH" | sed "s|${_safe_chain_phys}:||g") + # Also remove via dirname of $0 directly — on macOS /tmp is a symlink to /private/tmp, + # so pwd -P resolves to /private/tmp/… but PATH may still contain /tmp/…. + _dir=$(dirname -- "$0") + case "$_dir" in + /*) [ "$_dir" != "$_safe_chain_phys" ] && _path=$(echo "$_path" | sed "s|${_dir}:||g") ;; + esac + echo "$_path" } if command -v safe-chain >/dev/null 2>&1; then From 7ed943d46f6e6ee6b86a0e37ff96dbeb68f6ccab Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 15 Apr 2026 09:19:20 -0700 Subject: [PATCH 299/360] Fix Windows bash --- .../supported-shells/bash.js | 74 ++++++++++++++++++- .../supported-shells/bash.spec.js | 34 ++++++++- 2 files changed, 103 insertions(+), 5 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.js index e106928..34dcde7 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.js @@ -46,10 +46,11 @@ function teardown(tools) { function setup() { const startupFile = getStartupFile(); + const scriptsDir = getShellScriptsDir(); addLineToFile( startupFile, - `source ${path.join(getScriptsDir(), "init-posix.sh")} # Safe-chain bash initialization script`, + `source ${path.posix.join(scriptsDir, "init-posix.sh")} # Safe-chain bash initialization script`, eol ); @@ -96,6 +97,51 @@ function windowsFixPath(path) { } } +function getShellScriptsDir() { + return toBashPath(getScriptsDir()); +} + +/** + * @param {string} path + * + * @returns {string} + */ +function toBashPath(path) { + try { + if (os.platform() !== "win32") { + return path.replace(/\\/g, "/"); + } + + const directWindowsPath = windowsPathToBashPath(path); + if (directWindowsPath) { + return directWindowsPath; + } + + if (hasCygpath()) { + return cygpathu(path); + } + + return path.replace(/\\/g, "/"); + } catch { + return path.replace(/\\/g, "/"); + } +} + +/** + * @param {string} path + * + * @returns {string | undefined} + */ +function windowsPathToBashPath(path) { + const match = /^([A-Za-z]):[\\/](.*)$/.exec(path); + if (!match) { + return undefined; + } + + const [, driveLetter, rest] = match; + return `/${driveLetter.toLowerCase()}/${rest.replace(/\\/g, "/")}`; +} + function hasCygpath() { try { var result = spawnSync("where", ["cygpath"], { shell: executableName }); @@ -125,18 +171,40 @@ function cygpathw(path) { } } +/** + * @param {string} path + * + * @returns {string} + */ +function cygpathu(path) { + try { + var result = spawnSync("cygpath", ["-u", path], { + encoding: "utf8", + shell: executableName, + }); + if (result.status === 0) { + return result.stdout.trim(); + } + return path.replace(/\\/g, "/"); + } catch { + return path.replace(/\\/g, "/"); + } +} + function getManualTeardownInstructions() { + const scriptsDir = getShellScriptsDir(); return [ `Remove the following line from your ~/.bashrc file:`, - ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, + ` source ${path.posix.join(scriptsDir, "init-posix.sh")}`, `Then restart your terminal or run: source ~/.bashrc`, ]; } function getManualSetupInstructions() { + const scriptsDir = getShellScriptsDir(); return [ `Add the following line to your ~/.bashrc file:`, - ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, + ` source ${path.posix.join(scriptsDir, "init-posix.sh")}`, `Then restart your terminal or run: source ~/.bashrc`, ]; } diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js index a6b09a0..ac80d1f 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js @@ -9,6 +9,7 @@ describe("Bash shell integration", () => { let mockStartupFile; let bash; let windowsCygwinPath = ""; + let mockScriptsDir = "/test-home/.safe-chain/scripts"; let platform = "linux"; beforeEach(async () => { @@ -37,7 +38,7 @@ describe("Bash shell integration", () => { mock.module("../../config/safeChainDir.js", { namedExports: { - getScriptsDir: () => "/test-home/.safe-chain/scripts", + getScriptsDir: () => mockScriptsDir, }, }); @@ -67,6 +68,17 @@ describe("Bash shell integration", () => { stdout: windowsCygwinPath + "\n", }; } + + if ( + command === "cygpath" && + args[0] === "-u" && + args[1] === mockScriptsDir + ) { + return { + status: 0, + stdout: "/c/test-home/.safe-chain/scripts\n", + }; + } }, }, }); @@ -93,6 +105,7 @@ describe("Bash shell integration", () => { // Reset mocks mock.reset(); + mockScriptsDir = "/test-home/.safe-chain/scripts"; platform = "linux"; }); @@ -135,7 +148,24 @@ describe("Bash shell integration", () => { const content = fs.readFileSync(windowsCygwinPath, "utf-8"); assert.ok( content.includes( - "source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script" + "source /c/test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script" + ) + ); + }); + + it("should write a bash-compatible scripts path on Windows", () => { + platform = "win32"; + windowsCygwinPath = mockStartupFile; + mockScriptsDir = "C:\\test-home\\.safe-chain\\scripts"; + mockStartupFile = "DUMMY"; + + const result = bash.setup(); + assert.strictEqual(result, true); + + const content = fs.readFileSync(windowsCygwinPath, "utf-8"); + assert.ok( + content.includes( + "source /c/test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script" ) ); }); From b3372cc50ebee04ec690709a6c56c5bfa0b4dda6 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 15 Apr 2026 15:33:37 -0700 Subject: [PATCH 300/360] Rename function --- .../safe-chain/src/shell-integration/supported-shells/bash.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.js index 34dcde7..956429d 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.js @@ -118,7 +118,7 @@ function toBashPath(path) { } if (hasCygpath()) { - return cygpathu(path); + return convertCygwinPathToUnix(path); } return path.replace(/\\/g, "/"); @@ -176,7 +176,7 @@ function cygpathw(path) { * * @returns {string} */ -function cygpathu(path) { +function convertCygwinPathToUnix(path) { try { var result = spawnSync("cygpath", ["-u", path], { encoding: "utf8", From 33c3bec43d089701f7459ffeee6e84330a1b0093 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 17 Apr 2026 09:37:40 -0700 Subject: [PATCH 301/360] Fix PyPI minimum-age fallback when cached metadata bypasses rewrite --- .../interceptors/pip/modifyPipInfo.js | 17 ++++++++++++++ .../interceptors/pip/pipInterceptor.js | 2 ++ .../pip/pipInterceptor.minPackageAge.spec.js | 22 +++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.js b/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.js index 9ef4328..ef0ab18 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.js @@ -6,6 +6,23 @@ export { parsePipMetadataUrl, isPipPackageInfoUrl } from "./parsePipPackageUrl.j import { getPipMetadataContentType, logSuppressedVersion } from "./pipMetadataResponseUtils.js"; import { modifyPipJsonResponse } from "./modifyPipJsonResponse.js"; +/** + * Strip conditional GET headers so PyPI always returns a full 200 response + * with a body we can rewrite. Without this, pip sends If-None-Match / + * If-Modified-Since, PyPI responds 304 Not Modified (empty body), and + * safe-chain cannot rewrite it — leaving pip with a cached index that still + * lists too-young versions. Those versions are then blocked at direct-download + * time with a hard 403, preventing dependency resolution from completing. + * + * @param {NodeJS.Dict} headers + * @returns {NodeJS.Dict} + */ +export function modifyPipInfoRequestHeaders(headers) { + delete headers["if-none-match"]; + delete headers["if-modified-since"]; + return headers; +} + // Match simple-index anchor tags and capture their href so we can suppress // individual distribution links from PyPI HTML metadata responses. const HTML_ANCHOR_HREF_RE = diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js index 51e6f0d..86d84eb 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js @@ -9,6 +9,7 @@ import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache. import { interceptRequests } from "../interceptorBuilder.js"; import { isExcludedFromMinimumPackageAge } from "../minimumPackageAgeExclusions.js"; import { + modifyPipInfoRequestHeaders, modifyPipInfoResponse, parsePipMetadataUrl, } from "./modifyPipInfo.js"; @@ -61,6 +62,7 @@ function createPipRequestHandler(registry) { !isExcludedFromMinimumPackageAge(metadataPackageName) ) { const newPackagesDatabase = await openNewPackagesDatabase(); + reqContext.modifyRequestHeaders(modifyPipInfoRequestHeaders); reqContext.modifyBody((body, headers) => modifyPipInfoResponse( body, diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.minPackageAge.spec.js b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.minPackageAge.spec.js index 6bbd904..f311df7 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.minPackageAge.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.minPackageAge.spec.js @@ -129,6 +129,28 @@ describe("pipInterceptor minimum package age", async () => { newlyReleasedPackageResponse = false; }); + it("strips If-None-Match and If-Modified-Since from metadata requests to prevent 304 cache bypass", async () => { + const url = "https://pypi.org/simple/foo-bar/"; + newlyReleasedPackageResponse = true; + + const interceptor = pipInterceptorForUrl(url); + const result = await interceptor.handleRequest(url); + + const headers = { + "if-none-match": '"some-etag"', + "if-modified-since": "Thu, 01 Jan 2026 00:00:00 GMT", + accept: "*/*", + }; + + result.modifyRequestHeaders(headers); + + assert.equal(headers["if-none-match"], undefined, "If-None-Match must be stripped"); + assert.equal(headers["if-modified-since"], undefined, "If-Modified-Since must be stripped"); + assert.equal(headers.accept, "*/*", "unrelated headers must be preserved"); + + newlyReleasedPackageResponse = false; + }); + it("should not block newly released package downloads when a dot-name package matches a hyphen exclusion", async () => { const url = "https://files.pythonhosted.org/packages/xx/yy/foo.bar-2.0.0.tar.gz"; From 464847a6fca8ff93e65b6f71e8d772715f18e7fb Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 17 Apr 2026 10:50:04 -0700 Subject: [PATCH 302/360] Add e2e test --- test/e2e/pip-minimum-age.e2e.spec.js | 168 +++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 test/e2e/pip-minimum-age.e2e.spec.js diff --git a/test/e2e/pip-minimum-age.e2e.spec.js b/test/e2e/pip-minimum-age.e2e.spec.js new file mode 100644 index 0000000..36705db --- /dev/null +++ b/test/e2e/pip-minimum-age.e2e.spec.js @@ -0,0 +1,168 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import assert from "node:assert"; +import { DockerTestContainer } from "./DockerTestContainer.js"; + +describe("E2E: pip minimum package age", () => { + let container; + + before(async () => { + DockerTestContainer.buildImage(); + }); + + beforeEach(async () => { + container = new DockerTestContainer(); + await container.start(); + + const installationShell = await container.openShell("zsh"); + await installationShell.runCommand("safe-chain setup"); + await installationShell.runCommand("pip3 cache purge"); + }); + + afterEach(async () => { + if (container) { + await container.stop(); + container = null; + } + }); + + it("falls back to an older PyPI version for flexible constraints", async () => { + const shell = await container.openShell("zsh"); + const latestVersion = await getLatestPackageVersion(shell, "openai"); + const tooYoungTimestamps = getTooYoungReleaseTimestamps(); + + await startFeedServer(container, [ + { + source: "pypi", + package_name: "openai", + version: latestVersion, + ...tooYoungTimestamps, + }, + ]); + + const installResult = await shell.runCommand( + 'SAFE_CHAIN_MALWARE_LIST_BASE_URL=http://127.0.0.1:8123 pip3 install --break-system-packages "openai>=2.8.0,<3" --safe-chain-logging=verbose' + ); + + assert.ok( + installResult.output.includes(`openai@${latestVersion} is newer than 48 hours and was removed`), + `Expected Safe Chain to suppress the latest openai version. Output was:\n${installResult.output}` + ); + assert.ok( + !installResult.output.includes("blocked by safe-chain direct download minimum package age"), + `Expected fallback during resolution, not a direct-download block. Output was:\n${installResult.output}` + ); + assert.ok( + installResult.output.includes("Successfully installed"), + `Expected pip install to succeed after fallback. Output was:\n${installResult.output}` + ); + + const installedVersion = await getInstalledVersion(shell, "openai"); + assert.notEqual( + installedVersion, + latestVersion, + `Expected fallback to an older openai version, but installed ${latestVersion}.` + ); + }); + + it("fails cleanly for exact pinned too-young PyPI versions", async () => { + const shell = await container.openShell("zsh"); + const latestVersion = await getLatestPackageVersion(shell, "openai"); + const tooYoungTimestamps = getTooYoungReleaseTimestamps(); + + await startFeedServer(container, [ + { + source: "pypi", + package_name: "openai", + version: latestVersion, + ...tooYoungTimestamps, + }, + ]); + + const installResult = await shell.runCommand( + `SAFE_CHAIN_MALWARE_LIST_BASE_URL=http://127.0.0.1:8123 pip3 install --break-system-packages openai==${latestVersion} --safe-chain-logging=verbose` + ); + + assert.ok( + installResult.output.includes(`openai@${latestVersion} is newer than 48 hours and was removed`), + `Expected Safe Chain to suppress the pinned openai version. Output was:\n${installResult.output}` + ); + assert.ok( + installResult.output.includes(`No matching distribution found for openai==${latestVersion}`) || + installResult.output.includes(`Could not find a version that satisfies the requirement openai==${latestVersion}`), + `Expected pip to fail because the exact version was suppressed. Output was:\n${installResult.output}` + ); + assert.ok( + !installResult.output.includes("blocked by safe-chain direct download minimum package age"), + `Expected resolver failure for an exact pin, not a direct-download block. Output was:\n${installResult.output}` + ); + }); +}); + +async function getLatestPackageVersion(shell, packageName) { + const result = await shell.runCommand(`/usr/bin/pip3 index versions ${packageName}`); + const version = result.output.match(new RegExp(`${packageName} \\(([^)]+)\\)`))?.[1]; + + assert.ok( + version, + `Could not determine latest ${packageName} version from pip output:\n${result.output}` + ); + + return version; +} + +async function getInstalledVersion(shell, packageName) { + const result = await shell.runCommand( + `python3 - <<'PY' +import importlib.metadata +print(importlib.metadata.version("${packageName}")) +PY` + ); + + return result.output.trim(); +} + +async function startFeedServer(container, releases) { + const shell = await container.openShell("bash"); + const releasesJson = JSON.stringify(releases, null, 2); + + await shell.runCommand(`mkdir -p /tmp/safe-chain-feed/releases +cat > /tmp/safe-chain-feed/malware_pypi.json <<'EOF' +[] +EOF +cat > /tmp/safe-chain-feed/releases/pypi.json <<'EOF' +${releasesJson} +EOF`); + + container.dockerExec( + "nohup python3 -m http.server 8123 -d /tmp/safe-chain-feed >/tmp/safe-chain-feed.log 2>&1 /dev/null; then + break + fi + sleep 0.1 + i=$((i + 1)) +done +if [ "$i" -ge 100 ]; then + echo "feed server did not become ready" >&2 + cat /tmp/safe-chain-feed.log >&2 || true +fi`); + + assert.equal( + readinessResult.output.includes("feed server did not become ready"), + false, + `Expected local feed server to become ready. Output was:\n${readinessResult.output}` + ); +} + +function getTooYoungReleaseTimestamps() { + const now = Math.floor(Date.now() / 1000); + + return { + released_on: now, + scraped_on: now, + }; +} From 293089462430fe807f9cacfd207d4f85a51e1fc2 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 21 Apr 2026 09:26:07 +0200 Subject: [PATCH 303/360] 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 304/360] 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 b8d16c15b9d7756d7b38a36e13a4eb8f97e9c96a Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 21 Apr 2026 11:09:18 +0200 Subject: [PATCH 305/360] Add Aikido Endpoint paragraph to README.md --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index b74d797..c0915a7 100644 --- a/README.md +++ b/README.md @@ -535,3 +535,18 @@ npm-ci: # Troubleshooting Having issues? See the [Troubleshooting Guide](https://help.aikido.dev/code-scanning/aikido-malware-scanning/safe-chain-troubleshooting) for help with common problems. + +# Using Safe Chain across a team? + +[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** +- **Chrome extensions** +- **Skills.sh AI skills** + +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 21b44eb4a8dd376be69b8443a86ccb949daf39e6 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 21 Apr 2026 11:13:25 +0200 Subject: [PATCH 306/360] Mention cursor, windsurf, ... --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c0915a7..1c43de2 100644 --- a/README.md +++ b/README.md @@ -545,7 +545,7 @@ Having issues? See the [Troubleshooting Guide](https://help.aikido.dev/code-scan - **Maven** - **NuGet** - **VS Code** -- **Open VSX** +- **Open VSX** - (Cursor, Windsurf, Kiro, Vs Codium, ...) - **Chrome extensions** - **Skills.sh AI skills** From a840a99f1b4839ae86f18302641db175a31b6107 Mon Sep 17 00:00:00 2001 From: Samuel Vandamme Date: Tue, 21 Apr 2026 11:20:43 +0200 Subject: [PATCH 307/360] moved endpoint up --- README.md | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 1c43de2..81dda88 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,12 @@ Aikido Safe Chain supports the following package managers: ![Aikido Safe Chain demo](https://raw.githubusercontent.com/AikidoSec/safe-chain/main/docs/safe-package-manager-demo.gif) +# Using Safe Chain across a team? + +[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. + +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). + ## Installation Installing the Aikido Safe Chain is easy with our one-line installer. @@ -535,18 +541,3 @@ npm-ci: # Troubleshooting Having issues? See the [Troubleshooting Guide](https://help.aikido.dev/code-scanning/aikido-malware-scanning/safe-chain-troubleshooting) for help with common problems. - -# Using Safe Chain across a team? - -[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** - -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 fbabd4e3c655a9d7499cbc12bccf86b51a2c7259 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Tue, 21 Apr 2026 11:05:06 -0700 Subject: [PATCH 308/360] Bump endpoint versions --- 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 b4bf8aa..69f1bc7 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.16/EndpointProtection.pkg" -DOWNLOAD_SHA256="6c185d247093533e44c1547c10e32bed899b6313b51d8bf74bcf3ddc08d8d824" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.19/EndpointProtection.pkg" +DOWNLOAD_SHA256="bc80fd290660127e3e982aae1690987790027c4b402f8d162da0e619d682d882" 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 350a7f9..0bd7a59 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.16/EndpointProtection.msi" -$DownloadSha256 = "5284c7a8078a02439733b02f66158ac6a7cb09bbb9fba38ec2ff8d98b494e637" +$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.19/EndpointProtection.msi" +$DownloadSha256 = "fe83d7253c09012c7fa593fe0d5da63aaed143ef0459a23df35ec3fe23459983" # Ensure TLS 1.2 is enabled for downloads [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 From 88c969aee0c4dc757d55baea3c6f011c79e3691b Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 22 Apr 2026 13:02:41 +0200 Subject: [PATCH 309/360] Endpoint 1.2.20 --- 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 69f1bc7..51b5cac 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.19/EndpointProtection.pkg" -DOWNLOAD_SHA256="bc80fd290660127e3e982aae1690987790027c4b402f8d162da0e619d682d882" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.20/EndpointProtection.pkg" +DOWNLOAD_SHA256="def6c01caac6a4ce93eb68157a5a6b81028c9203fa13a0f5c539cceb92cc7e7b" 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 0bd7a59..f85d8ce 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.19/EndpointProtection.msi" -$DownloadSha256 = "fe83d7253c09012c7fa593fe0d5da63aaed143ef0459a23df35ec3fe23459983" +$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.20/EndpointProtection.msi" +$DownloadSha256 = "46fe377a4ce6204e1cc4a031e80f92f85cb8e1ef6b9690b542438c0870937be3" # Ensure TLS 1.2 is enabled for downloads [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 From c22f36113c3daf29cd50aee68039eafe9e412942 Mon Sep 17 00:00:00 2001 From: Samuel Vandamme Date: Wed, 22 Apr 2026 17:42:22 +0200 Subject: [PATCH 310/360] moved endpoint up --- README.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 81dda88..f041983 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,14 @@ - ✅ **Blocks packages newer than 48 hours** without breaking your build - ✅ **Tokenless, free, no build data shared** +## 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. + +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). + +--- + Aikido Safe Chain supports the following package managers: - 📦 **npm** @@ -30,12 +38,6 @@ Aikido Safe Chain supports the following package managers: ![Aikido Safe Chain demo](https://raw.githubusercontent.com/AikidoSec/safe-chain/main/docs/safe-package-manager-demo.gif) -# Using Safe Chain across a team? - -[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. - -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). - ## Installation Installing the Aikido Safe Chain is easy with our one-line installer. From d81b0f521497c865503c03dd0fee4c338b797f58 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 23 Apr 2026 10:32:04 -0700 Subject: [PATCH 311/360] 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 312/360] 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 313/360] 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 314/360] 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 315/360] 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 316/360] 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 317/360] 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 318/360] 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 319/360] 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 320/360] 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 321/360] 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 322/360] 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 323/360] 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 From 98a1ba7d103368ab2d4c19facb77f927926afaa1 Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Fri, 1 May 2026 17:04:28 +0100 Subject: [PATCH 324/360] Add rushx support too Co-authored-by: Copilot --- README.md | 9 +++++---- docs/shell-integration.md | 8 ++++---- packages/safe-chain/bin/aikido-rushx.js | 14 ++++++++++++++ packages/safe-chain/bin/safe-chain.js | 2 +- packages/safe-chain/package.json | 3 ++- .../packagemanager/currentPackageManager.js | 3 +++ .../rush/createRushPackageManager.js | 2 +- .../src/packagemanager/rush/runRushCommand.js | 7 ++++--- .../packagemanager/rush/runRushCommand.spec.js | 8 ++++---- .../rushx/createRushxPackageManager.js | 18 ++++++++++++++++++ .../rushx/createRushxPackageManager.spec.js | 14 ++++++++++++++ .../src/shell-integration/helpers.js | 6 ++++++ .../src/shell-integration/setup-ci.spec.js | 10 +--------- .../startup-scripts/init-fish.fish | 8 ++++++++ .../startup-scripts/init-posix.sh | 8 ++++++++ .../startup-scripts/init-pwsh.ps1 | 8 ++++++++ 16 files changed, 101 insertions(+), 27 deletions(-) create mode 100755 packages/safe-chain/bin/aikido-rushx.js create mode 100644 packages/safe-chain/src/packagemanager/rushx/createRushxPackageManager.js create mode 100644 packages/safe-chain/src/packagemanager/rushx/createRushxPackageManager.spec.js diff --git a/README.md b/README.md index a3f7a87..41785e1 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Aikido Safe Chain supports the following package managers: - 📦 **pnpm** - 📦 **pnpx** - 📦 **rush** +- 📦 **rushx** - 📦 **bun** - 📦 **bunx** - 📦 **pip** @@ -76,7 +77,7 @@ You can find all available versions on the [releases page](https://github.com/Ai ### Verify the installation 1. **❗Restart your terminal** to start using the Aikido Safe Chain. - - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, rush, bun, bunx, pip, pip3, poetry, uv, uvx and pipx are loaded correctly. If you do not restart your terminal, the aliases will not be available. + - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, pip, pip3, poetry, uv, uvx and pipx are loaded correctly. If you do not restart your terminal, the aliases will not be available. 2. **Verify the installation** by running the verification command: @@ -107,7 +108,7 @@ You can find all available versions on the [releases page](https://github.com/Ai - The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware. -When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command. +When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command. You can check the installed version by running: @@ -119,7 +120,7 @@ safe-chain --version ### Malware Blocking -The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, rush, bun, bunx, pip, pip3, uv, uvx, poetry or pipx commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. +The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, pip, pip3, uv, uvx, poetry or pipx commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine. ### Minimum package age @@ -138,7 +139,7 @@ By default, the minimum package age is 48 hours. This provides an additional sec ### Shell Integration -The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, rush, bun, bunx, and Python package managers (pip, uv, uvx, poetry, pipx). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support: +The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, and Python package managers (pip, uv, uvx, poetry, pipx). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support: - ✅ **Bash** - ✅ **Zsh** diff --git a/docs/shell-integration.md b/docs/shell-integration.md index 2e36d0a..d6cc0e0 100644 --- a/docs/shell-integration.md +++ b/docs/shell-integration.md @@ -2,7 +2,7 @@ ## Overview -The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry`, `pipx`) with Aikido's security scanning functionality. It also intercepts Python module invocations for pip when available: `python -m pip`, `python -m pip3`, `python3 -m pip`, `python3 -m pip3`. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents. +The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry`, `pipx`) with Aikido's security scanning functionality. It also intercepts Python module invocations for pip when available: `python -m pip`, `python -m pip3`, `python3 -m pip`, `python3 -m pip3`. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents. ## Supported Shells @@ -28,7 +28,7 @@ This command: - Copies necessary startup scripts to Safe Chain's installation directory (`~/.safe-chain/scripts`) - Detects all supported shells on your system -- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` +- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` - Adds lightweight interceptors so `python -m pip[...]` and `python3 -m pip[...]` route through Safe Chain when invoked by name ❗ After running this command, **you must restart your terminal** for the changes to take effect. This ensures that the startup scripts are sourced correctly. @@ -78,7 +78,7 @@ The system modifies the following files to source Safe Chain startup scripts: This means the shell functions are working but the Aikido commands aren't installed or available in your PATH: - Make sure Aikido Safe Chain is properly installed on your system -- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-bun`, `aikido-bunx`, `aikido-pip`, `aikido-pip3`, `aikido-uv`, `aikido-uvx`, `aikido-poetry` and `aikido-pipx` commands exist +- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-rush`, `aikido-rushx`, `aikido-bun`, `aikido-bunx`, `aikido-pip`, `aikido-pip3`, `aikido-uv`, `aikido-uvx`, `aikido-poetry` and `aikido-pipx` commands exist - Check that these commands are in your system's PATH ### Manual Verification @@ -121,7 +121,7 @@ npm() { } ``` -Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes. +Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes. To intercept Python module invocations for pip without altering Python itself, you can add small forwarding functions: diff --git a/packages/safe-chain/bin/aikido-rushx.js b/packages/safe-chain/bin/aikido-rushx.js new file mode 100755 index 0000000..dfa168c --- /dev/null +++ b/packages/safe-chain/bin/aikido-rushx.js @@ -0,0 +1,14 @@ +#!/usr/bin/env node + +import { main } from "../src/main.js"; +import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; +import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js"; + +setEcoSystem(ECOSYSTEM_JS); +const packageManagerName = "rushx"; +initializePackageManager(packageManagerName); + +(async () => { + var exitCode = await main(process.argv.slice(2)); + process.exit(exitCode); +})(); diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index e1f801c..900bd83 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -108,7 +108,7 @@ function writeHelp() { ui.writeInformation( `- ${chalk.cyan( "safe-chain setup", - )}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, rush, bun, bunx, pip and pip3.`, + )}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, pip and pip3.`, ); ui.writeInformation( `- ${chalk.cyan( diff --git a/packages/safe-chain/package.json b/packages/safe-chain/package.json index 42766d7..f7ae933 100644 --- a/packages/safe-chain/package.json +++ b/packages/safe-chain/package.json @@ -14,6 +14,7 @@ "aikido-pnpm": "bin/aikido-pnpm.js", "aikido-pnpx": "bin/aikido-pnpx.js", "aikido-rush": "bin/aikido-rush.js", + "aikido-rushx": "bin/aikido-rushx.js", "aikido-bun": "bin/aikido-bun.js", "aikido-bunx": "bin/aikido-bunx.js", "aikido-uv": "bin/aikido-uv.js", @@ -38,7 +39,7 @@ "keywords": [], "author": "Aikido Security", "license": "AGPL-3.0-or-later", - "description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [rush](https://rushjs.io/), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), and [pip](https://pip.pypa.io/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, rush, bun, bunx, uv, uvx, or pip/pip3 from downloading or running the malware.", + "description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [rush](https://rushjs.io/), [rushx](https://rushjs.io/pages/commands/rushx/), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), and [pip](https://pip.pypa.io/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, uv, uvx, or pip/pip3 from downloading or running the malware.", "dependencies": { "certifi": "14.5.15", "chalk": "5.4.1", diff --git a/packages/safe-chain/src/packagemanager/currentPackageManager.js b/packages/safe-chain/src/packagemanager/currentPackageManager.js index ee68ee1..90050d3 100644 --- a/packages/safe-chain/src/packagemanager/currentPackageManager.js +++ b/packages/safe-chain/src/packagemanager/currentPackageManager.js @@ -14,6 +14,7 @@ import { createUvPackageManager } from "./uv/createUvPackageManager.js"; import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js"; import { createPipXPackageManager } from "./pipx/createPipXPackageManager.js"; import { createRushPackageManager } from "./rush/createRushPackageManager.js"; +import { createRushxPackageManager } from "./rushx/createRushxPackageManager.js"; import { createUvxPackageManager } from "./uvx/createUvxPackageManager.js"; /** @@ -70,6 +71,8 @@ export function initializePackageManager(packageManagerName, context) { state.packageManagerName = createPipXPackageManager(); } else if (packageManagerName === "rush") { state.packageManagerName = createRushPackageManager(); + } else if (packageManagerName === "rushx") { + state.packageManagerName = createRushxPackageManager(); } else { throw new Error("Unsupported package manager: " + packageManagerName); } diff --git a/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js b/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js index 16c5815..d51a832 100644 --- a/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js +++ b/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js @@ -6,7 +6,7 @@ import { resolvePackageVersion } from "../../api/npmApi.js"; */ export function createRushPackageManager() { return { - runCommand: runRushCommand, + runCommand: (args) => runRushCommand("rush", args), // We pre-scan rush add commands and rely on MITM for install/update flows. isSupportedCommand: (args) => getRushCommand(args) === "add", getDependencyUpdatesForCommand: scanRushAddCommand, diff --git a/packages/safe-chain/src/packagemanager/rush/runRushCommand.js b/packages/safe-chain/src/packagemanager/rush/runRushCommand.js index f6ba3cc..ed43c23 100644 --- a/packages/safe-chain/src/packagemanager/rush/runRushCommand.js +++ b/packages/safe-chain/src/packagemanager/rush/runRushCommand.js @@ -3,23 +3,24 @@ import { safeSpawn } from "../../utils/safeSpawn.js"; import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; /** + * @param {"rush" | "rushx"} executableName * @param {string[]} args * @returns {Promise<{status: number}>} */ -export async function runRushCommand(args) { +export async function runRushCommand(executableName, args) { try { const env = normalizeProxyEnvironmentVariables( mergeSafeChainProxyEnvironmentVariables(process.env), ); - const result = await safeSpawn("rush", args, { + const result = await safeSpawn(executableName, args, { stdio: "inherit", env, }); return { status: result.status }; } catch (/** @type any */ error) { - return reportCommandExecutionFailure(error, "rush"); + return reportCommandExecutionFailure(error, executableName); } } diff --git a/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js b/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js index b21087e..daabcab 100644 --- a/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js +++ b/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js @@ -64,7 +64,7 @@ describe("runRushCommand", () => { }); it("spawns rush with merged proxy env", async () => { - const res = await runRushCommand(["install"]); + const res = await runRushCommand("rush", ["install"]); assert.strictEqual(res.status, 0); assert.strictEqual(safeSpawnMock.mock.calls.length, 1); @@ -88,7 +88,7 @@ describe("runRushCommand", () => { it("returns spawn result status", async () => { nextSpawnStatus = 7; - const res = await runRushCommand(["update"]); + const res = await runRushCommand("rush", ["update"]); assert.strictEqual(res.status, 7); }); @@ -98,7 +98,7 @@ describe("runRushCommand", () => { code: "ENOENT", }); - const res = await runRushCommand(["install"]); + const res = await runRushCommand("rush", ["install"]); assert.strictEqual(res.status, 1); }); @@ -108,7 +108,7 @@ describe("runRushCommand", () => { HTTPS_PROXY: "http://localhost:8080", }; - await runRushCommand(["install"]); + await runRushCommand("rush", ["install"]); assert.deepStrictEqual(mergeResultEnv, { HTTPS_PROXY: "http://localhost:8080", diff --git a/packages/safe-chain/src/packagemanager/rushx/createRushxPackageManager.js b/packages/safe-chain/src/packagemanager/rushx/createRushxPackageManager.js new file mode 100644 index 0000000..af89d21 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/rushx/createRushxPackageManager.js @@ -0,0 +1,18 @@ +import { runRushCommand } from "../rush/runRushCommand.js"; + +/** + * @returns {import("../currentPackageManager.js").PackageManager} + */ +export function createRushxPackageManager() { + return { + /** + * @param {string[]} args + */ + runCommand: (args) => { + return runRushCommand("rushx", args); + }, + // For rushx, rely solely on MITM. + isSupportedCommand: () => false, + getDependencyUpdatesForCommand: () => [], + }; +} diff --git a/packages/safe-chain/src/packagemanager/rushx/createRushxPackageManager.spec.js b/packages/safe-chain/src/packagemanager/rushx/createRushxPackageManager.spec.js new file mode 100644 index 0000000..20b4a32 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/rushx/createRushxPackageManager.spec.js @@ -0,0 +1,14 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { createRushxPackageManager } from "./createRushxPackageManager.js"; + +test("createRushxPackageManager returns valid package manager interface", () => { + const pm = createRushxPackageManager(); + + assert.ok(pm); + assert.strictEqual(typeof pm.runCommand, "function"); + assert.strictEqual(typeof pm.isSupportedCommand, "function"); + assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function"); + assert.strictEqual(pm.isSupportedCommand(), false); + assert.deepStrictEqual(pm.getDependencyUpdatesForCommand(), []); +}); diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index f61ff98..dd10f3f 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -54,6 +54,12 @@ export const knownAikidoTools = [ ecoSystem: ECOSYSTEM_JS, internalPackageManagerName: "rush", }, + { + tool: "rushx", + aikidoCommand: "aikido-rushx", + ecoSystem: ECOSYSTEM_JS, + internalPackageManagerName: "rushx", + }, { tool: "bun", aikidoCommand: "aikido-bun", diff --git a/packages/safe-chain/src/shell-integration/setup-ci.spec.js b/packages/safe-chain/src/shell-integration/setup-ci.spec.js index 4438124..7af41d6 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.spec.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.spec.js @@ -48,9 +48,8 @@ describe("Setup CI shell integration", () => { knownAikidoTools: [ { tool: "npm", aikidoCommand: "aikido-npm" }, { tool: "yarn", aikidoCommand: "aikido-yarn" }, - { tool: "rush", aikidoCommand: "aikido-rush" }, ], - getPackageManagerList: () => "npm, yarn, rush", + getPackageManagerList: () => "npm, yarn", }, }); @@ -108,10 +107,6 @@ describe("Setup CI shell integration", () => { const yarnShimPath = path.join(mockShimsDir, "yarn"); assert.ok(fs.existsSync(yarnShimPath), "yarn shim should exist"); - // Check if rush shim was created - const rushShimPath = path.join(mockShimsDir, "rush"); - assert.ok(fs.existsSync(rushShimPath), "rush shim should exist"); - // Check content of npm shim const npmShimContent = fs.readFileSync(npmShimPath, "utf-8"); assert.ok(npmShimContent.includes("aikido-npm"), "npm shim should contain aikido-npm"); @@ -138,9 +133,6 @@ describe("Setup CI shell integration", () => { const yarnShimPath = path.join(mockShimsDir, "yarn.cmd"); assert.ok(fs.existsSync(yarnShimPath), "yarn.cmd shim should exist"); - const rushShimPath = path.join(mockShimsDir, "rush.cmd"); - assert.ok(fs.existsSync(rushShimPath), "rush.cmd shim should exist"); - // Check content of npm.cmd shim const npmShimContent = fs.readFileSync(npmShimPath, "utf-8"); assert.ok(npmShimContent.includes("aikido-npm"), "npm.cmd should contain aikido-npm"); diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish index 06960ef..728aff1 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish @@ -19,6 +19,14 @@ function pnpx wrapSafeChainCommand "pnpx" $argv end +function rush + wrapSafeChainCommand "rush" $argv +end + +function rushx + wrapSafeChainCommand "rushx" $argv +end + function bun wrapSafeChainCommand "bun" $argv end diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh index 452e62d..cde8f48 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh @@ -28,6 +28,14 @@ function pnpx() { wrapSafeChainCommand "pnpx" "$@" } +function rush() { + wrapSafeChainCommand "rush" "$@" +} + +function rushx() { + wrapSafeChainCommand "rushx" "$@" +} + function bun() { wrapSafeChainCommand "bun" "$@" } diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 index f65deb9..7aad2fc 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 @@ -22,6 +22,14 @@ function pnpx { Invoke-WrappedCommand "pnpx" $args $MyInvocation.Line $MyInvocation.OffsetInLine } +function rush { + Invoke-WrappedCommand "rush" $args $MyInvocation.Line $MyInvocation.OffsetInLine +} + +function rushx { + Invoke-WrappedCommand "rushx" $args $MyInvocation.Line $MyInvocation.OffsetInLine +} + function bun { Invoke-WrappedCommand "bun" $args $MyInvocation.Line $MyInvocation.OffsetInLine } From 369a94948a73c4ab925763e9797372d167dfb8c7 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Fri, 1 May 2026 14:34:35 -0700 Subject: [PATCH 325/360] Bump Endpoint to 1.3.4 --- 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 ead41d5..feabeb1 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.3/EndpointProtection.pkg" -DOWNLOAD_SHA256="a025d33ca493a3b7b77c9515fe7f0b2c1f2dd18fb3e60e08549499cafee6f250" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.3.4/EndpointProtection.pkg" +DOWNLOAD_SHA256="f2ea55588d42e4aa17545ad787f46dd36001009e2ddb9655c497b1a36edf3581" 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 2797394..29bc873 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.3.1/EndpointProtection.msi" -$DownloadSha256 = "6d72170cfd2090c6af8e111a625fa3961f9dc345495117db4f1d7c518d537076" +$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.3.4/EndpointProtection.msi" +$DownloadSha256 = "0699379716a9a8b1531befa538befb237252af9f7fd780b33f4dce73588c6f83" # Ensure TLS 1.2 is enabled for downloads [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 From cd5040c3bea52464f07425965bd0a65b041da851 Mon Sep 17 00:00:00 2001 From: Samuel Vandamme Date: Wed, 6 May 2026 10:47:37 +0200 Subject: [PATCH 326/360] moved troubleshooting from docs to here --- README.md | 307 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 306 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6513578..0cec8d3 100644 --- a/README.md +++ b/README.md @@ -548,4 +548,309 @@ npm-ci: # Troubleshooting -Having issues? See the [Troubleshooting Guide](https://help.aikido.dev/code-scanning/aikido-malware-scanning/safe-chain-troubleshooting) for help with common problems. +## Verification & Diagnostics + +**Check Installation** + +```bash +# Check version +safe-chain --version +``` + +**Verify Shell Integration** + +Run the verification command for your package manager: + +```bash +npm safe-chain-verify +pnpm safe-chain-verify +``` + +``` +Expected output: `OK: Safe-chain works!` +``` + +**Test Malware Blocking** + +Verify that malware detection is working: +``` +npm install safe-chain-test +``` + +These test packages are flagged as malware and should be blocked by Safe Chain. + +**If the test package installs successfully instead of being blocked**, see Malware Not Being Blocked below. + +## Logging Options + +Use logging flags or environment variables to get more information: + +```bash +# Verbose mode - detailed diagnostic output for troubleshooting +npm install express --safe-chain-logging=verbose + +# Or set it globally for all commands in your session +export SAFE_CHAIN_LOGGING=verbose +npm install express + +# Silent mode - suppress all output except malware blocking +npm install express --safe-chain-logging=silent +``` + +## Common Issues + +### Malware Not Being Blocked + +**Symptom:** Test malware packages (like `safe-chain-test`) install successfully when they should be blocked + +**Most Common Cause:** The package is cached in your package manager's local store + +Safe-chain blocks malicious packages by intercepting network requests to package registries using its proxy. + +When a package is already cached locally, the package manager skips downloading it from the registry, which bypasses the proxy. + +**Resolution Steps** + +1) Clear your package manager's cache + +```bash +# For npm +npm cache clean --force + +# For pnpm +pnpm store prune + +# For yarn (classic) +yarn cache clean + +# For yarn (berry/v2+) +yarn cache clean --all + +# For bun +bun pm cache rm +``` + +2) Clean local installation artifacts: + +```bash +# Remove node_modules if you want a completely fresh install +rm -rf node_modules +``` + +3) Re-test malware blocking: + +```bash +npm install safe-chain-test # Should be blocked +``` + +### Shell Aliases Not Working After Installation + +**Symptom:** Running `npm` shows regular npm instead of safe-chain wrapped version + +**First step:** Restart your terminal (most common fix) + +**Verify it's working:** + +```bash +type npm +``` + +Should show: `npm is a function` + +**If still not working:** + +Check that your startup file sources safe-chain scripts from `~/.safe-chain/scripts/`: + +* Bash: `~/.bashrc` +* Zsh: `~/.zshrc` +* Fish: `~/.config/fish/config.fish` +* PowerShell: `$PROFILE` + +### "Command Not Found: safe-chain" + +**Symptom:** Binary not found in PATH + +**First step:** Restart your terminal + +**Check PATH:** + +```bash +echo $PATH +``` + +Should include `~/.safe-chain/bin` + +**If persists:** Re-run the installation script + +### PowerShell Execution Policy Blocks Scripts (Windows) + +**Symptom:** When opening PowerShell, you see an error like: + +``` +. : File C:\Users\\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1 cannot be loaded because +running scripts is disabled on this system. +CategoryInfo : SecurityError: (:) [], PSSecurityException +FullyQualifiedErrorId : UnauthorizedAccess +``` + +**Cause:** Windows PowerShell's default execution policy (`Restricted`) blocks all script execution, including safe-chain's initialization script that's sourced from your PowerShell profile. + +**Resolution** + +1) Set the execution policy to allow local scripts + +Open PowerShell as Administrator and run: + +```powershell +Set-ExecutionPolicy -ExecutionPolicy RemoteSigned +``` + +This allows: + +* Local scripts (like safe-chain's) to run without signing +* Downloaded scripts to run only if signed by a trusted publisher + +2) Restart PowerShell and verify the error is resolved. + +> [!IMPORTANT] +> `RemoteSigned` is Microsoft's recommended execution policy for client computers. It provides a good balance between security and usability. + +### Shell Aliases Persist After Uninstallation + +**Symptom:** safe-chain commands still active after running uninstall script + +**Steps** + +1. Run `safe-chain teardown` (if binary still exists) +2. Restart your terminal +3. If still present, manually edit shell config files: + * Bash: `~/.bashrc` + * Zsh: `~/.zshrc` + * Fish: `~/.config/fish/config.fish` + * PowerShell: `$PROFILE` +4. Remove lines that source scripts from `~/.safe-chain/scripts/` +5. Restart terminal again + +## Manual Verification Steps + +### Check Installation Status + +```bash +# Check installation location (helps identify if installed via npm or as standalone binary) +which safe-chain + +# Verify binary exists +ls ~/.safe-chain/bin/safe-chain + +# Check version +safe-chain --version + +# Test shell integration +type npm +type pip +``` + +**Expected `which` output:** + +* Standalone binary (correct): `~/.safe-chain/bin/safe-chain` or `/Users//.safe-chain/bin/safe-chain` +* npm global (outdated): path containing `node_modules` or nvm version paths + +If `which` shows an npm installation, see Check for Conflicting Installations. + +### Check Shell Integration + +```bash +# Which shell you're using +echo $SHELL + +# Check if startup file sources safe-chain +# For Bash: +grep safe-chain ~/.bashrc + +# For Zsh: +grep safe-chain ~/.zshrc + +# For Fish: +grep safe-chain ~/.config/fish/config.fish + +# Verify scripts exist +ls ~/.safe-chain/scripts/ +``` + +### Check for Conflicting Installations + +> **Note:** The install/uninstall scripts automatically detect and remove conflicting installations, but you can manually check: + +```bash +# Check npm global +npm list -g @aikidosec/safe-chain + +# Check Volta +volta list safe-chain + +# Check nvm (all versions) +for version in $(nvm list | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+'); do + nvm exec "$version" npm list -g @aikidosec/safe-chain 2>/dev/null && echo "Found in $version" +done +``` + +### Manual Cleanup + +> **Note:** The install and uninstall scripts automatically handle these cleanup steps. Use these manual commands only if automatic cleanup fails. + +#### Remove npm Global Installation + +```bash +npm uninstall -g @aikidosec/safe-chain +``` + +#### Remove Volta Installation + +```bash +volta uninstall @aikidosec/safe-chain +``` + +#### Remove nvm Installations (All Versions) + +```bash +# Automated approach +for version in $(nvm list | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+'); do + nvm exec "$version" npm uninstall -g @aikidosec/safe-chain +done + +# Or manual per version +nvm use +npm uninstall -g @aikidosec/safe-chain +``` + +#### Clean Shell Configuration Files + +Manually remove safe-chain entries from: + +* Bash: `~/.bashrc` +* Zsh: `~/.zshrc` +* Fish: `~/.config/fish/config.fish` +* PowerShell: `$PROFILE` + +Look for and remove: + +* Lines sourcing from `~/.safe-chain/scripts/` +* Any safe-chain related function definitions + +#### Remove Installation Directory + +```bash +rm -rf ~/.safe-chain +``` + +# Report Issues + +If you encounter problems: + +1. Visit [GitHub Issues](https://github.com/AikidoSec/safe-chain/issues) +2. Include: + * Operating system and version + * Shell type and version + * `safe-chain --version` output + * Output from verification commands + * Verbose logs of the failing command (add the `--safe-chain-logging=verbose` argument) From bd876275b3830be8cb820fa9b85e999b02356214 Mon Sep 17 00:00:00 2001 From: Samuel Vandamme Date: Wed, 6 May 2026 10:51:13 +0200 Subject: [PATCH 327/360] updated troubleshooting guide and linked from readme --- README.md | 295 +--------------------------------------- docs/troubleshooting.md | 161 +++++++++------------- 2 files changed, 69 insertions(+), 387 deletions(-) diff --git a/README.md b/README.md index 0cec8d3..60631b0 100644 --- a/README.md +++ b/README.md @@ -548,300 +548,7 @@ npm-ci: # Troubleshooting -## Verification & Diagnostics - -**Check Installation** - -```bash -# Check version -safe-chain --version -``` - -**Verify Shell Integration** - -Run the verification command for your package manager: - -```bash -npm safe-chain-verify -pnpm safe-chain-verify -``` - -``` -Expected output: `OK: Safe-chain works!` -``` - -**Test Malware Blocking** - -Verify that malware detection is working: -``` -npm install safe-chain-test -``` - -These test packages are flagged as malware and should be blocked by Safe Chain. - -**If the test package installs successfully instead of being blocked**, see Malware Not Being Blocked below. - -## Logging Options - -Use logging flags or environment variables to get more information: - -```bash -# Verbose mode - detailed diagnostic output for troubleshooting -npm install express --safe-chain-logging=verbose - -# Or set it globally for all commands in your session -export SAFE_CHAIN_LOGGING=verbose -npm install express - -# Silent mode - suppress all output except malware blocking -npm install express --safe-chain-logging=silent -``` - -## Common Issues - -### Malware Not Being Blocked - -**Symptom:** Test malware packages (like `safe-chain-test`) install successfully when they should be blocked - -**Most Common Cause:** The package is cached in your package manager's local store - -Safe-chain blocks malicious packages by intercepting network requests to package registries using its proxy. - -When a package is already cached locally, the package manager skips downloading it from the registry, which bypasses the proxy. - -**Resolution Steps** - -1) Clear your package manager's cache - -```bash -# For npm -npm cache clean --force - -# For pnpm -pnpm store prune - -# For yarn (classic) -yarn cache clean - -# For yarn (berry/v2+) -yarn cache clean --all - -# For bun -bun pm cache rm -``` - -2) Clean local installation artifacts: - -```bash -# Remove node_modules if you want a completely fresh install -rm -rf node_modules -``` - -3) Re-test malware blocking: - -```bash -npm install safe-chain-test # Should be blocked -``` - -### Shell Aliases Not Working After Installation - -**Symptom:** Running `npm` shows regular npm instead of safe-chain wrapped version - -**First step:** Restart your terminal (most common fix) - -**Verify it's working:** - -```bash -type npm -``` - -Should show: `npm is a function` - -**If still not working:** - -Check that your startup file sources safe-chain scripts from `~/.safe-chain/scripts/`: - -* Bash: `~/.bashrc` -* Zsh: `~/.zshrc` -* Fish: `~/.config/fish/config.fish` -* PowerShell: `$PROFILE` - -### "Command Not Found: safe-chain" - -**Symptom:** Binary not found in PATH - -**First step:** Restart your terminal - -**Check PATH:** - -```bash -echo $PATH -``` - -Should include `~/.safe-chain/bin` - -**If persists:** Re-run the installation script - -### PowerShell Execution Policy Blocks Scripts (Windows) - -**Symptom:** When opening PowerShell, you see an error like: - -``` -. : File C:\Users\\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1 cannot be loaded because -running scripts is disabled on this system. -CategoryInfo : SecurityError: (:) [], PSSecurityException -FullyQualifiedErrorId : UnauthorizedAccess -``` - -**Cause:** Windows PowerShell's default execution policy (`Restricted`) blocks all script execution, including safe-chain's initialization script that's sourced from your PowerShell profile. - -**Resolution** - -1) Set the execution policy to allow local scripts - -Open PowerShell as Administrator and run: - -```powershell -Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -``` - -This allows: - -* Local scripts (like safe-chain's) to run without signing -* Downloaded scripts to run only if signed by a trusted publisher - -2) Restart PowerShell and verify the error is resolved. - -> [!IMPORTANT] -> `RemoteSigned` is Microsoft's recommended execution policy for client computers. It provides a good balance between security and usability. - -### Shell Aliases Persist After Uninstallation - -**Symptom:** safe-chain commands still active after running uninstall script - -**Steps** - -1. Run `safe-chain teardown` (if binary still exists) -2. Restart your terminal -3. If still present, manually edit shell config files: - * Bash: `~/.bashrc` - * Zsh: `~/.zshrc` - * Fish: `~/.config/fish/config.fish` - * PowerShell: `$PROFILE` -4. Remove lines that source scripts from `~/.safe-chain/scripts/` -5. Restart terminal again - -## Manual Verification Steps - -### Check Installation Status - -```bash -# Check installation location (helps identify if installed via npm or as standalone binary) -which safe-chain - -# Verify binary exists -ls ~/.safe-chain/bin/safe-chain - -# Check version -safe-chain --version - -# Test shell integration -type npm -type pip -``` - -**Expected `which` output:** - -* Standalone binary (correct): `~/.safe-chain/bin/safe-chain` or `/Users//.safe-chain/bin/safe-chain` -* npm global (outdated): path containing `node_modules` or nvm version paths - -If `which` shows an npm installation, see Check for Conflicting Installations. - -### Check Shell Integration - -```bash -# Which shell you're using -echo $SHELL - -# Check if startup file sources safe-chain -# For Bash: -grep safe-chain ~/.bashrc - -# For Zsh: -grep safe-chain ~/.zshrc - -# For Fish: -grep safe-chain ~/.config/fish/config.fish - -# Verify scripts exist -ls ~/.safe-chain/scripts/ -``` - -### Check for Conflicting Installations - -> **Note:** The install/uninstall scripts automatically detect and remove conflicting installations, but you can manually check: - -```bash -# Check npm global -npm list -g @aikidosec/safe-chain - -# Check Volta -volta list safe-chain - -# Check nvm (all versions) -for version in $(nvm list | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+'); do - nvm exec "$version" npm list -g @aikidosec/safe-chain 2>/dev/null && echo "Found in $version" -done -``` - -### Manual Cleanup - -> **Note:** The install and uninstall scripts automatically handle these cleanup steps. Use these manual commands only if automatic cleanup fails. - -#### Remove npm Global Installation - -```bash -npm uninstall -g @aikidosec/safe-chain -``` - -#### Remove Volta Installation - -```bash -volta uninstall @aikidosec/safe-chain -``` - -#### Remove nvm Installations (All Versions) - -```bash -# Automated approach -for version in $(nvm list | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+'); do - nvm exec "$version" npm uninstall -g @aikidosec/safe-chain -done - -# Or manual per version -nvm use -npm uninstall -g @aikidosec/safe-chain -``` - -#### Clean Shell Configuration Files - -Manually remove safe-chain entries from: - -* Bash: `~/.bashrc` -* Zsh: `~/.zshrc` -* Fish: `~/.config/fish/config.fish` -* PowerShell: `$PROFILE` - -Look for and remove: - -* Lines sourcing from `~/.safe-chain/scripts/` -* Any safe-chain related function definitions - -#### Remove Installation Directory - -```bash -rm -rf ~/.safe-chain -``` +Having issues? See the [Troubleshooting Guide](./docs/troubleshooting) for help with common problems. # Report Issues diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 456fe58..321fb67 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,52 +1,39 @@ # Troubleshooting -This guide helps you diagnose and resolve common issues with Aikido Safe Chain. - ## Verification & Diagnostics -### Check Installation +**Check Installation** ```bash # Check version safe-chain --version ``` -### Verify Shell Integration +**Verify Shell Integration** Run the verification command for your package manager: ```bash npm safe-chain-verify pnpm safe-chain-verify -pip safe-chain-verify -uv safe-chain-verify - -# Any other supported package manager: {packagemanager} safe-chain-verify ``` +``` Expected output: `OK: Safe-chain works!` +``` -### Test Malware Blocking +**Test Malware Blocking** Verify that malware detection is working: - -**For JavaScript/Node.js:** - -```bash -npm install safe-chain-test ``` - -**For Python:** - -```bash -pip3 install safe-chain-pi-test +npm install safe-chain-test ``` These test packages are flagged as malware and should be blocked by Safe Chain. -**If the test package installs successfully instead of being blocked**, see [Malware Not Being Blocked](#malware-not-being-blocked) below. +**If the test package installs successfully instead of being blocked**, see Malware Not Being Blocked below. -### Logging Options +## Logging Options Use logging flags or environment variables to get more information: @@ -74,41 +61,39 @@ Safe-chain blocks malicious packages by intercepting network requests to package When a package is already cached locally, the package manager skips downloading it from the registry, which bypasses the proxy. -**Resolution Steps:** +**Resolution Steps** -1. **Clear your package manager's cache:** +1) Clear your package manager's cache - ```bash - # For npm - npm cache clean --force +```bash +# For npm +npm cache clean --force - # For pnpm - pnpm store prune +# For pnpm +pnpm store prune - # For yarn (classic) - yarn cache clean +# For yarn (classic) +yarn cache clean - # For yarn (berry/v2+) - yarn cache clean --all +# For yarn (berry/v2+) +yarn cache clean --all - # For bun - bun pm cache rm - ``` +# For bun +bun pm cache rm +``` - > **⚠️ Warning:** Cache clearing is safe but will remove all cached packages. Subsequent installations will need to re-download packages. In CI/CD environments or monorepos, this may affect build times. +2) Clean local installation artifacts: -2. **Clean local installation artifacts:** +```bash +# Remove node_modules if you want a completely fresh install +rm -rf node_modules +``` - ```bash - # Remove node_modules if you want a completely fresh install - rm -rf node_modules - ``` +3) Re-test malware blocking: -3. **Re-test malware blocking:** - - ```bash - npm install safe-chain-test # Should be blocked - ``` +```bash +npm install safe-chain-test # Should be blocked +``` ### Shell Aliases Not Working After Installation @@ -128,10 +113,10 @@ Should show: `npm is a function` Check that your startup file sources safe-chain scripts from `~/.safe-chain/scripts/`: -- Bash: `~/.bashrc` -- Zsh: `~/.zshrc` -- Fish: `~/.config/fish/config.fish` -- PowerShell: `$PROFILE` +* Bash: `~/.bashrc` +* Zsh: `~/.zshrc` +* Fish: `~/.config/fish/config.fish` +* PowerShell: `$PROFILE` ### "Command Not Found: safe-chain" @@ -162,37 +147,39 @@ FullyQualifiedErrorId : UnauthorizedAccess **Cause:** Windows PowerShell's default execution policy (`Restricted`) blocks all script execution, including safe-chain's initialization script that's sourced from your PowerShell profile. -**Resolution:** +**Resolution** -1. **Set the execution policy to allow local scripts:** +1) Set the execution policy to allow local scripts - Open PowerShell as Administrator and run: +Open PowerShell as Administrator and run: - ```powershell - Set-ExecutionPolicy -ExecutionPolicy RemoteSigned - ``` +```powershell +Set-ExecutionPolicy -ExecutionPolicy RemoteSigned +``` - This allows: - - Local scripts (like safe-chain's) to run without signing - - Downloaded scripts to run only if signed by a trusted publisher +This allows: -2. **Restart PowerShell** and verify the error is resolved. +* Local scripts (like safe-chain's) to run without signing +* Downloaded scripts to run only if signed by a trusted publisher -> **Note:** `RemoteSigned` is Microsoft's recommended execution policy for client computers. It provides a good balance between security and usability. +2) Restart PowerShell and verify the error is resolved. + +> [!IMPORTANT] +> `RemoteSigned` is Microsoft's recommended execution policy for client computers. It provides a good balance between security and usability. ### Shell Aliases Persist After Uninstallation **Symptom:** safe-chain commands still active after running uninstall script -**Steps:** +**Steps** 1. Run `safe-chain teardown` (if binary still exists) 2. Restart your terminal 3. If still present, manually edit shell config files: - - Bash: `~/.bashrc` - - Zsh: `~/.zshrc` - - Fish: `~/.config/fish/config.fish` - - PowerShell: `$PROFILE` + * Bash: `~/.bashrc` + * Zsh: `~/.zshrc` + * Fish: `~/.config/fish/config.fish` + * PowerShell: `$PROFILE` 4. Remove lines that source scripts from `~/.safe-chain/scripts/` 5. Restart terminal again @@ -217,10 +204,10 @@ type pip **Expected `which` output:** -- Standalone binary (correct): `~/.safe-chain/bin/safe-chain` or `/Users//.safe-chain/bin/safe-chain` -- npm global (outdated): path containing `node_modules` or nvm version paths +* Standalone binary (correct): `~/.safe-chain/bin/safe-chain` or `/Users//.safe-chain/bin/safe-chain` +* npm global (outdated): path containing `node_modules` or nvm version paths -If `which` shows an npm installation, see [Check for Conflicting Installations](#check-for-conflicting-installations). +If `which` shows an npm installation, see Check for Conflicting Installations. ### Check Shell Integration @@ -259,23 +246,23 @@ for version in $(nvm list | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+'); do done ``` -## Manual Cleanup +### Manual Cleanup > **Note:** The install and uninstall scripts automatically handle these cleanup steps. Use these manual commands only if automatic cleanup fails. -### Remove npm Global Installation +#### Remove npm Global Installation ```bash npm uninstall -g @aikidosec/safe-chain ``` -### Remove Volta Installation +#### Remove Volta Installation ```bash volta uninstall @aikidosec/safe-chain ``` -### Remove nvm Installations (All Versions) +#### Remove nvm Installations (All Versions) ```bash # Automated approach @@ -288,34 +275,22 @@ nvm use npm uninstall -g @aikidosec/safe-chain ``` -### Clean Shell Configuration Files +#### Clean Shell Configuration Files Manually remove safe-chain entries from: -- Bash: `~/.bashrc` -- Zsh: `~/.zshrc` -- Fish: `~/.config/fish/config.fish` -- PowerShell: `$PROFILE` +* Bash: `~/.bashrc` +* Zsh: `~/.zshrc` +* Fish: `~/.config/fish/config.fish` +* PowerShell: `$PROFILE` Look for and remove: -- Lines sourcing from `~/.safe-chain/scripts/` -- Any safe-chain related function definitions +* Lines sourcing from `~/.safe-chain/scripts/` +* Any safe-chain related function definitions -### Remove Installation Directory +#### Remove Installation Directory ```bash rm -rf ~/.safe-chain ``` - -### Report Issues - -If you encounter problems: - -1. Visit [GitHub Issues](https://github.com/AikidoSec/safe-chain/issues) -2. Include: - - Operating system and version - - Shell type and version - - `safe-chain --version` output - - Output from verification commands - - Verbose logs of the failing command (add the `--safe-chain-logging=verbose` argument) From fbe094802e05c2d44b1b2f9c68f180ea7415798e Mon Sep 17 00:00:00 2001 From: Samuel Vandamme Date: Wed, 6 May 2026 10:51:35 +0200 Subject: [PATCH 328/360] reverted copy --- docs/troubleshooting.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 321fb67..4672849 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,5 +1,7 @@ # Troubleshooting +This guide helps you diagnose and resolve common issues with Aikido Safe Chain. + ## Verification & Diagnostics **Check Installation** From 08ae1ef732a40340d523a01b184289bd7840d12e Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Fri, 8 May 2026 11:08:58 +0100 Subject: [PATCH 329/360] Pull parsing logic into distinct file and remove invalid continue --- .../rush/createRushPackageManager.js | 80 +------------------ .../parsing/parsePackagesFromRushAddArgs.js | 71 ++++++++++++++++ .../parsePackagesFromRushAddArgs.spec.js | 49 ++++++++++++ 3 files changed, 122 insertions(+), 78 deletions(-) create mode 100644 packages/safe-chain/src/packagemanager/rush/parsing/parsePackagesFromRushAddArgs.js create mode 100644 packages/safe-chain/src/packagemanager/rush/parsing/parsePackagesFromRushAddArgs.spec.js diff --git a/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js b/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js index d51a832..85ec4d5 100644 --- a/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js +++ b/packages/safe-chain/src/packagemanager/rush/createRushPackageManager.js @@ -1,5 +1,6 @@ import { runRushCommand } from "./runRushCommand.js"; import { resolvePackageVersion } from "../../api/npmApi.js"; +import { parsePackagesFromRushAddArgs } from "./parsing/parsePackagesFromRushAddArgs.js"; /** * @returns {import("../currentPackageManager.js").PackageManager} @@ -22,9 +23,7 @@ async function scanRushAddCommand(args) { return []; } - const parsedSpecs = extractRushAddPackageSpecs(args) - .map((spec) => parsePackageSpec(spec)) - .filter((spec) => spec !== null); + const parsedSpecs = parsePackagesFromRushAddArgs(args.slice(1)); const resolvedVersions = await Promise.all( parsedSpecs.map(async (parsed) => { @@ -63,78 +62,3 @@ function getRushCommand(args) { return args[0]?.toLowerCase(); } - -/** - * @param {string[]} args - * @returns {string[]} - */ -function extractRushAddPackageSpecs(args) { - const packageSpecs = []; - - for (let i = 1; i < args.length; i++) { - const arg = args[i]; - if (!arg) { - continue; - } - - if (arg === "--package" || arg === "-p") { - const next = args[i + 1]; - if (next && !next.startsWith("-")) { - packageSpecs.push(next); - i += 1; - } - continue; - } - - if (arg.startsWith("--package=")) { - const value = arg.slice("--package=".length); - if (value) { - packageSpecs.push(value); - } - continue; - } - - if (!arg.startsWith("-")) { - packageSpecs.push(arg); - } - } - - return packageSpecs; -} - -/** - * @param {string} spec - * @returns {{name: string, version: string | null} | null} - */ -function parsePackageSpec(spec) { - const value = removeAlias(spec.trim()); - if (!value) { - return null; - } - - const lastAtIndex = value.lastIndexOf("@"); - if (lastAtIndex > 0) { - return { - name: value.slice(0, lastAtIndex), - version: value.slice(lastAtIndex + 1), - }; - } - - return { - name: value, - version: null, - }; -} - -/** - * @param {string} spec - * @returns {string} - */ -function removeAlias(spec) { - const aliasIndex = spec.indexOf("@npm:"); - if (aliasIndex !== -1) { - return spec.slice(aliasIndex + 5); - } - - return spec; -} diff --git a/packages/safe-chain/src/packagemanager/rush/parsing/parsePackagesFromRushAddArgs.js b/packages/safe-chain/src/packagemanager/rush/parsing/parsePackagesFromRushAddArgs.js new file mode 100644 index 0000000..3e82085 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/rush/parsing/parsePackagesFromRushAddArgs.js @@ -0,0 +1,71 @@ +/** + * @param {string[]} args + * @returns {{name: string, version: string | null}[]} + */ +export function parsePackagesFromRushAddArgs(args) { + const packageSpecs = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (!arg) { + continue; + } + + if (arg === "--package" || arg === "-p") { + const next = args[i + 1]; + if (next && !next.startsWith("-")) { + packageSpecs.push(next); + i += 1; + } + continue; + } + + if (arg.startsWith("--package=")) { + const value = arg.slice("--package=".length); + if (value) { + packageSpecs.push(value); + } + } + } + + return packageSpecs + .map((spec) => parsePackageSpec(spec)) + .filter((spec) => spec !== null); +} + +/** + * @param {string} spec + * @returns {{name: string, version: string | null} | null} + */ +function parsePackageSpec(spec) { + const value = removeAlias(spec.trim()); + if (!value) { + return null; + } + + const lastAtIndex = value.lastIndexOf("@"); + if (lastAtIndex > 0) { + return { + name: value.slice(0, lastAtIndex), + version: value.slice(lastAtIndex + 1), + }; + } + + return { + name: value, + version: null, + }; +} + +/** + * @param {string} spec + * @returns {string} + */ +function removeAlias(spec) { + const aliasIndex = spec.indexOf("@npm:"); + if (aliasIndex !== -1) { + return spec.slice(aliasIndex + 5); + } + + return spec; +} diff --git a/packages/safe-chain/src/packagemanager/rush/parsing/parsePackagesFromRushAddArgs.spec.js b/packages/safe-chain/src/packagemanager/rush/parsing/parsePackagesFromRushAddArgs.spec.js new file mode 100644 index 0000000..0607c82 --- /dev/null +++ b/packages/safe-chain/src/packagemanager/rush/parsing/parsePackagesFromRushAddArgs.spec.js @@ -0,0 +1,49 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { parsePackagesFromRushAddArgs } from "./parsePackagesFromRushAddArgs.js"; + +describe("parsePackagesFromRushAddArgs", () => { + it("returns an empty array when no packages are provided", () => { + const result = parsePackagesFromRushAddArgs([]); + + assert.deepEqual(result, []); + }); + + it("parses packages from --package arguments", () => { + const result = parsePackagesFromRushAddArgs([ + "--package", + "axios@1.9.0", + "--package", + "@scope/tool@2.0.0", + ]); + + assert.deepEqual(result, [ + { name: "axios", version: "1.9.0" }, + { name: "@scope/tool", version: "2.0.0" }, + ]); + }); + + it("parses packages from -p arguments", () => { + const result = parsePackagesFromRushAddArgs(["-p", "axios"]); + + assert.deepEqual(result, [{ name: "axios", version: null }]); + }); + + it("parses packages from --package=value arguments", () => { + const result = parsePackagesFromRushAddArgs(["--package=axios@^1.9.0"]); + + assert.deepEqual(result, [{ name: "axios", version: "^1.9.0" }]); + }); + + it("ignores positional packages because rush add requires --package", () => { + const result = parsePackagesFromRushAddArgs(["axios", "--dev"]); + + assert.deepEqual(result, []); + }); + + it("parses aliases", () => { + const result = parsePackagesFromRushAddArgs(["--package", "server@npm:axios@1.9.0"]); + + assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]); + }); +}); From 5f561141857c9324e33d423bfd70b40267307043 Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Fri, 8 May 2026 11:24:17 +0100 Subject: [PATCH 330/360] Add e2e tests Note: rushx only dispatches package.json scripts, so it's probably not necessary to add it as a distinct manager at all. --- test/e2e/Dockerfile | 2 + test/e2e/rush.e2e.spec.js | 105 +++++++++++++++++++++++++++++++++++++ test/e2e/rushx.e2e.spec.js | 100 +++++++++++++++++++++++++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 test/e2e/rush.e2e.spec.js create mode 100644 test/e2e/rushx.e2e.spec.js diff --git a/test/e2e/Dockerfile b/test/e2e/Dockerfile index 3de600c..c448b09 100644 --- a/test/e2e/Dockerfile +++ b/test/e2e/Dockerfile @@ -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 npm install -g @microsoft/rush@${RUSH_VERSION} # Install Bun RUN curl -fsSL https://bun.sh/install | bash diff --git a/test/e2e/rush.e2e.spec.js b/test/e2e/rush.e2e.spec.js new file mode 100644 index 0000000..efe7ead --- /dev/null +++ b/test/e2e/rush.e2e.spec.js @@ -0,0 +1,105 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +describe("E2E: rush coverage", () => { + let container; + + before(async () => { + DockerTestContainer.buildImage(); + }); + + beforeEach(async () => { + container = new DockerTestContainer(); + await container.start(); + + const installationShell = await container.openShell("zsh"); + await installationShell.runCommand("safe-chain setup"); + await setupRushWorkspace(installationShell); + }); + + 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}` + ); + }); +}); + +async function setupRushWorkspace(shell) { + await shell.runCommand("mkdir -p /testapp/common/config/rush /testapp/apps/test-app"); + await shell.runCommand(`cat > /testapp/common/config/rush/rush.json <<'EOF' +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush.schema.json", + "rushVersion": "5.175.1", + "pnpmVersion": "11.0.6", + "nodeSupportedVersionRange": ">=18.0.0", + "projectFolderMinDepth": 1, + "projectFolderMaxDepth": 2, + "gitPolicy": {}, + "repository": { + "url": "https://example.com/testapp.git", + "defaultBranch": "main" + }, + "eventHooks": { + "preRushInstall": [], + "postRushInstall": [], + "preRushBuild": [], + "postRushBuild": [] + }, + "projects": [ + { + "packageName": "test-app", + "projectFolder": "apps/test-app" + } + ] +} +EOF`); + await shell.runCommand(`cat > /testapp/apps/test-app/package.json <<'EOF' +{ + "name": "test-app", + "version": "1.0.0" +} +EOF`); +} diff --git a/test/e2e/rushx.e2e.spec.js b/test/e2e/rushx.e2e.spec.js new file mode 100644 index 0000000..aaadf4e --- /dev/null +++ b/test/e2e/rushx.e2e.spec.js @@ -0,0 +1,100 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +describe("E2E: rushx coverage", () => { + let container; + + before(async () => { + DockerTestContainer.buildImage(); + }); + + beforeEach(async () => { + container = new DockerTestContainer(); + await container.start(); + + const installationShell = await container.openShell("zsh"); + await installationShell.runCommand("safe-chain setup"); + await setupRushWorkspace(installationShell); + }); + + 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.ok( + result.output.includes("blocked 1 malicious package downloads"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("- safe-chain-test"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); +}); + +async function setupRushWorkspace(shell) { + await shell.runCommand("mkdir -p /testapp/common/config/rush /testapp/apps/test-app"); + await shell.runCommand(`cat > /testapp/common/config/rush/rush.json <<'EOF' +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush.schema.json", + "rushVersion": "5.175.1", + "pnpmVersion": "11.0.6", + "nodeSupportedVersionRange": ">=18.0.0", + "projectFolderMinDepth": 1, + "projectFolderMaxDepth": 2, + "gitPolicy": {}, + "repository": { + "url": "https://example.com/testapp.git", + "defaultBranch": "main" + }, + "eventHooks": { + "preRushInstall": [], + "postRushInstall": [], + "preRushBuild": [], + "postRushBuild": [] + }, + "projects": [ + { + "packageName": "test-app", + "projectFolder": "apps/test-app" + } + ] +} +EOF`); + await shell.runCommand(`cat > /testapp/apps/test-app/package.json <<'EOF' +{ + "name": "test-app", + "version": "1.0.0", + "scripts": { + "install-safe": "npm install axios@1.13.0", + "install-malicious": "npm install safe-chain-test@0.0.1-security" + } +} +EOF`); +} From 55f2123f5c2e3e4eb1cc19a16865ed7f747c8f52 Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Fri, 8 May 2026 11:25:07 +0100 Subject: [PATCH 331/360] Remove the normalisation bits added in error --- .../src/packagemanager/rush/runRushCommand.js | 43 +++---------------- .../rush/runRushCommand.spec.js | 7 --- 2 files changed, 6 insertions(+), 44 deletions(-) diff --git a/packages/safe-chain/src/packagemanager/rush/runRushCommand.js b/packages/safe-chain/src/packagemanager/rush/runRushCommand.js index ed43c23..f2b249f 100644 --- a/packages/safe-chain/src/packagemanager/rush/runRushCommand.js +++ b/packages/safe-chain/src/packagemanager/rush/runRushCommand.js @@ -9,7 +9,7 @@ import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; */ export async function runRushCommand(executableName, args) { try { - const env = normalizeProxyEnvironmentVariables( + const env = prepareRushEnvironmentVariables( mergeSafeChainProxyEnvironmentVariables(process.env), ); @@ -25,48 +25,17 @@ export async function runRushCommand(executableName, args) { } /** - * Ensure proxy settings are visible to package manager variants that rely on - * lowercase or npm/yarn-specific environment variables. - * * @param {Record} env * @returns {Record} */ -function normalizeProxyEnvironmentVariables(env) { - const normalized = { +function prepareRushEnvironmentVariables(env) { + const prepared = { ...env, }; - if (normalized.HTTPS_PROXY && !normalized.HTTP_PROXY) { - normalized.HTTP_PROXY = normalized.HTTPS_PROXY; + if (prepared.HTTPS_PROXY && !prepared.HTTP_PROXY) { + prepared.HTTP_PROXY = prepared.HTTPS_PROXY; } - if (normalized.HTTP_PROXY && !normalized.http_proxy) { - normalized.http_proxy = normalized.HTTP_PROXY; - } - - if (normalized.HTTPS_PROXY && !normalized.https_proxy) { - normalized.https_proxy = normalized.HTTPS_PROXY; - } - - if (normalized.HTTP_PROXY && !normalized.npm_config_proxy) { - normalized.npm_config_proxy = normalized.HTTP_PROXY; - } - - if (normalized.HTTPS_PROXY && !normalized.npm_config_https_proxy) { - normalized.npm_config_https_proxy = normalized.HTTPS_PROXY; - } - - if (normalized.HTTP_PROXY && !normalized.NPM_CONFIG_PROXY) { - normalized.NPM_CONFIG_PROXY = normalized.HTTP_PROXY; - } - - if (normalized.HTTPS_PROXY && !normalized.NPM_CONFIG_HTTPS_PROXY) { - normalized.NPM_CONFIG_HTTPS_PROXY = normalized.HTTPS_PROXY; - } - - if (normalized.HTTPS_PROXY && !normalized.YARN_HTTPS_PROXY) { - normalized.YARN_HTTPS_PROXY = normalized.HTTPS_PROXY; - } - - return normalized; + return prepared; } diff --git a/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js b/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js index daabcab..343fb1e 100644 --- a/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js +++ b/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js @@ -75,13 +75,6 @@ describe("runRushCommand", () => { assert.strictEqual(options.stdio, "inherit"); assert.strictEqual(options.env.HTTPS_PROXY, "http://localhost:8080"); assert.strictEqual(options.env.HTTP_PROXY, "http://localhost:8080"); - assert.strictEqual(options.env.https_proxy, "http://localhost:8080"); - assert.strictEqual(options.env.http_proxy, "http://localhost:8080"); - assert.strictEqual(options.env.npm_config_https_proxy, "http://localhost:8080"); - assert.strictEqual(options.env.npm_config_proxy, "http://localhost:8080"); - assert.strictEqual(options.env.NPM_CONFIG_HTTPS_PROXY, "http://localhost:8080"); - assert.strictEqual(options.env.NPM_CONFIG_PROXY, "http://localhost:8080"); - assert.strictEqual(options.env.YARN_HTTPS_PROXY, "http://localhost:8080"); assert.ok(mergeCalls.length >= 1, "proxy env merge should be called"); }); From 7ce44b4c628f28d43616e5193f96705093b04b33 Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Fri, 8 May 2026 13:12:40 +0100 Subject: [PATCH 332/360] Remove the unecessary proxy setting --- .../src/packagemanager/rush/runRushCommand.js | 22 +------------------ .../rush/runRushCommand.spec.js | 1 - 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/packages/safe-chain/src/packagemanager/rush/runRushCommand.js b/packages/safe-chain/src/packagemanager/rush/runRushCommand.js index f2b249f..340e3f6 100644 --- a/packages/safe-chain/src/packagemanager/rush/runRushCommand.js +++ b/packages/safe-chain/src/packagemanager/rush/runRushCommand.js @@ -9,13 +9,9 @@ import { reportCommandExecutionFailure } from "../_shared/commandErrors.js"; */ export async function runRushCommand(executableName, args) { try { - const env = prepareRushEnvironmentVariables( - mergeSafeChainProxyEnvironmentVariables(process.env), - ); - const result = await safeSpawn(executableName, args, { stdio: "inherit", - env, + env: mergeSafeChainProxyEnvironmentVariables(process.env), }); return { status: result.status }; @@ -23,19 +19,3 @@ export async function runRushCommand(executableName, args) { return reportCommandExecutionFailure(error, executableName); } } - -/** - * @param {Record} env - * @returns {Record} - */ -function prepareRushEnvironmentVariables(env) { - const prepared = { - ...env, - }; - - if (prepared.HTTPS_PROXY && !prepared.HTTP_PROXY) { - prepared.HTTP_PROXY = prepared.HTTPS_PROXY; - } - - return prepared; -} diff --git a/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js b/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js index 343fb1e..fa2c35a 100644 --- a/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js +++ b/packages/safe-chain/src/packagemanager/rush/runRushCommand.spec.js @@ -74,7 +74,6 @@ describe("runRushCommand", () => { assert.deepStrictEqual(args, ["install"]); assert.strictEqual(options.stdio, "inherit"); assert.strictEqual(options.env.HTTPS_PROXY, "http://localhost:8080"); - assert.strictEqual(options.env.HTTP_PROXY, "http://localhost:8080"); assert.ok(mergeCalls.length >= 1, "proxy env merge should be called"); }); From 26f1dfb81aca770df73070a3a63771b9cbece60c Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Fri, 8 May 2026 13:12:57 +0100 Subject: [PATCH 333/360] Use the standard install command for rush --- test/e2e/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/Dockerfile b/test/e2e/Dockerfile index c448b09..0e38110 100644 --- a/test/e2e/Dockerfile +++ b/test/e2e/Dockerfile @@ -47,7 +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 npm install -g @microsoft/rush@${RUSH_VERSION} +RUN volta install @microsoft/rush@${RUSH_VERSION} # Install Bun RUN curl -fsSL https://bun.sh/install | bash From e891d1a992517f000a386dc9507dcd9cc96db6ad Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Fri, 8 May 2026 13:13:37 +0100 Subject: [PATCH 334/360] Update e2e suite to cover supported package managers --- test/e2e/rush.e2e.spec.js | 109 +++++++++++++++++++++++++++----------- 1 file changed, 77 insertions(+), 32 deletions(-) diff --git a/test/e2e/rush.e2e.spec.js b/test/e2e/rush.e2e.spec.js index efe7ead..fb3cbdd 100644 --- a/test/e2e/rush.e2e.spec.js +++ b/test/e2e/rush.e2e.spec.js @@ -4,6 +4,11 @@ import assert from "node:assert"; describe("E2E: rush coverage", () => { let container; + const packageManagerConfigs = [ + { name: "pnpm", versionField: "pnpmVersion", version: "latest" }, + { name: "yarn", versionField: "yarnVersion", version: "latest" }, + { name: "npm", versionField: "npmVersion", version: "latest" }, + ]; before(async () => { DockerTestContainer.buildImage(); @@ -65,41 +70,81 @@ describe("E2E: rush coverage", () => { `Malicious package was added despite safe-chain protection. Output was:\n${packageJson.output}` ); }); + + for (const packageManagerConfig of packageManagerConfigs) { + it(`safe-chain proxy blocks malicious package downloads during rush update with ${packageManagerConfig.name}`, async () => { + const shell = await container.openShell("zsh"); + await setupRushWorkspace(shell, { + packageManagerConfig, + packageJson: `{ + "name": "test-app", + "version": "1.0.0", + "dependencies": { + "safe-chain-test": "0.0.1-security" + } +}`, + }); + + const result = await shell.runCommand("cd /testapp/apps/test-app && rush update"); + + assert.ok( + result.output.includes("blocked 1 malicious package downloads"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("- safe-chain-test"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); + } }); -async function setupRushWorkspace(shell) { - await shell.runCommand("mkdir -p /testapp/common/config/rush /testapp/apps/test-app"); - await shell.runCommand(`cat > /testapp/common/config/rush/rush.json <<'EOF' -{ - "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush.schema.json", - "rushVersion": "5.175.1", - "pnpmVersion": "11.0.6", - "nodeSupportedVersionRange": ">=18.0.0", - "projectFolderMinDepth": 1, - "projectFolderMaxDepth": 2, - "gitPolicy": {}, - "repository": { - "url": "https://example.com/testapp.git", - "defaultBranch": "main" - }, - "eventHooks": { - "preRushInstall": [], - "postRushInstall": [], - "preRushBuild": [], - "postRushBuild": [] - }, - "projects": [ - { - "packageName": "test-app", - "projectFolder": "apps/test-app" - } - ] -} -EOF`); - await shell.runCommand(`cat > /testapp/apps/test-app/package.json <<'EOF' -{ +async function setupRushWorkspace(shell, options = {}) { + const packageManagerConfig = options.packageManagerConfig ?? { + versionField: "pnpmVersion", + version: "11.0.6", + }; + const packageJson = options.packageJson ?? `{ "name": "test-app", "version": "1.0.0" +}`; + const rushConfig = { + $schema: "https://developer.microsoft.com/json-schemas/rush/v5/rush.schema.json", + rushVersion: "5.175.1", + [packageManagerConfig.versionField]: packageManagerConfig.version, + nodeSupportedVersionRange: ">=18.0.0", + projectFolderMinDepth: 1, + projectFolderMaxDepth: 2, + gitPolicy: {}, + repository: { + url: "https://example.com/testapp.git", + defaultBranch: "main", + }, + eventHooks: { + preRushInstall: [], + postRushInstall: [], + preRushBuild: [], + postRushBuild: [], + }, + projects: [ + { + packageName: "test-app", + projectFolder: "apps/test-app", + }, + ], + }; + + 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); } -EOF`); + +async function writeTextFile(shell, filePath, content) { + const encoded = Buffer.from(content).toString("base64"); + await shell.runCommand(`printf '%s' '${encoded}' | base64 -d > ${filePath}`); } From 6667e5d7b4eb68ee704efa8d931f40975cdcf1b3 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 11 May 2026 16:04:27 +0200 Subject: [PATCH 335/360] E2E: Use pnpm 10 in node versions that don't support pnpm 11 --- .github/workflows/test-on-pr.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-on-pr.yml b/.github/workflows/test-on-pr.yml index d7e9aab..744f52c 100644 --- a/.github/workflows/test-on-pr.yml +++ b/.github/workflows/test-on-pr.yml @@ -77,7 +77,7 @@ jobs: - node_version: "20" npm_version: "9.0.0" yarn_version: "latest" - pnpm_version: "latest" + pnpm_version: "10.0.0" # Version pinning scenario - node_version: "22" npm_version: "10.2.0" @@ -87,7 +87,7 @@ jobs: - node_version: "18" npm_version: "latest" yarn_version: "latest" - pnpm_version: "latest" + pnpm_version: "10.0.0" # Future compatibility (becomes LTS October 2025) - node_version: "24" npm_version: "latest" From 5f0ad7ecfdde2152aad12f826ccb20f92e94b46c Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Tue, 12 May 2026 10:33:26 +0100 Subject: [PATCH 336/360] Address e2e suite failures --- npm-shrinkwrap.json | 2 +- test/e2e/rush.e2e.spec.js | 131 ++++++++++++++----------------- test/e2e/rushx.e2e.spec.js | 67 ++++++++-------- test/e2e/utils/rushtestutils.mjs | 70 +++++++++++++++++ 4 files changed, 165 insertions(+), 105 deletions(-) create mode 100644 test/e2e/utils/rushtestutils.mjs diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 68aecf7..8148344 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -2417,7 +2417,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3139,6 +3138,7 @@ "aikido-python": "bin/aikido-python.js", "aikido-python3": "bin/aikido-python3.js", "aikido-rush": "bin/aikido-rush.js", + "aikido-rushx": "bin/aikido-rushx.js", "aikido-uv": "bin/aikido-uv.js", "aikido-uvx": "bin/aikido-uvx.js", "aikido-yarn": "bin/aikido-yarn.js", diff --git a/test/e2e/rush.e2e.spec.js b/test/e2e/rush.e2e.spec.js index fb3cbdd..f2ccc14 100644 --- a/test/e2e/rush.e2e.spec.js +++ b/test/e2e/rush.e2e.spec.js @@ -1,14 +1,22 @@ import { describe, it, before, beforeEach, afterEach } from "node:test"; import { DockerTestContainer } from "./DockerTestContainer.js"; +import { + buildRushConfig, + resolveRushVersions, + writeTextFile, +} from "./utils/rushtestutils.mjs"; import assert from "node:assert"; +// These tests cover safe-chain's Rush wrapper: pre-scanning `rush add` and +// blocking malicious packages downloaded during `rush update` via the MITM +// proxy. They use a single Rush-internal package manager (pnpm) — see +// `utils/rushtestutils.mjs` for why this suite isn't parameterised over the +// CI matrix's NPM_VERSION/PNPM_VERSION/YARN_VERSION values. + describe("E2E: rush coverage", () => { let container; - const packageManagerConfigs = [ - { name: "pnpm", versionField: "pnpmVersion", version: "latest" }, - { name: "yarn", versionField: "yarnVersion", version: "latest" }, - { name: "npm", versionField: "npmVersion", version: "latest" }, - ]; + /** @type {{ rushVersion: string, pnpmVersion: string } | undefined} */ + let resolvedVersions; before(async () => { DockerTestContainer.buildImage(); @@ -20,7 +28,12 @@ describe("E2E: rush coverage", () => { const installationShell = await container.openShell("zsh"); await installationShell.runCommand("safe-chain setup"); - await setupRushWorkspace(installationShell); + + if (!resolvedVersions) { + resolvedVersions = await resolveRushVersions(installationShell); + } + + await setupRushWorkspace(installationShell, { resolvedVersions }); }); afterEach(async () => { @@ -71,80 +84,58 @@ describe("E2E: rush coverage", () => { ); }); - for (const packageManagerConfig of packageManagerConfigs) { - it(`safe-chain proxy blocks malicious package downloads during rush update with ${packageManagerConfig.name}`, async () => { - const shell = await container.openShell("zsh"); - await setupRushWorkspace(shell, { - packageManagerConfig, - packageJson: `{ + it("safe-chain proxy blocks malicious package downloads during rush update", async () => { + const shell = await container.openShell("zsh"); + await setupRushWorkspace(shell, { + resolvedVersions, + packageJson: `{ "name": "test-app", "version": "1.0.0", "dependencies": { "safe-chain-test": "0.0.1-security" } }`, - }); - - const result = await shell.runCommand("cd /testapp/apps/test-app && rush update"); - - assert.ok( - result.output.includes("blocked 1 malicious package downloads"), - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("- safe-chain-test"), - `Output did not include expected text. Output was:\n${result.output}` - ); - assert.ok( - result.output.includes("Exiting without installing malicious packages."), - `Output did not include expected text. Output was:\n${result.output}` - ); }); - } + + const result = await shell.runCommand( + "cd /testapp/apps/test-app && rush update" + ); + + assert.ok( + result.output.includes("blocked 1 malicious package downloads"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("- safe-chain-test"), + `Output did not include expected text. Output was:\n${result.output}` + ); + assert.ok( + result.output.includes("Exiting without installing malicious packages."), + `Output did not include expected text. Output was:\n${result.output}` + ); + }); }); -async function setupRushWorkspace(shell, options = {}) { - const packageManagerConfig = options.packageManagerConfig ?? { - versionField: "pnpmVersion", - version: "11.0.6", - }; - const packageJson = options.packageJson ?? `{ - "name": "test-app", - "version": "1.0.0" -}`; - const rushConfig = { - $schema: "https://developer.microsoft.com/json-schemas/rush/v5/rush.schema.json", - rushVersion: "5.175.1", - [packageManagerConfig.versionField]: packageManagerConfig.version, - nodeSupportedVersionRange: ">=18.0.0", - projectFolderMinDepth: 1, - projectFolderMaxDepth: 2, - gitPolicy: {}, - repository: { - url: "https://example.com/testapp.git", - defaultBranch: "main", - }, - eventHooks: { - preRushInstall: [], - postRushInstall: [], - preRushBuild: [], - postRushBuild: [], - }, - projects: [ - { - packageName: "test-app", - projectFolder: "apps/test-app", - }, - ], - }; +async function setupRushWorkspace(shell, { resolvedVersions, packageJson }) { + const rushConfig = buildRushConfig({ + rushVersion: resolvedVersions.rushVersion, + pnpmVersion: resolvedVersions.pnpmVersion, + }); await shell.runCommand("rm -rf /testapp/common /testapp/apps/test-app"); await shell.runCommand("mkdir -p /testapp/apps/test-app"); - await writeTextFile(shell, "/testapp/rush.json", JSON.stringify(rushConfig, null, 2)); - await writeTextFile(shell, "/testapp/apps/test-app/package.json", packageJson); -} - -async function writeTextFile(shell, filePath, content) { - const encoded = Buffer.from(content).toString("base64"); - await shell.runCommand(`printf '%s' '${encoded}' | base64 -d > ${filePath}`); + await writeTextFile( + shell, + "/testapp/rush.json", + JSON.stringify(rushConfig, null, 2) + ); + await writeTextFile( + shell, + "/testapp/apps/test-app/package.json", + packageJson ?? + `{ + "name": "test-app", + "version": "1.0.0" +}` + ); } diff --git a/test/e2e/rushx.e2e.spec.js b/test/e2e/rushx.e2e.spec.js index aaadf4e..ab2c803 100644 --- a/test/e2e/rushx.e2e.spec.js +++ b/test/e2e/rushx.e2e.spec.js @@ -1,9 +1,16 @@ import { describe, it, before, beforeEach, afterEach } from "node:test"; import { DockerTestContainer } from "./DockerTestContainer.js"; +import { + buildRushConfig, + resolveRushVersions, + writeTextFile, +} from "./utils/rushtestutils.mjs"; import assert from "node:assert"; describe("E2E: rushx coverage", () => { let container; + /** @type {{ rushVersion: string, pnpmVersion: string } | undefined} */ + let resolvedVersions; before(async () => { DockerTestContainer.buildImage(); @@ -15,7 +22,12 @@ describe("E2E: rushx coverage", () => { const installationShell = await container.openShell("zsh"); await installationShell.runCommand("safe-chain setup"); - await setupRushWorkspace(installationShell); + + if (!resolvedVersions) { + resolvedVersions = await resolveRushVersions(installationShell); + } + + await setupRushWorkspace(installationShell, { resolvedVersions }); }); afterEach(async () => { @@ -58,43 +70,30 @@ describe("E2E: rushx coverage", () => { }); }); -async function setupRushWorkspace(shell) { - await shell.runCommand("mkdir -p /testapp/common/config/rush /testapp/apps/test-app"); - await shell.runCommand(`cat > /testapp/common/config/rush/rush.json <<'EOF' -{ - "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush.schema.json", - "rushVersion": "5.175.1", - "pnpmVersion": "11.0.6", - "nodeSupportedVersionRange": ">=18.0.0", - "projectFolderMinDepth": 1, - "projectFolderMaxDepth": 2, - "gitPolicy": {}, - "repository": { - "url": "https://example.com/testapp.git", - "defaultBranch": "main" - }, - "eventHooks": { - "preRushInstall": [], - "postRushInstall": [], - "preRushBuild": [], - "postRushBuild": [] - }, - "projects": [ - { - "packageName": "test-app", - "projectFolder": "apps/test-app" - } - ] -} -EOF`); - await shell.runCommand(`cat > /testapp/apps/test-app/package.json <<'EOF' -{ +async function setupRushWorkspace(shell, { resolvedVersions }) { + const rushConfig = buildRushConfig({ + rushVersion: resolvedVersions.rushVersion, + pnpmVersion: resolvedVersions.pnpmVersion, + }); + + await shell.runCommand( + "mkdir -p /testapp/common/config/rush /testapp/apps/test-app" + ); + await writeTextFile( + shell, + "/testapp/rush.json", + JSON.stringify(rushConfig, null, 2) + ); + await writeTextFile( + shell, + "/testapp/apps/test-app/package.json", + `{ "name": "test-app", "version": "1.0.0", "scripts": { "install-safe": "npm install axios@1.13.0", "install-malicious": "npm install safe-chain-test@0.0.1-security" } -} -EOF`); +}` + ); } diff --git a/test/e2e/utils/rushtestutils.mjs b/test/e2e/utils/rushtestutils.mjs new file mode 100644 index 0000000..624cc61 --- /dev/null +++ b/test/e2e/utils/rushtestutils.mjs @@ -0,0 +1,70 @@ +// Helpers for the Rush E2E suites. +// +// What these suites actually test: that safe-chain's shim intercepts `rush` +// and `rushx` invocations correctly. The contents of `rush.json` are just +// fixture noise needed to make Rush run at all — Rush's schema requires +// exact semver for `rushVersion`/`pnpmVersion` and refuses dist-tags like +// "latest", so we resolve those once per suite. +// +// * `rushVersion` is read from the `rush` binary baked into the image +// (Dockerfile installs `@microsoft/rush@${RUSH_VERSION:-latest}`). +// * `pnpmVersion` is pinned to a known-good pnpm 9 release. Rush downloads +// this internally into `~/.rush/...`; it's unrelated to the system +// pnpm exercised by the pnpm e2e suite. + +const PINNED_PNPM_VERSION = "9.15.9"; + +/** Resolves the versions to put into `rush.json`. */ +export async function resolveRushVersions(shell) { + return { + rushVersion: await getInstalledRushVersion(shell), + pnpmVersion: PINNED_PNPM_VERSION, + }; +} + +/** Builds the standard `rush.json` body for the e2e fixtures. */ +export function buildRushConfig({ rushVersion, pnpmVersion, projects }) { + return { + $schema: + "https://developer.microsoft.com/json-schemas/rush/v5/rush.schema.json", + rushVersion, + pnpmVersion, + nodeSupportedVersionRange: ">=18.0.0", + projectFolderMinDepth: 1, + projectFolderMaxDepth: 2, + gitPolicy: {}, + repository: { + url: "https://example.com/testapp.git", + defaultBranch: "main", + }, + eventHooks: { + preRushInstall: [], + postRushInstall: [], + preRushBuild: [], + postRushBuild: [], + }, + projects: projects ?? [ + { packageName: "test-app", projectFolder: "apps/test-app" }, + ], + }; +} + +/** + * Writes a UTF-8 text file inside the container, base64-encoding the payload + * to avoid shell escaping issues for arbitrary content. + */ +export async function writeTextFile(shell, filePath, content) { + const encoded = Buffer.from(content).toString("base64"); + await shell.runCommand(`printf '%s' '${encoded}' | base64 -d > ${filePath}`); +} + +async function getInstalledRushVersion(shell) { + const { output } = await shell.runCommand("rush --version"); + const match = output.match(/\b(\d+\.\d+\.\d+)\b/); + if (!match) { + throw new Error( + `Could not determine installed Rush version. Output was:\n${output}` + ); + } + return match[1]; +} From 25d966bfa939887702c4071c8d2add3fe3d2e6d3 Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Tue, 12 May 2026 10:51:55 +0100 Subject: [PATCH 337/360] Switch to using the versions from the CI matrix Incorporates the actual Rush and PNPM versions instead of pinning an old known-good version of PNPM --- test/e2e/utils/rushtestutils.mjs | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/test/e2e/utils/rushtestutils.mjs b/test/e2e/utils/rushtestutils.mjs index 624cc61..285c50e 100644 --- a/test/e2e/utils/rushtestutils.mjs +++ b/test/e2e/utils/rushtestutils.mjs @@ -4,22 +4,21 @@ // and `rushx` invocations correctly. The contents of `rush.json` are just // fixture noise needed to make Rush run at all — Rush's schema requires // exact semver for `rushVersion`/`pnpmVersion` and refuses dist-tags like -// "latest", so we resolve those once per suite. +// "latest", so we read both back from the binaries baked into the image. // -// * `rushVersion` is read from the `rush` binary baked into the image -// (Dockerfile installs `@microsoft/rush@${RUSH_VERSION:-latest}`). -// * `pnpmVersion` is pinned to a known-good pnpm 9 release. Rush downloads -// this internally into `~/.rush/...`; it's unrelated to the system -// pnpm exercised by the pnpm e2e suite. - -const PINNED_PNPM_VERSION = "9.15.9"; +// * `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) { - return { - rushVersion: await getInstalledRushVersion(shell), - pnpmVersion: PINNED_PNPM_VERSION, - }; + // 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. */ @@ -58,12 +57,12 @@ export async function writeTextFile(shell, filePath, content) { await shell.runCommand(`printf '%s' '${encoded}' | base64 -d > ${filePath}`); } -async function getInstalledRushVersion(shell) { - const { output } = await shell.runCommand("rush --version"); +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 Rush version. Output was:\n${output}` + `Could not determine installed ${command} version. Output was:\n${output}` ); } return match[1]; From c93f1920fb6ab8345e1b4d3bfeaf9254073deb19 Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Tue, 12 May 2026 16:53:51 +0100 Subject: [PATCH 338/360] Skip min safe age to allow brand new PNPM boostrap --- test/e2e/rush.e2e.spec.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/e2e/rush.e2e.spec.js b/test/e2e/rush.e2e.spec.js index f2ccc14..70de4b8 100644 --- a/test/e2e/rush.e2e.spec.js +++ b/test/e2e/rush.e2e.spec.js @@ -97,8 +97,14 @@ describe("E2E: rush coverage", () => { }`, }); + // `--safe-chain-skip-minimum-package-age` is needed because Rush's + // internal pnpm bootstrap (`npm install pnpm@`) 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" + "cd /testapp/apps/test-app && rush update --safe-chain-skip-minimum-package-age" ); assert.ok( From fde0003a0af234085d821853b7ef4416821189ce Mon Sep 17 00:00:00 2001 From: James McMeeking Date: Tue, 12 May 2026 17:33:31 +0100 Subject: [PATCH 339/360] Fix expected format to account for retries Count is apparently not deterministic --- test/e2e/rush.e2e.spec.js | 5 +++-- test/e2e/rushx.e2e.spec.js | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/test/e2e/rush.e2e.spec.js b/test/e2e/rush.e2e.spec.js index 70de4b8..a5471a0 100644 --- a/test/e2e/rush.e2e.spec.js +++ b/test/e2e/rush.e2e.spec.js @@ -107,8 +107,9 @@ describe("E2E: rush coverage", () => { "cd /testapp/apps/test-app && rush update --safe-chain-skip-minimum-package-age" ); - assert.ok( - result.output.includes("blocked 1 malicious package downloads"), + assert.match( + result.output, + /blocked \d+ malicious package downloads/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( diff --git a/test/e2e/rushx.e2e.spec.js b/test/e2e/rushx.e2e.spec.js index ab2c803..b7d5078 100644 --- a/test/e2e/rushx.e2e.spec.js +++ b/test/e2e/rushx.e2e.spec.js @@ -55,8 +55,9 @@ describe("E2E: rushx coverage", () => { "cd /testapp/apps/test-app && rushx install-malicious" ); - assert.ok( - result.output.includes("blocked 1 malicious package downloads"), + assert.match( + result.output, + /blocked \d+ malicious package downloads/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( From d9b7aefd343c98e9bbc6b1e89b49596c48e19cd5 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 13 May 2026 14:33:58 -0700 Subject: [PATCH 340/360] unset PKG_EXECPATH before invoking safe-chain binary --- packages/safe-chain/bin/safe-chain.js | 6 ++ .../templates/unix-wrapper.template.sh | 5 +- .../pkg-execpath-cleanup.spec.js | 60 +++++++++++++++++++ .../startup-scripts/init-fish.fish | 6 +- .../startup-scripts/init-posix.sh | 6 +- 5 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 packages/safe-chain/src/shell-integration/pkg-execpath-cleanup.spec.js diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index 900bd83..53b6617 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -1,5 +1,11 @@ #!/usr/bin/env node +// Strip PKG_EXECPATH from the environment so any child process safe-chain +// spawns (npm, uv, pip, …) doesn't inherit it. If it leaks into a subsequent +// safe-chain invocation (e.g. via a shim) the yao-pkg bootstrap would treat +// argv[1] as a script path and fail with MODULE_NOT_FOUND. +delete process.env.PKG_EXECPATH; + import chalk from "chalk"; import { ui } from "../src/environment/userInteraction.js"; import { setup } from "../src/shell-integration/setup.js"; diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh index 5b318ff..30ab833 100644 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh @@ -20,7 +20,10 @@ remove_shim_from_path() { } if command -v safe-chain >/dev/null 2>&1; then - # Remove shim directory from PATH when calling {{AIKIDO_COMMAND}} to prevent infinite loops + # Remove shim directory from PATH when calling {{AIKIDO_COMMAND}} to prevent infinite loops. + # Unset PKG_EXECPATH so the yao-pkg bootstrap inside the safe-chain binary doesn't + # mistake argv[1] for a script path and try to resolve "{{PACKAGE_MANAGER}}" against cwd. + unset PKG_EXECPATH PATH=$(remove_shim_from_path) exec safe-chain {{PACKAGE_MANAGER}} "$@" else # safe-chain is not reachable — warn the user so they know protection is inactive diff --git a/packages/safe-chain/src/shell-integration/pkg-execpath-cleanup.spec.js b/packages/safe-chain/src/shell-integration/pkg-execpath-cleanup.spec.js new file mode 100644 index 0000000..4057224 --- /dev/null +++ b/packages/safe-chain/src/shell-integration/pkg-execpath-cleanup.spec.js @@ -0,0 +1,60 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, "..", ".."); + +describe("PKG_EXECPATH cleanup", () => { + it("unix shim template unsets PKG_EXECPATH before invoking safe-chain", () => { + const file = path.join( + repoRoot, + "src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh", + ); + const content = fs.readFileSync(file, "utf-8"); + assert.match( + content, + /unset PKG_EXECPATH[\s\S]*exec safe-chain/, + "unix-wrapper.template.sh must `unset PKG_EXECPATH` before `exec safe-chain`", + ); + }); + + it("posix shell function unsets PKG_EXECPATH before invoking safe-chain", () => { + const file = path.join( + repoRoot, + "src/shell-integration/startup-scripts/init-posix.sh", + ); + const content = fs.readFileSync(file, "utf-8"); + // Scoped subshell so we don't mutate the user's interactive env. + assert.match( + content, + /\(unset PKG_EXECPATH;\s*safe-chain "\$@"\)/, + "init-posix.sh must invoke safe-chain in a subshell that unsets PKG_EXECPATH", + ); + }); + + it("fish shell function unsets PKG_EXECPATH before invoking safe-chain", () => { + const file = path.join( + repoRoot, + "src/shell-integration/startup-scripts/init-fish.fish", + ); + const content = fs.readFileSync(file, "utf-8"); + assert.match( + content, + /env -u PKG_EXECPATH safe-chain/, + "init-fish.fish must invoke safe-chain via `env -u PKG_EXECPATH`", + ); + }); + + it("safe-chain entry point deletes PKG_EXECPATH from process.env", () => { + const file = path.join(repoRoot, "bin/safe-chain.js"); + const content = fs.readFileSync(file, "utf-8"); + assert.match( + content, + /delete process\.env\.PKG_EXECPATH/, + "bin/safe-chain.js must delete process.env.PKG_EXECPATH so spawned children don't inherit it", + ); + }); +}); diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish index 728aff1..68a3df0 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish @@ -120,8 +120,10 @@ function wrapSafeChainCommand end if type -q safe-chain - # If the safe-chain command is available, just run it with the provided arguments - safe-chain $original_cmd $cmd_args + # If the safe-chain command is available, just run it with the provided arguments. + # Unset PKG_EXECPATH for this invocation so the yao-pkg bootstrap inside the + # safe-chain binary doesn't mistake argv[1] for a script path to resolve against cwd. + env -u PKG_EXECPATH safe-chain $original_cmd $cmd_args else # If the safe-chain command is not available, print a warning and run the original command printSafeChainWarning $original_cmd diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh index cde8f48..258c281 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh @@ -109,8 +109,10 @@ function wrapSafeChainCommand() { fi if command -v safe-chain > /dev/null 2>&1; then - # If the aikido command is available, just run it with the provided arguments - safe-chain "$@" + # If the aikido command is available, just run it with the provided arguments. + # Unset PKG_EXECPATH so the yao-pkg bootstrap inside the safe-chain binary doesn't + # mistake argv[1] for a script path and try to resolve it against cwd. + (unset PKG_EXECPATH; safe-chain "$@") else # If the aikido command is not available, print a warning and run the original command printSafeChainWarning "$original_cmd" From 6cdad3df98bae5036c0142b5233a61546d0808d9 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 13 May 2026 20:27:27 -0700 Subject: [PATCH 341/360] Fix tests --- test/e2e/bun.e2e.spec.js | 10 ++++++---- test/e2e/npm.e2e.spec.js | 5 +++-- test/e2e/pip.e2e.spec.js | 5 +++-- test/e2e/pnpm.e2e.spec.js | 5 +++-- test/e2e/safe-chain-cli-python.e2e.spec.js | 5 +++-- test/e2e/uv.e2e.spec.js | 20 ++++++++++++-------- test/e2e/yarn.e2e.spec.js | 5 +++-- 7 files changed, 33 insertions(+), 22 deletions(-) diff --git a/test/e2e/bun.e2e.spec.js b/test/e2e/bun.e2e.spec.js index fb6e99a..c4d2e25 100644 --- a/test/e2e/bun.e2e.spec.js +++ b/test/e2e/bun.e2e.spec.js @@ -46,8 +46,9 @@ describe("E2E: bun coverage", () => { var result = await shell.runCommand("bun install"); - assert.ok( - result.output.includes("blocked 1 malicious package downloads"), + assert.match( + result.output, + /blocked \d+ malicious package downloads/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( @@ -65,8 +66,9 @@ describe("E2E: bun coverage", () => { const result = await shell.runCommand("bunx safe-chain-test"); - assert.ok( - result.output.includes("blocked 1 malicious package downloads"), + assert.match( + result.output, + /blocked \d+ malicious package downloads/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( diff --git a/test/e2e/npm.e2e.spec.js b/test/e2e/npm.e2e.spec.js index e8ba7c8..c1a09ab 100644 --- a/test/e2e/npm.e2e.spec.js +++ b/test/e2e/npm.e2e.spec.js @@ -70,8 +70,9 @@ describe("E2E: npm coverage", () => { var result = await shell.runCommand("npm install"); - assert.ok( - result.output.includes("blocked 1 malicious package downloads"), + assert.match( + result.output, + /blocked \d+ malicious package downloads/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( diff --git a/test/e2e/pip.e2e.spec.js b/test/e2e/pip.e2e.spec.js index af979dc..ecf8ad9 100644 --- a/test/e2e/pip.e2e.spec.js +++ b/test/e2e/pip.e2e.spec.js @@ -131,8 +131,9 @@ describe("E2E: pip coverage", () => { "pip3 install --break-system-packages numpy==2.4.4 --safe-chain-logging=verbose" ); - assert.ok( - result.output.includes("blocked 1 malicious package downloads:"), + assert.match( + result.output, + /blocked \d+ malicious package downloads:/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( diff --git a/test/e2e/pnpm.e2e.spec.js b/test/e2e/pnpm.e2e.spec.js index a15250a..4411492 100644 --- a/test/e2e/pnpm.e2e.spec.js +++ b/test/e2e/pnpm.e2e.spec.js @@ -70,8 +70,9 @@ describe("E2E: pnpm coverage", () => { var result = await shell.runCommand("pnpm install"); - assert.ok( - result.output.includes("blocked 1 malicious package downloads"), + assert.match( + result.output, + /blocked \d+ malicious package downloads/, `Output did not include expected text. Output was:\n${result.output}` ); 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 cf3fda2..9d59fb3 100644 --- a/test/e2e/safe-chain-cli-python.e2e.spec.js +++ b/test/e2e/safe-chain-cli-python.e2e.spec.js @@ -100,8 +100,9 @@ describe("E2E: safe-chain CLI python/pip support", () => { "safe-chain pip3 install --break-system-packages numpy==2.4.4" ); - assert.ok( - result.output.includes("blocked 1 malicious package downloads"), + assert.match( + result.output, + /blocked \d+ malicious package downloads/, `Should have blocked malware. Output was:\n${result.output}` ); }); diff --git a/test/e2e/uv.e2e.spec.js b/test/e2e/uv.e2e.spec.js index 5536e22..8fd633a 100644 --- a/test/e2e/uv.e2e.spec.js +++ b/test/e2e/uv.e2e.spec.js @@ -129,8 +129,9 @@ describe("E2E: uv coverage", () => { "uv pip install --system --break-system-packages numpy==2.4.4" ); - assert.ok( - result.output.includes("blocked 1 malicious package downloads:"), + assert.match( + result.output, + /blocked \d+ malicious package downloads:/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( @@ -416,8 +417,9 @@ describe("E2E: uv coverage", () => { "cd test-project-malware && uv add numpy==2.4.4" ); - assert.ok( - result.output.includes("blocked 1 malicious package downloads:"), + assert.match( + result.output, + /blocked \d+ malicious package downloads:/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( @@ -447,8 +449,9 @@ describe("E2E: uv coverage", () => { const shell = await container.openShell("zsh"); const result = await shell.runCommand("uv tool install numpy==2.4.4"); - assert.ok( - result.output.includes("blocked 1 malicious package downloads:"), + assert.match( + result.output, + /blocked \d+ malicious package downloads:/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( @@ -485,8 +488,9 @@ describe("E2E: uv coverage", () => { "uv run --with numpy==2.4.4 test_script2.py" ); - assert.ok( - result.output.includes("blocked 1 malicious package downloads:"), + assert.match( + result.output, + /blocked \d+ malicious package downloads:/, `Output did not include expected text. Output was:\n${result.output}` ); }); diff --git a/test/e2e/yarn.e2e.spec.js b/test/e2e/yarn.e2e.spec.js index 5e56d12..6f892a0 100644 --- a/test/e2e/yarn.e2e.spec.js +++ b/test/e2e/yarn.e2e.spec.js @@ -70,8 +70,9 @@ describe("E2E: yarn coverage", () => { var result = await shell.runCommand("yarn"); - assert.ok( - result.output.includes("blocked 1 malicious package downloads"), + assert.match( + result.output, + /blocked \d+ malicious package downloads/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( From e0e06431d166883bba74631fd28cf4b470d68845 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 13 May 2026 20:28:58 -0700 Subject: [PATCH 342/360] Fix tests --- test/e2e/bun.e2e.spec.js | 4 ++-- test/e2e/npm.e2e.spec.js | 2 +- test/e2e/pip.e2e.spec.js | 2 +- test/e2e/pnpm.e2e.spec.js | 2 +- test/e2e/rush.e2e.spec.js | 2 +- test/e2e/rushx.e2e.spec.js | 2 +- test/e2e/safe-chain-cli-python.e2e.spec.js | 2 +- test/e2e/uv.e2e.spec.js | 8 ++++---- test/e2e/yarn.e2e.spec.js | 2 +- 9 files changed, 13 insertions(+), 13 deletions(-) diff --git a/test/e2e/bun.e2e.spec.js b/test/e2e/bun.e2e.spec.js index c4d2e25..589d863 100644 --- a/test/e2e/bun.e2e.spec.js +++ b/test/e2e/bun.e2e.spec.js @@ -48,7 +48,7 @@ describe("E2E: bun coverage", () => { assert.match( result.output, - /blocked \d+ malicious package downloads/, + /blocked [1-9]\d* malicious package downloads/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( @@ -68,7 +68,7 @@ describe("E2E: bun coverage", () => { assert.match( result.output, - /blocked \d+ malicious package downloads/, + /blocked [1-9]\d* malicious package downloads/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( diff --git a/test/e2e/npm.e2e.spec.js b/test/e2e/npm.e2e.spec.js index c1a09ab..810359e 100644 --- a/test/e2e/npm.e2e.spec.js +++ b/test/e2e/npm.e2e.spec.js @@ -72,7 +72,7 @@ describe("E2E: npm coverage", () => { assert.match( result.output, - /blocked \d+ malicious package downloads/, + /blocked [1-9]\d* malicious package downloads/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( diff --git a/test/e2e/pip.e2e.spec.js b/test/e2e/pip.e2e.spec.js index ecf8ad9..8044a0f 100644 --- a/test/e2e/pip.e2e.spec.js +++ b/test/e2e/pip.e2e.spec.js @@ -133,7 +133,7 @@ describe("E2E: pip coverage", () => { assert.match( result.output, - /blocked \d+ malicious package downloads:/, + /blocked [1-9]\d* malicious package downloads:/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( diff --git a/test/e2e/pnpm.e2e.spec.js b/test/e2e/pnpm.e2e.spec.js index 4411492..6f9dacf 100644 --- a/test/e2e/pnpm.e2e.spec.js +++ b/test/e2e/pnpm.e2e.spec.js @@ -72,7 +72,7 @@ describe("E2E: pnpm coverage", () => { assert.match( result.output, - /blocked \d+ malicious package downloads/, + /blocked [1-9]\d* malicious package downloads/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( diff --git a/test/e2e/rush.e2e.spec.js b/test/e2e/rush.e2e.spec.js index a5471a0..fb6895f 100644 --- a/test/e2e/rush.e2e.spec.js +++ b/test/e2e/rush.e2e.spec.js @@ -109,7 +109,7 @@ describe("E2E: rush coverage", () => { assert.match( result.output, - /blocked \d+ malicious package downloads/, + /blocked [1-9]\d* malicious package downloads/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( diff --git a/test/e2e/rushx.e2e.spec.js b/test/e2e/rushx.e2e.spec.js index b7d5078..ec5ff75 100644 --- a/test/e2e/rushx.e2e.spec.js +++ b/test/e2e/rushx.e2e.spec.js @@ -57,7 +57,7 @@ describe("E2E: rushx coverage", () => { assert.match( result.output, - /blocked \d+ malicious package downloads/, + /blocked [1-9]\d* malicious package downloads/, `Output did not include expected text. Output was:\n${result.output}` ); 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 9d59fb3..43187d8 100644 --- a/test/e2e/safe-chain-cli-python.e2e.spec.js +++ b/test/e2e/safe-chain-cli-python.e2e.spec.js @@ -102,7 +102,7 @@ describe("E2E: safe-chain CLI python/pip support", () => { assert.match( result.output, - /blocked \d+ malicious package downloads/, + /blocked [1-9]\d* malicious package downloads/, `Should have blocked malware. Output was:\n${result.output}` ); }); diff --git a/test/e2e/uv.e2e.spec.js b/test/e2e/uv.e2e.spec.js index 8fd633a..728d4c5 100644 --- a/test/e2e/uv.e2e.spec.js +++ b/test/e2e/uv.e2e.spec.js @@ -131,7 +131,7 @@ describe("E2E: uv coverage", () => { assert.match( result.output, - /blocked \d+ malicious package downloads:/, + /blocked [1-9]\d* malicious package downloads:/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( @@ -419,7 +419,7 @@ describe("E2E: uv coverage", () => { assert.match( result.output, - /blocked \d+ malicious package downloads:/, + /blocked [1-9]\d* malicious package downloads:/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( @@ -451,7 +451,7 @@ describe("E2E: uv coverage", () => { assert.match( result.output, - /blocked \d+ malicious package downloads:/, + /blocked [1-9]\d* malicious package downloads:/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( @@ -490,7 +490,7 @@ describe("E2E: uv coverage", () => { assert.match( result.output, - /blocked \d+ malicious package downloads:/, + /blocked [1-9]\d* malicious package downloads:/, `Output did not include expected text. Output was:\n${result.output}` ); }); diff --git a/test/e2e/yarn.e2e.spec.js b/test/e2e/yarn.e2e.spec.js index 6f892a0..e70d6fc 100644 --- a/test/e2e/yarn.e2e.spec.js +++ b/test/e2e/yarn.e2e.spec.js @@ -72,7 +72,7 @@ describe("E2E: yarn coverage", () => { assert.match( result.output, - /blocked \d+ malicious package downloads/, + /blocked [1-9]\d* malicious package downloads/, `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( From 54db058ac70810cbcc57507cc8d2d61c04401352 Mon Sep 17 00:00:00 2001 From: Chris Ingram Date: Thu, 14 May 2026 10:04:18 +0100 Subject: [PATCH 343/360] Use getPackageManagerList in safe-chain setup help text The install message in `safe-chain setup` help was hardcoding a stale list of package managers (missing uv, uvx, poetry, pipx, pdm). Use the existing getPackageManagerList() helper so the list stays in sync with knownAikidoTools. --- packages/safe-chain/bin/safe-chain.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index 900bd83..8853467 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -15,7 +15,7 @@ import { main } from "../src/main.js"; import path from "path"; import { fileURLToPath } from "url"; import fs from "fs"; -import { knownAikidoTools } from "../src/shell-integration/helpers.js"; +import { knownAikidoTools, getPackageManagerList } from "../src/shell-integration/helpers.js"; import { getInstalledSafeChainDir } from "../src/installLocation.js"; /** @type {string} */ @@ -108,7 +108,7 @@ function writeHelp() { ui.writeInformation( `- ${chalk.cyan( "safe-chain setup", - )}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, pip and pip3.`, + )}: This will setup your shell to wrap safe-chain around ${getPackageManagerList()}.`, ); ui.writeInformation( `- ${chalk.cyan( From ffe7f8de1f03c887c8697e86bad31b37d684b1bf Mon Sep 17 00:00:00 2001 From: Chris Ingram Date: Thu, 14 May 2026 16:28:50 +0100 Subject: [PATCH 344/360] Use numpy==2.4.4 as test malware in pdm e2e tests The safe-chain-pi-test package no longer exists on PyPI. Aikido now patches numpy==2.4.4 into the malware list for tests, matching the pattern already used in the poetry e2e suite. --- test/e2e/pdm.e2e.spec.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/e2e/pdm.e2e.spec.js b/test/e2e/pdm.e2e.spec.js index 96379fb..f9d1ee6 100644 --- a/test/e2e/pdm.e2e.spec.js +++ b/test/e2e/pdm.e2e.spec.js @@ -70,7 +70,7 @@ describe("E2E: pdm coverage", () => { await shell.runCommand("cd /tmp/test-pdm-malware && pdm init --non-interactive"); const result = await shell.runCommand( - "cd /tmp/test-pdm-malware && pdm add safe-chain-pi-test" + "cd /tmp/test-pdm-malware && pdm add numpy==2.4.4" ); assert.ok( @@ -231,7 +231,7 @@ describe("E2E: pdm coverage", () => { // Add malware package - this will create lock file and attempt download const result = await shell.runCommand( - "cd /tmp/test-pdm-install-malware && pdm add safe-chain-pi-test 2>&1" + "cd /tmp/test-pdm-install-malware && pdm add numpy==2.4.4 2>&1" ); assert.ok( @@ -252,7 +252,7 @@ describe("E2E: pdm coverage", () => { // Try to add malware alongside safe package const result = await shell.runCommand( - "cd /tmp/test-pdm-batch && pdm add safe-chain-pi-test requests 2>&1" + "cd /tmp/test-pdm-batch && pdm add numpy==2.4.4 requests 2>&1" ); assert.ok( From 8ab5cebd4f5c693999f2b36e1a8bc9463ed6fcd3 Mon Sep 17 00:00:00 2001 From: Chris Ingram Date: Thu, 14 May 2026 16:48:18 +0100 Subject: [PATCH 345/360] Match actual block output in pdm e2e assertions The user-facing message is "Safe-chain: blocked N malicious package downloads", not "blocked by safe-chain" (which only appears in the proxy's HTTP response, not the rendered CLI output). --- test/e2e/pdm.e2e.spec.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/e2e/pdm.e2e.spec.js b/test/e2e/pdm.e2e.spec.js index f9d1ee6..5287ca6 100644 --- a/test/e2e/pdm.e2e.spec.js +++ b/test/e2e/pdm.e2e.spec.js @@ -74,7 +74,7 @@ describe("E2E: pdm coverage", () => { ); assert.ok( - result.output.includes("blocked by safe-chain"), + result.output.includes("blocked") && result.output.includes("malicious package downloads"), `Expected malware to be blocked. Output was:\n${result.output}` ); assert.ok( @@ -235,7 +235,7 @@ describe("E2E: pdm coverage", () => { ); assert.ok( - result.output.includes("blocked by safe-chain"), + result.output.includes("blocked") && result.output.includes("malicious package downloads"), `Expected malware to be blocked during add (which triggers install). Output was:\n${result.output}` ); assert.ok( @@ -256,7 +256,7 @@ describe("E2E: pdm coverage", () => { ); assert.ok( - result.output.includes("blocked by safe-chain"), + result.output.includes("blocked") && result.output.includes("malicious package downloads"), `Expected malware to be blocked. Output was:\n${result.output}` ); assert.ok( From a1b89a55f8d04e0d9b286308c3ff0c9b351b6edf Mon Sep 17 00:00:00 2001 From: Chris Ingram Date: Thu, 14 May 2026 17:16:57 +0100 Subject: [PATCH 346/360] Make block-count assertions count-agnostic in bun e2e Bun retries blocked downloads, so the count in "blocked N malicious package downloads" can be >1. Match on the surrounding text rather than a fixed count to keep the assertion robust. Also drops the brittle "pdm update updates dependencies" case. --- test/e2e/bun.e2e.spec.js | 4 ++-- test/e2e/pdm.e2e.spec.js | 17 ----------------- 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/test/e2e/bun.e2e.spec.js b/test/e2e/bun.e2e.spec.js index fb6e99a..494ded2 100644 --- a/test/e2e/bun.e2e.spec.js +++ b/test/e2e/bun.e2e.spec.js @@ -47,7 +47,7 @@ describe("E2E: bun coverage", () => { var result = await shell.runCommand("bun install"); assert.ok( - result.output.includes("blocked 1 malicious package downloads"), + result.output.includes("blocked") && result.output.includes("malicious package downloads"), `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( @@ -66,7 +66,7 @@ describe("E2E: bun coverage", () => { const result = await shell.runCommand("bunx safe-chain-test"); assert.ok( - result.output.includes("blocked 1 malicious package downloads"), + result.output.includes("blocked") && result.output.includes("malicious package downloads"), `Output did not include expected text. Output was:\n${result.output}` ); assert.ok( diff --git a/test/e2e/pdm.e2e.spec.js b/test/e2e/pdm.e2e.spec.js index 5287ca6..94bb5e0 100644 --- a/test/e2e/pdm.e2e.spec.js +++ b/test/e2e/pdm.e2e.spec.js @@ -103,23 +103,6 @@ describe("E2E: pdm coverage", () => { ); }); - it(`pdm update updates dependencies`, async () => { - const shell = await container.openShell("zsh"); - - await shell.runCommand("mkdir /tmp/test-pdm-update && cd /tmp/test-pdm-update"); - await shell.runCommand("cd /tmp/test-pdm-update && pdm init --non-interactive"); - await shell.runCommand("cd /tmp/test-pdm-update && pdm add requests"); - - const result = await shell.runCommand( - "cd /tmp/test-pdm-update && pdm update" - ); - - assert.ok( - result.output.includes("no malware found.") || result.output.includes("Updating"), - `Output did not include expected text. Output was:\n${result.output}` - ); - }); - it(`pdm update with specific packages`, async () => { const shell = await container.openShell("zsh"); From 34898980d7aaef03c12e3b2a792b6b2f1fe47830 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Mon, 18 May 2026 10:24:37 +0200 Subject: [PATCH 347/360] Remove obsolete npm token from pipeline --- .github/workflows/build-and-release.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 36dad7b..08f714a 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -144,8 +144,6 @@ jobs: with: node-version: "lts/*" registry-url: "https://registry.npmjs.org/" - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} - name: Setup safe-chain run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci From b38aba43ddd179ef9d6c4d7572679d6d325a39c3 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Tue, 19 May 2026 14:37:02 -0700 Subject: [PATCH 348/360] Create a bump-endpoint.yml workflow --- .github/workflows/bump-endpoint.yml | 82 +++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 .github/workflows/bump-endpoint.yml diff --git a/.github/workflows/bump-endpoint.yml b/.github/workflows/bump-endpoint.yml new file mode 100644 index 0000000..595e820 --- /dev/null +++ b/.github/workflows/bump-endpoint.yml @@ -0,0 +1,82 @@ +name: Bump safechain-internals endpoint + +on: + schedule: + - cron: '0 * * * *' # every hour + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + bump-endpoint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Get latest safechain-internals release + id: latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION=$(gh api repos/AikidoSec/safechain-internals/releases/latest --jq '.tag_name') + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Get current version from install script + id: current + run: | + CURRENT=$(grep -oP '(?<=releases/download/)[^/]+(?=/EndpointProtection\.pkg)' install-scripts/install-endpoint-mac.sh) + echo "version=$CURRENT" >> $GITHUB_OUTPUT + + - name: Download assets and compute checksums + if: steps.latest.outputs.version != steps.current.outputs.version + id: checksums + run: | + VERSION="${{ steps.latest.outputs.version }}" + BASE="https://github.com/AikidoSec/safechain-internals/releases/download/${VERSION}" + curl -fsSL "${BASE}/EndpointProtection.pkg" -o /tmp/EndpointProtection.pkg + curl -fsSL "${BASE}/EndpointProtection.msi" -o /tmp/EndpointProtection.msi + echo "mac=$(sha256sum /tmp/EndpointProtection.pkg | cut -d' ' -f1)" >> $GITHUB_OUTPUT + echo "win=$(sha256sum /tmp/EndpointProtection.msi | cut -d' ' -f1)" >> $GITHUB_OUTPUT + + - name: Update install scripts + if: steps.latest.outputs.version != steps.current.outputs.version + run: | + NEW="${{ steps.latest.outputs.version }}" + OLD="${{ steps.current.outputs.version }}" + MAC_SHA="${{ steps.checksums.outputs.mac }}" + WIN_SHA="${{ steps.checksums.outputs.win }}" + + sed -i "s|${OLD}/EndpointProtection.pkg|${NEW}/EndpointProtection.pkg|" install-scripts/install-endpoint-mac.sh + sed -i "s|^DOWNLOAD_SHA256=\"[^\"]*\"|DOWNLOAD_SHA256=\"${MAC_SHA}\"|" install-scripts/install-endpoint-mac.sh + + sed -i "s|${OLD}/EndpointProtection.msi|${NEW}/EndpointProtection.msi|" install-scripts/install-endpoint-windows.ps1 + sed -i 's|^\$DownloadSha256 = "[^"]*"|\$DownloadSha256 = "'"${WIN_SHA}"'"|' install-scripts/install-endpoint-windows.ps1 + + - name: Open PR + if: steps.latest.outputs.version != steps.current.outputs.version + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + NEW="${{ steps.latest.outputs.version }}" + OLD="${{ steps.current.outputs.version }}" + BRANCH="bump/endpoint-${NEW}" + + # Skip if a PR for this version already exists + if gh pr list --head "$BRANCH" --json number --jq '.[0].number' | grep -q '[0-9]'; then + echo "PR for $NEW already open, skipping." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b "$BRANCH" + git add install-scripts/install-endpoint-mac.sh install-scripts/install-endpoint-windows.ps1 + git commit -m "Bump Endpoint to ${NEW}" + git push origin "$BRANCH" + gh pr create \ + --title "Bump Endpoint to ${NEW}" \ + --body "Automated bump of safechain-internals endpoint from \`${OLD}\` to \`${NEW}\`." \ + --head "$BRANCH" \ + --base main From 9d44eca1d169c4c1714c9c39eb48bc20548d9468 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Tue, 19 May 2026 14:39:04 -0700 Subject: [PATCH 349/360] Apply suggestion from @bitterpanda63 --- .github/workflows/bump-endpoint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/bump-endpoint.yml b/.github/workflows/bump-endpoint.yml index 595e820..0968115 100644 --- a/.github/workflows/bump-endpoint.yml +++ b/.github/workflows/bump-endpoint.yml @@ -1,4 +1,4 @@ -name: Bump safechain-internals endpoint +name: Bump Device Protection Automatically on: schedule: From cbbbe703d316912cedcf3ad0127f10956f123f04 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Tue, 19 May 2026 14:45:10 -0700 Subject: [PATCH 350/360] Add a slack webhook curl req for endpoint bumps --- .github/workflows/bump-endpoint.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/bump-endpoint.yml b/.github/workflows/bump-endpoint.yml index 0968115..db7e3b6 100644 --- a/.github/workflows/bump-endpoint.yml +++ b/.github/workflows/bump-endpoint.yml @@ -75,8 +75,12 @@ jobs: git add install-scripts/install-endpoint-mac.sh install-scripts/install-endpoint-windows.ps1 git commit -m "Bump Endpoint to ${NEW}" git push origin "$BRANCH" - gh pr create \ + PR_URL=$(gh pr create \ --title "Bump Endpoint to ${NEW}" \ --body "Automated bump of safechain-internals endpoint from \`${OLD}\` to \`${NEW}\`." \ --head "$BRANCH" \ - --base main + --base main) + + curl -s -X POST "https://hooks.slack.com/triggers/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" \ + -H "Content-Type: application/json" \ + -d "{\"text\": \"update to ${NEW} - ${PR_URL}\"}" From 47e9ed0f6cd94f5b67d0ada88311fc30f367ec34 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Tue, 19 May 2026 14:47:33 -0700 Subject: [PATCH 351/360] temp: trigger bump-endpoint on push to test --- .github/workflows/bump-endpoint.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/bump-endpoint.yml b/.github/workflows/bump-endpoint.yml index db7e3b6..d289893 100644 --- a/.github/workflows/bump-endpoint.yml +++ b/.github/workflows/bump-endpoint.yml @@ -1,6 +1,9 @@ name: Bump Device Protection Automatically on: + push: + branches: + - create-bump-endpoint-workflow schedule: - cron: '0 * * * *' # every hour workflow_dispatch: From 3f0837c65a30aafdb0d81bbdf6bdb65d72ff6bb1 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Tue, 19 May 2026 14:48:23 -0700 Subject: [PATCH 352/360] temp: use open-source-releaser runner --- .github/workflows/bump-endpoint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/bump-endpoint.yml b/.github/workflows/bump-endpoint.yml index d289893..3da395f 100644 --- a/.github/workflows/bump-endpoint.yml +++ b/.github/workflows/bump-endpoint.yml @@ -14,7 +14,7 @@ permissions: jobs: bump-endpoint: - runs-on: ubuntu-latest + runs-on: open-source-releaser steps: - uses: actions/checkout@v4 From 07b8571758638539db7327c19f63061936224e07 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Tue, 19 May 2026 14:52:37 -0700 Subject: [PATCH 353/360] temp: post compare URL to Slack instead of creating PR --- .github/workflows/bump-endpoint.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/bump-endpoint.yml b/.github/workflows/bump-endpoint.yml index 3da395f..b204c5b 100644 --- a/.github/workflows/bump-endpoint.yml +++ b/.github/workflows/bump-endpoint.yml @@ -78,11 +78,7 @@ jobs: git add install-scripts/install-endpoint-mac.sh install-scripts/install-endpoint-windows.ps1 git commit -m "Bump Endpoint to ${NEW}" git push origin "$BRANCH" - PR_URL=$(gh pr create \ - --title "Bump Endpoint to ${NEW}" \ - --body "Automated bump of safechain-internals endpoint from \`${OLD}\` to \`${NEW}\`." \ - --head "$BRANCH" \ - --base main) + PR_URL="https://github.com/${{ github.repository }}/compare/main...${BRANCH}?expand=1" curl -s -X POST "https://hooks.slack.com/triggers/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" \ -H "Content-Type: application/json" \ From 0b46c5408b18ad924b19f8672590ea28ddb1c24a Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Tue, 19 May 2026 14:55:22 -0700 Subject: [PATCH 354/360] Update bump-endpoint.yml --- .github/workflows/bump-endpoint.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/bump-endpoint.yml b/.github/workflows/bump-endpoint.yml index b204c5b..becdb77 100644 --- a/.github/workflows/bump-endpoint.yml +++ b/.github/workflows/bump-endpoint.yml @@ -1,9 +1,6 @@ name: Bump Device Protection Automatically on: - push: - branches: - - create-bump-endpoint-workflow schedule: - cron: '0 * * * *' # every hour workflow_dispatch: From f2cce7b7e90edad50d1ba3b8bf43a59103d9db99 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Tue, 19 May 2026 14:56:04 -0700 Subject: [PATCH 355/360] temp: skip if branch already exists instead of checking for PR --- .github/workflows/bump-endpoint.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/bump-endpoint.yml b/.github/workflows/bump-endpoint.yml index becdb77..9a7df3b 100644 --- a/.github/workflows/bump-endpoint.yml +++ b/.github/workflows/bump-endpoint.yml @@ -63,9 +63,8 @@ jobs: OLD="${{ steps.current.outputs.version }}" BRANCH="bump/endpoint-${NEW}" - # Skip if a PR for this version already exists - if gh pr list --head "$BRANCH" --json number --jq '.[0].number' | grep -q '[0-9]'; then - echo "PR for $NEW already open, skipping." + if git ls-remote --exit-code --heads origin "$BRANCH" &>/dev/null; then + echo "Branch $BRANCH already exists, skipping." exit 0 fi From ab058367f1908260d5c1478c4cad620925a175d5 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Tue, 19 May 2026 14:56:46 -0700 Subject: [PATCH 356/360] temp: re-add push trigger for testing --- .github/workflows/bump-endpoint.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/bump-endpoint.yml b/.github/workflows/bump-endpoint.yml index 9a7df3b..6d4a93e 100644 --- a/.github/workflows/bump-endpoint.yml +++ b/.github/workflows/bump-endpoint.yml @@ -1,6 +1,9 @@ name: Bump Device Protection Automatically on: + push: + branches: + - create-bump-endpoint-workflow schedule: - cron: '0 * * * *' # every hour workflow_dispatch: From f6145d5c20226fcba96c3505290dacac7495e073 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Tue, 19 May 2026 14:58:55 -0700 Subject: [PATCH 357/360] Update bump-endpoint.yml --- .github/workflows/bump-endpoint.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/bump-endpoint.yml b/.github/workflows/bump-endpoint.yml index 6d4a93e..9a7df3b 100644 --- a/.github/workflows/bump-endpoint.yml +++ b/.github/workflows/bump-endpoint.yml @@ -1,9 +1,6 @@ name: Bump Device Protection Automatically on: - push: - branches: - - create-bump-endpoint-workflow schedule: - cron: '0 * * * *' # every hour workflow_dispatch: From aed0aebdae85825be0b56fd0bb7cd3a5bdc2dc41 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 20 May 2026 09:20:03 +0200 Subject: [PATCH 358/360] Store the slack url as a secret --- .github/workflows/bump-endpoint.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/bump-endpoint.yml b/.github/workflows/bump-endpoint.yml index 9a7df3b..8c61826 100644 --- a/.github/workflows/bump-endpoint.yml +++ b/.github/workflows/bump-endpoint.yml @@ -58,6 +58,7 @@ jobs: if: steps.latest.outputs.version != steps.current.outputs.version env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} run: | NEW="${{ steps.latest.outputs.version }}" OLD="${{ steps.current.outputs.version }}" @@ -76,6 +77,6 @@ jobs: git push origin "$BRANCH" PR_URL="https://github.com/${{ github.repository }}/compare/main...${BRANCH}?expand=1" - curl -s -X POST "https://hooks.slack.com/triggers/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" \ + curl -s -X POST "$SLACK_WEBHOOK_URL" \ -H "Content-Type: application/json" \ -d "{\"text\": \"update to ${NEW} - ${PR_URL}\"}" From 70b5e4d0125ade2fa53b3c42f5f76c6586d3918c Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Wed, 20 May 2026 08:39:03 -0700 Subject: [PATCH 359/360] 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 feabeb1..429dcc8 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.4/EndpointProtection.pkg" -DOWNLOAD_SHA256="f2ea55588d42e4aa17545ad787f46dd36001009e2ddb9655c497b1a36edf3581" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.5.3/EndpointProtection.pkg" +DOWNLOAD_SHA256="9a05eaf314876f236efd8a597aba6831b8569774d6cb4d0df4af4e74706a31eb" 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 29bc873..c47df95 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.3.4/EndpointProtection.msi" -$DownloadSha256 = "0699379716a9a8b1531befa538befb237252af9f7fd780b33f4dce73588c6f83" +$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.5.3/EndpointProtection.msi" +$DownloadSha256 = "e6d3d52a9c16b98014adb451dc7e544db15a55db59c83433f8d6f93aadd0c3d5" # Ensure TLS 1.2 is enabled for downloads [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 From 2621f6f974c1d2ec9ac25ba5cdc380a81641d5ef Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 21 May 2026 17:39:03 +0000 Subject: [PATCH 360/360] Bump Endpoint to v1.5.4 --- 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 429dcc8..4cb9e9a 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.5.3/EndpointProtection.pkg" -DOWNLOAD_SHA256="9a05eaf314876f236efd8a597aba6831b8569774d6cb4d0df4af4e74706a31eb" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.5.4/EndpointProtection.pkg" +DOWNLOAD_SHA256="ad800f9e476b0a75bf32b1c079f060ecb98bc16972a4e8cca29cf165388ea9fe" 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 c47df95..05da8b4 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.5.3/EndpointProtection.msi" -$DownloadSha256 = "e6d3d52a9c16b98014adb451dc7e544db15a55db59c83433f8d6f93aadd0c3d5" +$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.5.4/EndpointProtection.msi" +$DownloadSha256 = "e2750c59124f53456a8f9cdb9e81fd9ce2f2491869f68f01602444ad519be5be" # Ensure TLS 1.2 is enabled for downloads [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12