From cddcec9ba52df44c2064fb777ce5aa7accc5d7a8 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 19 Mar 2026 14:14:13 -0700 Subject: [PATCH 001/175] 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 002/175] 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 003/175] 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 004/175] 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 005/175] 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 d6aa016cbc4ebfca29b63828003ff7563ace9748 Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Wed, 25 Mar 2026 09:54:03 -0700 Subject: [PATCH 006/175] disable build-and-release and move to circleci config.yml --- .circleci/config.yml | 491 ++++++++++++++++++++++++ .github/workflows/build-and-release.yml | 5 +- 2 files changed, 493 insertions(+), 3 deletions(-) create mode 100644 .circleci/config.yml diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..331d042 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,491 @@ +version: 2.1 +# env: +# GITHUB_TOKEN — GitHub token with repo write access (used by gh CLI) +# NPM_PUBLISH_TOKEN — npm access token with publish rights + +orbs: + windows: circleci/windows@5.0 + +executors: + linux-node20: + docker: + - image: cimg/node:20.18 + resource_class: medium + + linux-arm64-node20: + docker: + - image: cimg/node:20.18 + resource_class: arm.medium + + linux-machine: + machine: + image: ubuntu-2404:current + resource_class: medium + + # Intel Mac — used for node20-macos-x64 target + macos-x64: + macos: + xcode: "16.0.0" + resource_class: macos.x86.medium.gen2 + + macos-arm64: + macos: + xcode: "16.0.0" + resource_class: macos.m2.medium.gen1 + +commands: + setup-node-20-macos: + steps: + - run: + name: Install Node.js 20 + command: | + echo 'export NVM_DIR="$HOME/.nvm"' >> "$BASH_ENV" + echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' >> "$BASH_ENV" + source "$BASH_ENV" + nvm install 20 + nvm alias default 20 + node --version + npm --version + + setup-node-20-windows: + steps: + - run: + name: Install Node.js 20 + command: | + nvm install 20.18.0 + nvm use 20.18.0 + node --version + npm --version + + install-safe-chain: + steps: + - run: + name: Setup safe-chain + command: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci + + set-package-version: + steps: + - run: + name: Set version in safe-chain package + command: | + source version.env + if [ -n "${VERSION}" ]; then + npm --no-git-tag-version version "${VERSION}" --workspace=packages/safe-chain --ignore-scripts + fi + +# --------------------------------------------------------------------------- +# Jobs +# --------------------------------------------------------------------------- + +jobs: + set-version: + executor: linux-machine + steps: + - checkout + - run: + name: Extract version and check pre-release status + command: | + VERSION="${CIRCLE_TAG}" + echo "VERSION=${VERSION}" > version.env + IS_PRERELEASE=$(gh release view "${VERSION}" \ + --json isPrerelease --jq '.isPrerelease' \ + --repo AikidoSec/safe-chain) + echo "IS_PRERELEASE=${IS_PRERELEASE}" >> version.env + cat version.env + - persist_to_workspace: + root: . + paths: + - version.env + + build-macos-x64: + executor: macos-x64 + steps: + - checkout + - attach_workspace: + at: . + - setup-node-20-macos + - install-safe-chain + - run: + name: Install dependencies + command: npm ci --ignore-scripts + - set-package-version + - run: + name: Create binary + command: node build.js node20-macos-x64 + - run: + name: Stage artifact + command: | + mkdir -p artifacts + cp dist/safe-chain artifacts/safe-chain-macos-x64 + - persist_to_workspace: + root: . + paths: + - artifacts/safe-chain-macos-x64 + + build-macos-arm64: + executor: macos-arm64 + steps: + - checkout + - attach_workspace: + at: . + - setup-node-20-macos + - install-safe-chain + - run: + name: Install dependencies + command: npm ci --ignore-scripts + - set-package-version + - run: + name: Create binary + command: node build.js node20-macos-arm64 + - run: + name: Stage artifact + command: | + mkdir -p artifacts + cp dist/safe-chain artifacts/safe-chain-macos-arm64 + - persist_to_workspace: + root: . + paths: + - artifacts/safe-chain-macos-arm64 + + build-linux-x64: + executor: linux-node20 + steps: + - checkout + - attach_workspace: + at: . + - install-safe-chain + - run: + name: Install dependencies + command: npm ci --ignore-scripts + - set-package-version + - run: + name: Create binary + command: node build.js node20-linux-x64 + - run: + name: Stage artifact + command: | + mkdir -p artifacts + cp dist/safe-chain artifacts/safe-chain-linux-x64 + - persist_to_workspace: + root: . + paths: + - artifacts/safe-chain-linux-x64 + + build-linux-arm64: + executor: linux-arm64-node20 + steps: + - checkout + - attach_workspace: + at: . + - install-safe-chain + - run: + name: Install dependencies + command: npm ci --ignore-scripts + - set-package-version + - run: + name: Create binary + command: node build.js node20-linux-arm64 + - run: + name: Stage artifact + command: | + mkdir -p artifacts + cp dist/safe-chain artifacts/safe-chain-linux-arm64 + - persist_to_workspace: + root: . + paths: + - artifacts/safe-chain-linux-arm64 + + build-linuxstatic-x64: + executor: linux-node20 + steps: + - checkout + - attach_workspace: + at: . + - install-safe-chain + - run: + name: Install dependencies + command: npm ci --ignore-scripts + - set-package-version + - run: + name: Create binary + command: node build.js node20-linuxstatic-x64 + - run: + name: Stage artifact + command: | + mkdir -p artifacts + cp dist/safe-chain artifacts/safe-chain-linuxstatic-x64 + - persist_to_workspace: + root: . + paths: + - artifacts/safe-chain-linuxstatic-x64 + + build-linuxstatic-arm64: + executor: linux-arm64-node20 + steps: + - checkout + - attach_workspace: + at: . + - install-safe-chain + - run: + name: Install dependencies + command: npm ci --ignore-scripts + - set-package-version + - run: + name: Create binary + command: node build.js node20-linuxstatic-arm64 + - run: + name: Stage artifact + command: | + mkdir -p artifacts + cp dist/safe-chain artifacts/safe-chain-linuxstatic-arm64 + - persist_to_workspace: + root: . + paths: + - artifacts/safe-chain-linuxstatic-arm64 + + build-win: + # CircleCI has no Windows ARM64 runner, so both Windows targets are built + # here on the x64 runner. pkg cross-compiles win-arm64 on win-x64 natively. + executor: + name: win/server-2022 + shell: bash.exe + resource_class: windows.medium + steps: + - checkout + - attach_workspace: + at: . + - setup-node-20-windows + - install-safe-chain + - run: + name: Install dependencies + command: npm ci --ignore-scripts + - set-package-version + - run: + name: Create win-x64 binary + command: node build.js node20-win-x64 + - run: + name: Stage win-x64 artifact + command: | + mkdir -p artifacts + cp dist/safe-chain.exe artifacts/safe-chain-win-x64.exe + - run: + name: Create win-arm64 binary + command: node build.js node20-win-arm64 + - run: + name: Stage win-arm64 artifact + command: cp dist/safe-chain.exe artifacts/safe-chain-win-arm64.exe + - persist_to_workspace: + root: . + paths: + - artifacts/safe-chain-win-x64.exe + - artifacts/safe-chain-win-arm64.exe + + publish-binaries: + # circleci_ip_ranges routes outbound traffic through static IPs. + # Requires a CircleCI Performance or Scale plan. + machine: + image: ubuntu-2404:current + resource_class: medium + circleci_ip_ranges: true + steps: + - checkout + - attach_workspace: + at: . + - run: + name: Prepare release artifacts + command: | + source version.env + mkdir -p release-artifacts + cp artifacts/safe-chain-macos-x64 release-artifacts/safe-chain-macos-x64 + cp artifacts/safe-chain-macos-arm64 release-artifacts/safe-chain-macos-arm64 + cp artifacts/safe-chain-linux-x64 release-artifacts/safe-chain-linux-x64 + cp artifacts/safe-chain-linux-arm64 release-artifacts/safe-chain-linux-arm64 + cp artifacts/safe-chain-linuxstatic-x64 release-artifacts/safe-chain-linuxstatic-x64 + cp artifacts/safe-chain-linuxstatic-arm64 release-artifacts/safe-chain-linuxstatic-arm64 + cp artifacts/safe-chain-win-x64.exe release-artifacts/safe-chain-win-x64.exe + cp artifacts/safe-chain-win-arm64.exe release-artifacts/safe-chain-win-arm64.exe + 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 + 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 + - run: + name: Upload binaries to GitHub Release + command: | + source version.env + gh release upload "${VERSION}" \ + release-artifacts/safe-chain-macos-x64 \ + 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 \ + release-artifacts/install-safe-chain.ps1 \ + release-artifacts/uninstall-safe-chain.sh \ + 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 \ + --repo AikidoSec/safe-chain + + publish-npm: + executor: linux-node20 + steps: + - checkout + - attach_workspace: + at: . + - run: + name: Skip if pre-release + command: | + source version.env + if [ "${IS_PRERELEASE}" = "true" ]; then + echo "Pre-release tag detected — skipping npm publish" + circleci-agent step halt + fi + - install-safe-chain + - run: + name: Set the version in safe-chain package + command: | + source version.env + npm --no-git-tag-version version "${VERSION}" --workspace=packages/safe-chain + - run: + name: Install dependencies + command: npm ci + - run: + name: Run tests + command: npm run test + - run: + name: Copy documentation files to package + command: | + cp README.md packages/safe-chain/ + cp LICENSE packages/safe-chain/ + cp -r docs packages/safe-chain/ + - run: + name: Configure npm authentication + command: echo "//registry.npmjs.org/:_authToken=${NPM_PUBLISH_TOKEN}" >> ~/.npmrc + - run: + name: Publish to npm + command: | + source version.env + echo "Publishing version ${VERSION} to NPM" + npm publish --workspace=packages/safe-chain --access public --provenance + +# --------------------------------------------------------------------------- +# Workflow — triggered on every tag push (mirrors GitHub's on.push.tags: ["*"]) +# --------------------------------------------------------------------------- +# IMPORTANT: In CircleCI, tag filters must be repeated on every job in the +# workflow, otherwise those jobs are skipped for tag-triggered pipelines. + +workflows: + release: + jobs: + - set-version: + filters: + branches: + ignore: /.*/ + tags: + only: /.*/ + + - build-macos-x64: + requires: + - set-version + filters: + branches: + ignore: /.*/ + tags: + only: /.*/ + + - build-macos-arm64: + requires: + - set-version + filters: + branches: + ignore: /.*/ + tags: + only: /.*/ + + - build-linux-x64: + requires: + - set-version + filters: + branches: + ignore: /.*/ + tags: + only: /.*/ + + - build-linux-arm64: + requires: + - set-version + filters: + branches: + ignore: /.*/ + tags: + only: /.*/ + + - build-linuxstatic-x64: + requires: + - set-version + filters: + branches: + ignore: /.*/ + tags: + only: /.*/ + + - build-linuxstatic-arm64: + requires: + - set-version + filters: + branches: + ignore: /.*/ + tags: + only: /.*/ + + - build-win: + requires: + - set-version + filters: + branches: + ignore: /.*/ + tags: + only: /.*/ + + # publish-binaries and publish-npm both fan in from all build jobs and + # run in parallel, matching the original GitHub Actions structure. + - publish-binaries: + requires: + - build-macos-x64 + - build-macos-arm64 + - build-linux-x64 + - build-linux-arm64 + - build-linuxstatic-x64 + - build-linuxstatic-arm64 + - build-win + filters: + branches: + ignore: /.*/ + tags: + only: /.*/ + + - publish-npm: + requires: + - build-macos-x64 + - build-macos-arm64 + - build-linux-x64 + - build-linux-arm64 + - build-linuxstatic-x64 + - build-linuxstatic-arm64 + - build-win + filters: + branches: + ignore: /.*/ + tags: + only: /.*/ diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index d6c810a..6552e17 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -1,9 +1,8 @@ name: Create Release +# Workflow disabled — release pipeline moved to CircleCI (.circleci/config.yml) on: - push: - tags: - - "*" + workflow_dispatch: permissions: id-token: write From 7b764609af63b01b86e7228ba0187405c216b9ae Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Wed, 25 Mar 2026 10:07:00 -0700 Subject: [PATCH 007/175] Apply suggestions from code review Co-authored-by: bitterpanda --- .circleci/config.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 331d042..f065d9e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -244,8 +244,7 @@ jobs: - artifacts/safe-chain-linuxstatic-arm64 build-win: - # CircleCI has no Windows ARM64 runner, so both Windows targets are built - # here on the x64 runner. pkg cross-compiles win-arm64 on win-x64 natively. + # CircleCI has no Windows ARM64 runner, so both Windows targets are built on x64 executor: name: win/server-2022 shell: bash.exe From 8a902d68d7d3c1f28fefaeda7b8fbc7cda6a9ed3 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Wed, 25 Mar 2026 10:07:40 -0700 Subject: [PATCH 008/175] Apply suggestions from code review Co-authored-by: bitterpanda --- .circleci/config.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index f065d9e..7f0bafc 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -280,8 +280,6 @@ jobs: - artifacts/safe-chain-win-arm64.exe publish-binaries: - # circleci_ip_ranges routes outbound traffic through static IPs. - # Requires a CircleCI Performance or Scale plan. machine: image: ubuntu-2404:current resource_class: medium From 757ddc4f15917747b4a79dc3535ea99bfed69a9f Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Wed, 25 Mar 2026 10:14:28 -0700 Subject: [PATCH 009/175] fix 'win' orb -> windows --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 7f0bafc..388dc67 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -246,7 +246,7 @@ jobs: build-win: # CircleCI has no Windows ARM64 runner, so both Windows targets are built on x64 executor: - name: win/server-2022 + name: windows/server-2022 shell: bash.exe resource_class: windows.medium steps: From ea242458dd806d484bb15dee196a6fc451f3b57d Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Wed, 25 Mar 2026 10:16:34 -0700 Subject: [PATCH 010/175] Fix Duplicated resource_class in job build-win and under machine in the same job (may be coming from an executor) --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 388dc67..5645539 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -248,7 +248,7 @@ jobs: executor: name: windows/server-2022 shell: bash.exe - resource_class: windows.medium + resource_class: windows.medium steps: - checkout - attach_workspace: From 0231cd92828e01f88b01f6b342519bb0e1519ebd Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Wed, 25 Mar 2026 10:18:36 -0700 Subject: [PATCH 011/175] Revert "Fix Duplicated resource_class in job build-win and under machine in the same job (may be coming from an executor)" This reverts commit ea242458dd806d484bb15dee196a6fc451f3b57d. --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 5645539..388dc67 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -248,7 +248,7 @@ jobs: executor: name: windows/server-2022 shell: bash.exe - resource_class: windows.medium + resource_class: windows.medium steps: - checkout - attach_workspace: From 3b73add44f88f51b369dd6f52d5ec7192f143c08 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Wed, 25 Mar 2026 10:19:40 -0700 Subject: [PATCH 012/175] remove windows reaource class --- .circleci/config.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 388dc67..b566989 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -248,7 +248,6 @@ jobs: executor: name: windows/server-2022 shell: bash.exe - resource_class: windows.medium steps: - checkout - attach_workspace: From 933a334d5b48398678acf1c6d85c0699301e89f9 Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Wed, 25 Mar 2026 10:21:06 -0700 Subject: [PATCH 013/175] Install gh cli tool on circleci --- .circleci/config.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index b566989..dd15949 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -82,6 +82,12 @@ jobs: executor: linux-machine steps: - checkout + - run: + name: Install GitHub CLI + command: | + curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list + sudo apt-get update && sudo apt-get install -y gh - run: name: Extract version and check pre-release status command: | From 33f50ba5804e2c8543dd7e0c65224bcd752811bd Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Wed, 25 Mar 2026 11:04:05 -0700 Subject: [PATCH 014/175] 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 015/175] 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 016/175] 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 017/175] 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 018/175] 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 019/175] 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 020/175] 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 021/175] 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 022/175] 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 023/175] 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 024/175] 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 025/175] 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 026/175] 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 027/175] 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 028/175] 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 029/175] 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 030/175] 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 031/175] 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 032/175] 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 033/175] 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 034/175] 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 035/175] 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 036/175] 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 037/175] 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 038/175] 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 039/175] 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 040/175] 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 041/175] 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 042/175] 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 043/175] 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 044/175] 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 045/175] 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 046/175] 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 047/175] 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 048/175] 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 049/175] 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 050/175] 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 051/175] 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 052/175] 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 053/175] 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 054/175] 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 055/175] 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 056/175] 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 057/175] 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 058/175] 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 059/175] 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 060/175] 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 061/175] 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 062/175] 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 063/175] 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 064/175] 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 065/175] 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 066/175] 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 067/175] 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 068/175] 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 069/175] 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 070/175] 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 071/175] 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 072/175] 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 073/175] 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 074/175] 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 075/175] 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 076/175] 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 077/175] 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 078/175] 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 079/175] 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 080/175] 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 081/175] 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 082/175] 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 083/175] 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 084/175] 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 085/175] 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 086/175] 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 087/175] 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 088/175] 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 089/175] 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 090/175] 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 091/175] 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 092/175] 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 093/175] 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 094/175] 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 095/175] 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 096/175] 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 097/175] 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 098/175] 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 099/175] 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 100/175] 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 101/175] 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 102/175] 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 103/175] 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 104/175] 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 105/175] 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 106/175] 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 107/175] 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 108/175] 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 109/175] 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 110/175] 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 111/175] 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 112/175] 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 113/175] 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 114/175] 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 115/175] 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 116/175] 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 117/175] 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 118/175] 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 119/175] 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 120/175] 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 121/175] 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 122/175] 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 123/175] 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 124/175] 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 125/175] 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 126/175] 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 127/175] 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 128/175] 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 129/175] 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 130/175] 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 131/175] 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 132/175] 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 133/175] 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 134/175] 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 135/175] 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 136/175] 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 137/175] 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 138/175] 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 139/175] 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 140/175] 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 141/175] 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 142/175] 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 143/175] 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 144/175] 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 145/175] 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 146/175] 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 147/175] 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 148/175] 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 149/175] 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 150/175] 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 151/175] 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 152/175] 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 153/175] 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 154/175] 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 155/175] 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 156/175] 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 157/175] 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 158/175] 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 159/175] 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 160/175] 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 161/175] 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 162/175] 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 163/175] 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 164/175] 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 165/175] 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 166/175] 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 167/175] 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 168/175] 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 169/175] 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 170/175] 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 171/175] 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 172/175] 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 173/175] 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 174/175] 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 From 40f45faea8d3592e3401c77b65e7b3121f427fa8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 26 May 2026 16:35:00 +0000 Subject: [PATCH 175/175] Bump Endpoint to v1.5.6 --- 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 4cb9e9a..4a78f52 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.4/EndpointProtection.pkg" -DOWNLOAD_SHA256="ad800f9e476b0a75bf32b1c079f060ecb98bc16972a4e8cca29cf165388ea9fe" +INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.5.6/EndpointProtection.pkg" +DOWNLOAD_SHA256="345b26168b3090de5268c48d923cdf115cc617c39c37d44cc40fb9150409a6ba" 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 05da8b4..a025a50 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.4/EndpointProtection.msi" -$DownloadSha256 = "e2750c59124f53456a8f9cdb9e81fd9ce2f2491869f68f01602444ad519be5be" +$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.5.6/EndpointProtection.msi" +$DownloadSha256 = "70382b65036c6a4f0fc64e221ab3e74b06ec23bce54f93616a1e59abaac5442d" # Ensure TLS 1.2 is enabled for downloads [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12