Merge branch 'rama-integration-beta' into rama-min-package-age-reporting

This commit is contained in:
Sander Declerck 2026-05-04 16:07:20 +02:00
commit 5f82e45b2b
No known key found for this signature in database
131 changed files with 6372 additions and 2461 deletions

View file

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

View file

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

View file

@ -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,9 +37,8 @@
"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": {
"archiver": "^7.0.1",
"certifi": "14.5.15",
"chalk": "5.4.1",
"https-proxy-agent": "7.0.6",
@ -49,7 +49,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",

View file

@ -3,14 +3,22 @@ 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 newPackagesListPaths = {
[ECOSYSTEM_JS]: "releases/npm.json",
[ECOSYSTEM_PY]: "releases/pypi.json",
};
const DEFAULT_FETCH_RETRY_ATTEMPTS = 4;
/**
* @typedef {Object} MalwarePackage
* @property {string} package_name
@ -18,18 +26,26 @@ const malwareDatabaseUrls = {
* @property {string} reason
*/
/**
* @typedef {Object} NewPackageEntry
* @property {string} [source]
* @property {string} package_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 =
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(
@ -46,21 +62,20 @@ export async function fetchMalwareDatabase() {
} catch (/** @type {any} */ error) {
throw new Error(`Error parsing malware database: ${error.message}`);
}
}, numberOfAttempts);
}, DEFAULT_FETCH_RETRY_ATTEMPTS);
}
/**
* @returns {Promise<string | undefined>}
*/
export async function fetchMalwareDatabaseVersion() {
const numberOfAttempts = 4;
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",
});
@ -71,7 +86,67 @@ 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 baseUrl = getMalwareListBaseUrl();
const path = newPackagesListPaths[/** @type {keyof typeof newPackagesListPaths} */ (ecosystem)];
if (!path) {
return { newPackagesList: [], version: undefined };
}
const url = `${baseUrl}/${path}`;
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<string | undefined>}
*/
export async function fetchNewPackagesListVersion() {
return retry(async () => {
const ecosystem = getEcoSystem();
const baseUrl = getMalwareListBaseUrl();
const path = newPackagesListPaths[/** @type {keyof typeof newPackagesListPaths} */ (ecosystem)];
if (!path) {
return undefined;
}
const url = `${baseUrl}/${path}`;
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 +166,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;

View file

@ -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,23 @@ describe("aikido API", async () => {
mock.module("../config/settings.js", {
namedExports: {
getEcoSystem: () => "js",
getEcoSystem: () => ecosystem,
ECOSYSTEM_JS: "js",
ECOSYSTEM_PY: "py",
getMalwareListBaseUrl: () => "https://malware-list.aikido.dev",
},
});
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 +137,95 @@ describe("aikido API", async () => {
assert.strictEqual(result, '"final-etag"');
});
});
describe("fetchNewPackagesList", () => {
it("should succeed immediately when fetch succeeds on first try", async () => {
const releases = [
{
package_name: "fresh-pkg",
version: "1.0.0",
released_on: 123,
},
];
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.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"');
});
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 = "ruby";
const result = await fetchNewPackagesList();
assert.strictEqual(mockFetch.mock.calls.length, 0);
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", () => {
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(
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"');
});
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);
});
});
});

View file

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

View file

@ -3,6 +3,7 @@ import path from "path";
import os from "os";
import { ui } from "../environment/userInteraction.js";
import { getEcoSystem } from "./settings.js";
import { getSafeChainBaseDir } from "./safeChainDir.js";
/**
* @typedef {Object} SafeChainConfig
@ -10,6 +11,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 +86,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[]}
@ -129,18 +143,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 [];
@ -211,6 +228,7 @@ function readConfigFile() {
const emptyConfig = {
scanTimeout: undefined,
minimumPackageAgeHours: undefined,
malwareListBaseUrl: undefined,
npm: {
customRegistries: undefined,
},
@ -248,11 +266,51 @@ 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}
*/
function getConfigFilePath() {
return path.join(getAikidoDirectory(), "config.json");
const primaryPath = path.join(getSafeChainDirectory(), "config.json");
if (fs.existsSync(primaryPath)) {
return primaryPath;
}
const legacyPath = path.join(getAikidoDirectory(), "config.json");
if (fs.existsSync(legacyPath)) {
return legacyPath;
}
return primaryPath;
}
/**
* @returns {string}
*/
export function getSafeChainDirectory() {
const safeChainDir = getSafeChainBaseDir();
if (!fs.existsSync(safeChainDir)) {
fs.mkdirSync(safeChainDir, { recursive: true });
}
return safeChainDir;
}
/**

View file

@ -1,16 +1,35 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
import os from "os";
import path from "path";
let configFileContent = undefined;
const safeChainConfigPath = path.join(os.homedir(), ".safe-chain", "config.json");
const aikidoConfigPath = path.join(os.homedir(), ".aikido", "config.json");
/** @type {Map<string, string>} */
let mockFiles = new Map();
mock.module("fs", {
namedExports: {
existsSync: () => configFileContent !== undefined,
readFileSync: () => configFileContent,
writeFileSync: (content) => (configFileContent = content),
existsSync: (filePath) => mockFiles.has(filePath),
readFileSync: (filePath) => {
if (!mockFiles.has(filePath)) {
throw new Error(`ENOENT: no such file: ${filePath}`);
}
return mockFiles.get(filePath);
},
writeFileSync: (filePath, content) => mockFiles.set(filePath, content),
mkdirSync: () => {},
},
});
/**
* Helper to set config content at the primary (~/.safe-chain/) location.
* @param {string} content
*/
function setConfigContent(content) {
mockFiles.set(safeChainConfigPath, content);
}
describe("getScanTimeout", async () => {
let originalEnv;
@ -29,12 +48,11 @@ describe("getScanTimeout", async () => {
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
}
configFileContent = undefined;
mockFiles.clear();
});
it("should return default timeout of 10000ms when no config or env var is set", () => {
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
configFileContent = undefined;
const timeout = getScanTimeout();
@ -43,7 +61,7 @@ describe("getScanTimeout", async () => {
it("should return timeout from config file when set", () => {
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
configFileContent = JSON.stringify({ scanTimeout: 5000 });
setConfigContent(JSON.stringify({ scanTimeout: 5000 }));
const timeout = getScanTimeout();
@ -52,7 +70,7 @@ describe("getScanTimeout", async () => {
it("should prioritize environment variable over config file", () => {
process.env.AIKIDO_SCAN_TIMEOUT_MS = "20000";
configFileContent = JSON.stringify({ scanTimeout: 5000 });
setConfigContent(JSON.stringify({ scanTimeout: 5000 }));
const timeout = getScanTimeout();
@ -61,7 +79,7 @@ describe("getScanTimeout", async () => {
it("should handle invalid environment variable and fall back to config", () => {
process.env.AIKIDO_SCAN_TIMEOUT_MS = "invalid";
configFileContent = JSON.stringify({ scanTimeout: 7000 });
setConfigContent(JSON.stringify({ scanTimeout: 7000 }));
const timeout = getScanTimeout();
@ -69,8 +87,6 @@ describe("getScanTimeout", async () => {
});
it("should ignore zero and negative values and fall back to default", () => {
configFileContent = undefined;
process.env.AIKIDO_SCAN_TIMEOUT_MS = "0";
let timeout = getScanTimeout();
@ -84,7 +100,7 @@ describe("getScanTimeout", async () => {
it("should ignore textual non-numeric values in environment variable and fall back to config", () => {
process.env.AIKIDO_SCAN_TIMEOUT_MS = "fast";
configFileContent = JSON.stringify({ scanTimeout: 8000 });
setConfigContent(JSON.stringify({ scanTimeout: 8000 }));
const timeout = getScanTimeout();
@ -93,7 +109,7 @@ describe("getScanTimeout", async () => {
it("should ignore textual non-numeric values in config file and fall back to default", () => {
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
configFileContent = JSON.stringify({ scanTimeout: "slow" });
setConfigContent(JSON.stringify({ scanTimeout: "slow" }));
const timeout = getScanTimeout();
@ -102,7 +118,7 @@ describe("getScanTimeout", async () => {
it("should ignore textual non-numeric values in both env and config, fall back to default", () => {
process.env.AIKIDO_SCAN_TIMEOUT_MS = "quick";
configFileContent = JSON.stringify({ scanTimeout: "medium" });
setConfigContent(JSON.stringify({ scanTimeout: "medium" }));
const timeout = getScanTimeout();
@ -111,7 +127,7 @@ describe("getScanTimeout", async () => {
it("should ignore mixed alphanumeric strings in environment variable", () => {
process.env.AIKIDO_SCAN_TIMEOUT_MS = "5000ms";
configFileContent = JSON.stringify({ scanTimeout: 6000 });
setConfigContent(JSON.stringify({ scanTimeout: 6000 }));
const timeout = getScanTimeout();
@ -120,7 +136,7 @@ describe("getScanTimeout", async () => {
it("should ignore mixed alphanumeric strings in config file", () => {
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
configFileContent = JSON.stringify({ scanTimeout: "3000ms" });
setConfigContent(JSON.stringify({ scanTimeout: "3000ms" }));
const timeout = getScanTimeout();
@ -132,19 +148,17 @@ describe("getMinimumPackageAgeHours", async () => {
const { getMinimumPackageAgeHours } = await import("./configFile.js");
afterEach(() => {
configFileContent = undefined;
mockFiles.clear();
});
it("should return null when config file doesn't exist", () => {
configFileContent = undefined;
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, undefined);
});
it("should return null when config file exists but minimumPackageAgeHours is not set", () => {
configFileContent = JSON.stringify({ scanTimeout: 5000 });
setConfigContent(JSON.stringify({ scanTimeout: 5000 }));
const hours = getMinimumPackageAgeHours();
@ -152,7 +166,7 @@ describe("getMinimumPackageAgeHours", async () => {
});
it("should return value from config file when set to valid number", () => {
configFileContent = JSON.stringify({ minimumPackageAgeHours: 48 });
setConfigContent(JSON.stringify({ minimumPackageAgeHours: 48 }));
const hours = getMinimumPackageAgeHours();
@ -160,7 +174,7 @@ describe("getMinimumPackageAgeHours", async () => {
});
it("should handle string numbers in config file", () => {
configFileContent = JSON.stringify({ minimumPackageAgeHours: "72" });
setConfigContent(JSON.stringify({ minimumPackageAgeHours: "72" }));
const hours = getMinimumPackageAgeHours();
@ -168,7 +182,7 @@ describe("getMinimumPackageAgeHours", async () => {
});
it("should handle decimal values", () => {
configFileContent = JSON.stringify({ minimumPackageAgeHours: 1.5 });
setConfigContent(JSON.stringify({ minimumPackageAgeHours: 1.5 }));
const hours = getMinimumPackageAgeHours();
@ -176,7 +190,7 @@ describe("getMinimumPackageAgeHours", async () => {
});
it("should return null for non-numeric strings", () => {
configFileContent = JSON.stringify({ minimumPackageAgeHours: "invalid" });
setConfigContent(JSON.stringify({ minimumPackageAgeHours: "invalid" }));
const hours = getMinimumPackageAgeHours();
@ -184,7 +198,7 @@ describe("getMinimumPackageAgeHours", async () => {
});
it("should return undefined for values with units suffix", () => {
configFileContent = JSON.stringify({ minimumPackageAgeHours: "48h" });
setConfigContent(JSON.stringify({ minimumPackageAgeHours: "48h" }));
const hours = getMinimumPackageAgeHours();
@ -192,7 +206,7 @@ describe("getMinimumPackageAgeHours", async () => {
});
it("should handle malformed JSON and return null", () => {
configFileContent = "{ invalid json";
setConfigContent("{ invalid json");
const hours = getMinimumPackageAgeHours();
@ -200,7 +214,7 @@ describe("getMinimumPackageAgeHours", async () => {
});
it("should return 0 when minimumPackageAgeHours is set to 0", () => {
configFileContent = JSON.stringify({ minimumPackageAgeHours: 0 });
setConfigContent(JSON.stringify({ minimumPackageAgeHours: 0 }));
const hours = getMinimumPackageAgeHours();
@ -208,7 +222,7 @@ describe("getMinimumPackageAgeHours", async () => {
});
it("should return 0 when minimumPackageAgeHours is set to string '0'", () => {
configFileContent = JSON.stringify({ minimumPackageAgeHours: "0" });
setConfigContent(JSON.stringify({ minimumPackageAgeHours: "0" }));
const hours = getMinimumPackageAgeHours();
@ -216,7 +230,7 @@ describe("getMinimumPackageAgeHours", async () => {
});
it("should handle negative numeric values", () => {
configFileContent = JSON.stringify({ minimumPackageAgeHours: -24 });
setConfigContent(JSON.stringify({ minimumPackageAgeHours: -24 }));
const hours = getMinimumPackageAgeHours();
@ -224,7 +238,7 @@ describe("getMinimumPackageAgeHours", async () => {
});
it("should handle negative string values", () => {
configFileContent = JSON.stringify({ minimumPackageAgeHours: "-48" });
setConfigContent(JSON.stringify({ minimumPackageAgeHours: "-48" }));
const hours = getMinimumPackageAgeHours();
@ -249,19 +263,17 @@ for (const { packageManager, getCustomRegistries } of [
{
describe(getCustomRegistries.name, async () => {
afterEach(() => {
configFileContent = undefined;
mockFiles.clear();
});
it("should return empty array when config file doesn't exist", () => {
configFileContent = undefined;
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, []);
});
it(`should return empty array when ${packageManager} config is not set`, () => {
configFileContent = JSON.stringify({ scanTimeout: 5000 });
setConfigContent(JSON.stringify({ scanTimeout: 5000 }));
const registries = getCustomRegistries();
@ -269,9 +281,9 @@ for (const { packageManager, getCustomRegistries } of [
});
it("should return empty array when customRegistries is not an array", () => {
configFileContent = JSON.stringify({
setConfigContent(JSON.stringify({
[packageManager]: { customRegistries: "not-an-array" },
});
}));
const registries = getCustomRegistries();
@ -279,11 +291,11 @@ for (const { packageManager, getCustomRegistries } of [
});
it("should return array of custom registries when set", () => {
configFileContent = JSON.stringify({
setConfigContent(JSON.stringify({
[packageManager]: {
customRegistries: [`${packageManager}.company.com`, "registry.internal.net"],
},
});
}));
const registries = getCustomRegistries();
@ -294,7 +306,7 @@ for (const { packageManager, getCustomRegistries } of [
});
it("should filter out non-string values", () => {
configFileContent = JSON.stringify({
setConfigContent(JSON.stringify({
[packageManager]: {
customRegistries: [
`${packageManager}.company.com`,
@ -305,7 +317,7 @@ for (const { packageManager, getCustomRegistries } of [
{},
],
},
});
}));
const registries = getCustomRegistries();
@ -316,9 +328,9 @@ for (const { packageManager, getCustomRegistries } of [
});
it("should return empty array for empty customRegistries array", () => {
configFileContent = JSON.stringify({
setConfigContent(JSON.stringify({
[packageManager]: { customRegistries: [] },
});
}));
const registries = getCustomRegistries();
@ -326,7 +338,7 @@ for (const { packageManager, getCustomRegistries } of [
});
it("should handle malformed JSON and return empty array", () => {
configFileContent = "{ invalid json";
setConfigContent("{ invalid json");
const registries = getCustomRegistries();
@ -334,3 +346,35 @@ for (const { packageManager, getCustomRegistries } of [
});
});
}
describe("config file location fallback", async () => {
const { getScanTimeout } = await import("./configFile.js");
afterEach(() => {
mockFiles.clear();
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
});
it("should read config from ~/.safe-chain/config.json when it exists", () => {
mockFiles.set(safeChainConfigPath, JSON.stringify({ scanTimeout: 3000 }));
assert.strictEqual(getScanTimeout(), 3000);
});
it("should fall back to ~/.aikido/config.json when primary does not exist", () => {
mockFiles.set(aikidoConfigPath, JSON.stringify({ scanTimeout: 4000 }));
assert.strictEqual(getScanTimeout(), 4000);
});
it("should prefer ~/.safe-chain/config.json when both exist", () => {
mockFiles.set(safeChainConfigPath, JSON.stringify({ scanTimeout: 3000 }));
mockFiles.set(aikidoConfigPath, JSON.stringify({ scanTimeout: 4000 }));
assert.strictEqual(getScanTimeout(), 3000);
});
it("should return default when neither config file exists", () => {
assert.strictEqual(getScanTimeout(), 10000);
});
});

View file

@ -41,6 +41,17 @@ 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;
}
/**
* 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;
}

View file

@ -0,0 +1,71 @@
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
import { getInstalledSafeChainDir } from "../installLocation.js";
/**
* @returns {string}
*/
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");
}
/**
* 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(resolveModuleDir(moduleUrl), "startup-scripts", fileName);
}
/**
* @param {string | undefined} moduleUrl
* @param {string} fileName
* @returns {string}
*/
export function getPathWrapperTemplatePath(moduleUrl, fileName) {
return path.join(resolveModuleDir(moduleUrl), "path-wrappers", "templates", fileName);
}

View file

@ -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";
@ -45,7 +46,7 @@ export function setEcoSystem(setting) {
ecosystemSettings.ecoSystem = setting;
}
const defaultMinimumPackageAge = 24;
const defaultMinimumPackageAge = 48;
/** @returns {number} */
export function getMinimumPackageAgeHours() {
// Priority 1: CLI argument
@ -188,13 +189,59 @@ 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];
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) {
const url = removeTrailingSlashes(cliValue);
ui.writeVerbose(`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) {
const url = removeTrailingSlashes(envValue);
ui.writeVerbose(`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) {
const url = removeTrailingSlashes(configValue);
ui.writeVerbose(`Fetching malware lists from ${url} as defined by config file (malwareListBaseUrl)`);
return url;
}
// Default
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(/\/+$/, "");
}

View file

@ -14,7 +14,11 @@ mock.module("fs", {
const {
getNpmCustomRegistries,
getPipCustomRegistries,
getNpmMinimumPackageAgeExclusions,
getMinimumPackageAgeExclusions,
getMalwareListBaseUrl,
setEcoSystem,
ECOSYSTEM_JS,
ECOSYSTEM_PY,
getLoggingLevel,
LOGGING_SILENT,
LOGGING_NORMAL,
@ -367,13 +371,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 +391,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 +414,7 @@ describe("getNpmMinimumPackageAgeExclusions", () => {
},
});
const exclusions = getNpmMinimumPackageAgeExclusions();
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["react", "@aikidosec/safe-chain"]);
});
@ -409,7 +423,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 +436,7 @@ describe("getNpmMinimumPackageAgeExclusions", () => {
},
});
const exclusions = getNpmMinimumPackageAgeExclusions();
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["lodash", "react"]);
});
@ -435,7 +449,7 @@ describe("getNpmMinimumPackageAgeExclusions", () => {
},
});
const exclusions = getNpmMinimumPackageAgeExclusions();
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["lodash", "react", "express"]);
});
@ -444,7 +458,7 @@ describe("getNpmMinimumPackageAgeExclusions", () => {
process.env[envVarName] = " lodash , react ";
configFileContent = undefined;
const exclusions = getNpmMinimumPackageAgeExclusions();
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["lodash", "react"]);
});
@ -456,7 +470,7 @@ describe("getNpmMinimumPackageAgeExclusions", () => {
},
});
const exclusions = getNpmMinimumPackageAgeExclusions();
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["@babel/core", "@types/react"]);
});
@ -465,7 +479,7 @@ describe("getNpmMinimumPackageAgeExclusions", () => {
process.env[envVarName] = "lodash,,react,";
configFileContent = undefined;
const exclusions = getNpmMinimumPackageAgeExclusions();
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["lodash", "react"]);
});
@ -474,7 +488,7 @@ describe("getNpmMinimumPackageAgeExclusions", () => {
process.env[envVarName] = "";
configFileContent = undefined;
const exclusions = getNpmMinimumPackageAgeExclusions();
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, []);
});
@ -483,7 +497,7 @@ describe("getNpmMinimumPackageAgeExclusions", () => {
process.env[envVarName] = " , , ";
configFileContent = undefined;
const exclusions = getNpmMinimumPackageAgeExclusions();
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, []);
});
@ -495,8 +509,139 @@ 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"]);
});
});
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 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"]);
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");
});
});

View file

@ -0,0 +1,42 @@
import path from "path";
/** @type {NodeJS.Process & { pkg?: unknown }} */
const processWithPkg = process;
/**
* @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(processWithPkg.pkg);
if (!isPackaged) {
return undefined;
}
return deriveInstallDirFromExecutablePath(
options.executablePath ?? process.execPath,
);
}

View file

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

View file

@ -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<boolean>}
*/
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<string | null>} 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;
}

View file

@ -1,45 +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 } from "node:fs";
import {
DOWNLOAD_URLS,
downloadFile,
verifyChecksum,
} from "./downloadAgent.js";
describe("downloadAgent checksums", { timeout: 120_000 }, () => {
const downloadedFiles = [];
after(() => {
for (const file of downloadedFiles) {
try {
unlinkSync(file);
} catch {
// ignore cleanup errors
}
}
});
for (const [platform, architectures] of Object.entries(DOWNLOAD_URLS)) {
for (const [arch, { url, checksum }] of Object.entries(architectures)) {
it(`${platform}/${arch} checksum matches`, async () => {
const destPath = join(
tmpdir(),
`safe-chain-test-${platform}-${arch}-${Date.now()}`
);
downloadedFiles.push(destPath);
await downloadFile(url, destPath);
const isValid = await verifyChecksum(destPath, checksum);
assert.strictEqual(
isValid,
true,
`Checksum mismatch for ${platform}/${arch} (${url})`
);
});
}
}
});

View file

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

View file

@ -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<boolean>} 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.");
}
}

View file

@ -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.`,
);
}
}

View file

@ -20,8 +20,10 @@ export async function main(args) {
process.on("SIGINT", handleProcessTermination);
process.on("SIGTERM", handleProcessTermination);
/** @type {import("./registryProxy/registryProxy.js").MalwareBlockedEvent[]} */
/** @type {import("./registryProxy/registryProxy.js").PackageBlockedEvent[]} */
let malwareBlockedEvents = [];
/** @type {import("./registryProxy/registryProxy.js").PackageBlockedEvent[]} */
let minPackageAgeBlocks = [];
/** @type {import("./registryProxy/registryProxy.js").MinPackageAgeSuppressionEvent[]} */
let suppressedVersionEvents = [];
@ -32,6 +34,9 @@ export async function main(args) {
proxy.addListener("minPackageAgeVersionsSuppressed", (ev) =>
suppressedVersionEvents.push(ev),
);
proxy.addListener("minimumAgeRequestBlocked", (ev) =>
minPackageAgeBlocks.push(ev),
);
// Global error handlers to log unhandled errors
process.on("uncaughtException", (error) => {
@ -79,6 +84,11 @@ export async function main(args) {
return 1;
}
if (minPackageAgeBlocks.length > 0) {
printMinPackageAgeBlocks(minPackageAgeBlocks);
return 1;
}
const auditStats = getAuditStats();
if (auditStats.totalPackages > 0) {
ui.writeVerbose(
@ -122,7 +132,7 @@ function isSafeChainVerify(args) {
/**
*
* @param {import("./registryProxy/registryProxy.js").MalwareBlockedEvent[]} malwareBlockedEvents
* @param {import("./registryProxy/registryProxy.js").PackageBlockedEvent[]} malwareBlockedEvents
*/
function printBlockedMalware(malwareBlockedEvents) {
ui.emptyLine();
@ -142,6 +152,36 @@ function printBlockedMalware(malwareBlockedEvents) {
ui.emptyLine();
}
/**
*
* @param {import("./registryProxy/registryProxy.js").PackageBlockedEvent[]} minPackageAgeBlocks
*/
function printMinPackageAgeBlocks(minPackageAgeBlocks) {
ui.emptyLine();
ui.writeInformation(
`Safe-chain: ${chalk.bold(
`blocked ${minPackageAgeBlocks.length} direct package download request(s) due to minimum package age`,
)}:`,
);
for (const req of minPackageAgeBlocks) {
ui.writeInformation(` - ${req.packageName}@${req.packageVersion}`);
}
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 the direct download minimum package age check.",
);
ui.emptyLine();
}
/**
*
* @param {import("./registryProxy/registryProxy.js").MinPackageAgeSuppressionEvent[]} minPackageAgeSuppressionEvents
@ -150,7 +190,7 @@ function printSuppressedVersions(minPackageAgeSuppressionEvents) {
ui.writeVerbose(
`${chalk.yellow(
"",
)} Safe-chain: Suppressed package versions due to minimum age requirement:`,
)} Safe-chain: Some package versions were suppressed during package metadata resolution due to minimum package age:`,
);
for (const ev of minPackageAgeSuppressionEvents) {

View file

@ -0,0 +1,17 @@
import { ui } from "../../environment/userInteraction.js";
/**
* Centralized logging for package-manager command launch failures.
*
* @param {any} error - Error thrown by safeSpawn while preparing/running the command.
* @param {string} command - Command name that failed to execute.
* @returns {{status: number}}
*/
export function reportCommandExecutionFailure(error, command) {
const message = typeof error?.message === "string" ? error.message : "Unknown error";
ui.writeError(`Error executing command: ${message}`);
ui.writeError(`Is '${command}' installed and available on your system?`);
return { status: typeof error?.status === "number" ? error.status : 1 };
}

View file

@ -0,0 +1,59 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
describe("reportCommandExecutionFailure", () => {
let errorLines;
beforeEach(async () => {
errorLines = [];
mock.module("../../environment/userInteraction.js", {
namedExports: {
ui: {
writeError: (...args) => {
errorLines.push(args.join(" "));
},
},
},
});
});
afterEach(() => {
mock.reset();
});
it("reports command errors while preserving exit status", async () => {
const { reportCommandExecutionFailure } = await import("./commandErrors.js");
const result = reportCommandExecutionFailure(
{
status: 127,
message: "Command failed: command -v bun",
},
"bun",
);
assert.deepStrictEqual(result, { status: 127 });
assert.deepStrictEqual(errorLines, [
"Error executing command: Command failed: command -v bun",
"Is 'bun' installed and available on your system?",
]);
});
it("falls back to exit code 1 when status is missing", async () => {
const { reportCommandExecutionFailure } = await import("./commandErrors.js");
const result = reportCommandExecutionFailure(
{
message: "Network error",
},
"npm",
);
assert.deepStrictEqual(result, { status: 1 });
assert.deepStrictEqual(errorLines, [
"Error executing command: Network error",
"Is 'npm' installed and available on your system?",
]);
});
});

View file

@ -1,6 +1,6 @@
import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/**
* @returns {import("../currentPackageManager.js").PackageManager}
@ -43,11 +43,6 @@ async function runBunCommand(command, args) {
});
return { status: result.status };
} catch (/** @type any */ error) {
if (error.status) {
return { status: error.status };
} else {
ui.writeError("Error executing command:", error.message);
return { status: 1 };
}
return reportCommandExecutionFailure(error, command);
}
}

View file

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

View file

@ -1,6 +1,6 @@
import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/**
* @param {string[]} args
@ -15,11 +15,6 @@ export async function runNpm(args) {
});
return { status: result.status };
} catch (/** @type any */ error) {
if (error.status) {
return { status: error.status };
} else {
ui.writeError("Error executing command:", error.message);
return { status: 1 };
}
return reportCommandExecutionFailure(error, "npm");
}
}

View file

@ -1,6 +1,6 @@
import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/**
* @param {string[]} args
@ -15,11 +15,6 @@ export async function runNpx(args) {
});
return { status: result.status };
} catch (/** @type any */ error) {
if (error.status) {
return { status: error.status };
} else {
ui.writeError("Error executing command:", error.message);
return { status: 1 };
}
return reportCommandExecutionFailure(error, "npx");
}
}

View file

@ -11,6 +11,7 @@ import os from "node:os";
import path from "node:path";
import ini from "ini";
import { spawn } from "child_process";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/**
* Checks if this pip invocation should bypass safe-chain and spawn directly.
@ -205,12 +206,6 @@ export async function runPip(command, args) {
return { status: result.status };
} catch (/** @type any */ error) {
if (error.status) {
return { status: error.status };
} else {
ui.writeError(`Error executing command: ${error.message}`);
ui.writeError(`Is '${command}' installed and available on your system?`);
return { status: 1 };
}
return reportCommandExecutionFailure(error, command);
}
}

View file

@ -4,6 +4,7 @@ import {
getProxySettings,
mergeSafeChainProxyEnvironmentVariables,
} from "../../registryProxy/registryProxy.js";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/**
* Sets CA bundle environment variables used by Python libraries and pipx.
@ -56,12 +57,6 @@ export async function runPipX(command, args) {
return { status: result.status };
} catch (/** @type any */ error) {
if (error.status) {
return { status: error.status };
} else {
ui.writeError(`Error executing command: ${error.message}`);
ui.writeError(`Is '${command}' installed and available on your system?`);
return { status: 1 };
}
return reportCommandExecutionFailure(error, command);
}
}

View file

@ -1,6 +1,6 @@
import { ui } from "../../environment/userInteraction.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { safeSpawn } from "../../utils/safeSpawn.js";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/**
* @param {string[]} args
@ -26,11 +26,7 @@ export async function runPnpmCommand(args, toolName = "pnpm") {
return { status: result.status };
} catch (/** @type any */ error) {
if (error.status) {
return { status: error.status };
} else {
ui.writeError("Error executing command:", error.message);
return { status: 1 };
}
const target = toolName === "pnpm" ? "pnpm" : "pnpx";
return reportCommandExecutionFailure(error, target);
}
}

View file

@ -4,6 +4,7 @@ import {
getProxySettings,
mergeSafeChainProxyEnvironmentVariables,
} from "../../registryProxy/registryProxy.js";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/**
* @returns {import("../currentPackageManager.js").PackageManager}
@ -68,12 +69,6 @@ async function runPoetryCommand(args) {
return { status: result.status };
} catch (/** @type any */ error) {
if (error.status) {
return { status: error.status };
} else {
ui.writeError("Error executing command:", error.message);
ui.writeError("Is 'poetry' installed and available on your system?");
return { status: 1 };
}
return reportCommandExecutionFailure(error, "poetry");
}
}

View file

@ -4,6 +4,7 @@ import {
getProxySettings,
mergeSafeChainProxyEnvironmentVariables,
} from "../../registryProxy/registryProxy.js";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/**
* Sets CA bundle environment variables used by Python libraries and uv.
@ -62,12 +63,6 @@ export async function runUv(command, args) {
return { status: result.status };
} catch (/** @type any */ error) {
if (error.status) {
return { status: error.status };
} else {
ui.writeError(`Error executing command: ${error.message}`);
ui.writeError(`Is '${command}' installed and available on your system?`);
return { status: 1 };
}
return reportCommandExecutionFailure(error, command);
}
}

View file

@ -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: () => [],
};
}

View file

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

View file

@ -1,6 +1,6 @@
import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/**
* @param {string[]} args
@ -18,12 +18,7 @@ export async function runYarnCommand(args) {
});
return { status: result.status };
} catch (/** @type any */ error) {
if (error.status) {
return { status: error.status };
} else {
ui.writeError("Error executing command:", error.message);
return { status: 1 };
}
return reportCommandExecutionFailure(error, "yarn");
}
}

View file

@ -1,9 +1,8 @@
import forge from "node-forge";
import path from "path";
import fs from "fs";
import os from "os";
import { getCertsDir } from "../../config/safeChainDir.js";
const certFolder = path.join(os.homedir(), ".safe-chain", "certs");
const ca = loadCa();
const certCache = new Map();
@ -20,7 +19,7 @@ function createKeyIdentifier(publicKey) {
}
export function getCaCertPath() {
return path.join(certFolder, "ca-cert.pem");
return path.join(getCertsDir(), "ca-cert.pem");
}
/**
@ -112,6 +111,7 @@ export function generateCertForHost(hostname) {
}
function loadCa() {
const certFolder = getCertsDir();
const keyPath = path.join(certFolder, "ca-key.pem");
const certPath = path.join(certFolder, "ca-cert.pem");

View file

@ -0,0 +1,71 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
describe("certUtils", () => {
let installedSafeChainDir;
beforeEach(() => {
installedSafeChainDir = undefined;
mock.module("../../config/safeChainDir.js", {
namedExports: {
getSafeChainBaseDir: () => installedSafeChainDir ?? "/home/test/.safe-chain",
getCertsDir: () => `${installedSafeChainDir ?? "/home/test/.safe-chain"}/certs`,
},
});
});
afterEach(() => {
mock.reset();
});
it("stores CA certificates in the packaged install dir when available", async () => {
installedSafeChainDir = "/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",
);
});
});

View file

@ -8,6 +8,8 @@ import { getCaCertPath } from "./certUtils.js";
import { readFileSync } from "fs";
import EventEmitter from "events";
import { modifyResponseEventEmitter } from "./interceptors/npm/modifyNpmInfo.js";
import { cleanupCertBundle } from "../certBundle.js";
import { getHasSuppressedVersions } from "./interceptors/suppressedVersionsState.js";
/** *
* @returns {import("../registryProxy.js").SafeChainProxy} */
@ -50,8 +52,11 @@ export function createBuiltInProxyServer() {
*/
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;
@ -76,12 +81,16 @@ export function createBuiltInProxyServer() {
return new Promise((resolve) => {
try {
server.close(() => {
cleanupCertBundle();
resolve();
});
} catch {
resolve();
}
setTimeout(() => resolve(), SERVER_STOP_TIMEOUT_MS);
setTimeout(() => {
cleanupCertBundle();
resolve();
}, SERVER_STOP_TIMEOUT_MS);
});
}
@ -112,6 +121,18 @@ export function createBuiltInProxyServer() {
},
);
interceptor.on(
"minimumAgeRequestBlocked",
(
/** @type {import("./interceptors/interceptorBuilder.js").MinimumAgeRequestBlockedEvent} */ event,
) => {
emitter.emit("minimumAgeRequestBlocked", {
packageName: event.packageName,
packageVersion: event.version,
});
},
);
mitmConnect(req, clientSocket, interceptor);
} else {
// For other hosts, just tunnel the request to the destination tcp socket

View file

@ -15,3 +15,66 @@ 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<string | string[]> | undefined} headers
* @param {string[]} headerNames
* @param {{ caseInsensitive?: boolean }} [options]
* @returns {NodeJS.Dict<string | string[]> | 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<string | string[]>} */
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.
*
* @param {NodeJS.Dict<string | string[]> | undefined} headers
* @returns {void}
*/
export function clearCachingHeaders(headers) {
if (!headers) {
return;
}
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);
}

View file

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

View file

@ -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<string | string[]>) => NodeJS.Dict<string | string[]>) => void} modifyRequestHeaders
* @property {(modificationFunc: (body: Buffer, headers: NodeJS.Dict<string | string[]> | 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,

View file

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

View file

@ -4,7 +4,7 @@ import {
getNpmMinimumPackageAgeExclusions,
} from "../../../../config/settings.js";
import { ui } from "../../../../environment/userInteraction.js";
import { getHeaderValueAsString } from "../../http-utils.js";
import { clearCachingHeaders, getHeaderValueAsString } from "../../http-utils.js";
/** @type {EventEmitter<{ versionsRemoved: [{packageName: string, packageVersions: string[]}] }>} */
export const modifyResponseEventEmitter = new EventEmitter();
@ -104,15 +104,7 @@ export function modifyNpmInfoResponse(body, headers) {
removedVersions.push(version);
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);
}
}
@ -213,3 +205,22 @@ function matchesExclusionPattern(packageName, pattern) {
}
return packageName === pattern;
}
/**
* @param {Buffer} body
* @param {NodeJS.Dict<string | string[]> | 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;
}
}

View file

@ -5,11 +5,16 @@ import {
import { isMalwarePackage } from "../../../../scanning/audit/index.js";
import { interceptRequests } from "../interceptorBuilder.js";
import {
getPackageNameFromMetadataResponse,
isPackageInfoUrl,
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",
@ -43,14 +48,54 @@ function buildNpmInterceptor(registry) {
reqContext.targetUrl,
registry
);
const minimumAgeChecksEnabled = !skipMinimumPackageAge();
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(modifyNpmInfoResponseUnlessExcluded);
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 (
minimumAgeChecksEnabled &&
packageName &&
version &&
!isExcludedFromMinimumPackageAge(packageName)
) {
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})`
);
}
}
});
}
/**
* @param {Buffer} body
* @param {NodeJS.Dict<string | string[]> | undefined} headers
* @returns {Buffer}
*/
function modifyNpmInfoResponseUnlessExcluded(body, headers) {
const metadataPackageName = getPackageNameFromMetadataResponse(body, headers);
if (
metadataPackageName &&
isExcludedFromMinimumPackageAge(metadataPackageName)
) {
return body;
}
return modifyNpmInfoResponse(body, headers);
}

View file

@ -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,
getMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting,
getEcoSystem: () => "js",
},
});
mock.module("../../../../scanning/newPackagesListCache.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 direct download 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";

View file

@ -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: {
@ -26,14 +28,30 @@ mock.module("../../../../config/settings.js", {
setEcoSystem: () => {},
getMinimumPackageAgeHours: () => 24,
getNpmCustomRegistries: () => customRegistries,
getNpmMinimumPackageAgeExclusions: () => [],
skipMinimumPackageAge: () => false,
getMinimumPackageAgeExclusions: () => [],
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
},
});
mock.module("../../../../scanning/newPackagesListCache.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
{
@ -109,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",
@ -178,6 +200,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 direct download 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 () => {

View file

@ -5,12 +5,29 @@
*/
export function parseNpmPackageUrl(url, registry) {
let packageName, version;
if (!registry || !url.endsWith(".tgz")) {
let parsedUrl;
try {
parsedUrl = new URL(url);
} catch {
return { packageName, version };
}
const registryIndex = url.indexOf(registry);
const afterRegistry = url.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) {

View file

@ -0,0 +1,184 @@
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 { 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<string | string[]>} headers
* @returns {NodeJS.Dict<string | string[]>}
*/
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 =
/<a\b[^>]*href\s*=\s*(["'])([^"']+)\1[^>]*>[\s\S]*?<\/a>/gi;
/**
* @param {Buffer} body
* @param {NodeJS.Dict<string | string[]> | 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<string | string[]> | 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 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);
clearCachingHeaders(headers);
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<string | string[]> | 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"));
const modified = modifyPipJsonResponse(
json,
metadataUrl,
isNewlyReleasedPackage,
packageName
);
if (!modified) return body;
const modifiedBuffer = Buffer.from(JSON.stringify(json));
clearCachingHeaders(headers);
return modifiedBuffer;
}

View file

@ -0,0 +1,302 @@
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(`
<!doctype html>
<html>
<body>
<a href="https://files.pythonhosted.org/packages/source/r/requests/requests-1.0.0.tar.gz">requests-1.0.0.tar.gz</a>
<a href="https://files.pythonhosted.org/packages/source/r/requests/requests-2.0.0.tar.gz">requests-2.0.0.tar.gz</a>
</body>
</html>
`);
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" }],
},
})
);
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(
`<a href="https://files.pythonhosted.org/packages/source/r/requests/requests-1.0.0.tar.gz">requests-1.0.0.tar.gz</a>`
);
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(
`<a href="https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0.tar.gz">foo_bar-2.0.0.tar.gz</a>` +
`<a href="https://files.pythonhosted.org/packages/xx/yy/foo_bar-1.0.0.tar.gz">foo_bar-1.0.0.tar.gz</a>`
);
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("matches anchor href regex with single quotes and extra attributes", () => {
const headers = { "content-type": "application/vnd.pypi.simple.v1+html" };
const body = Buffer.from(`
<a
data-requires-python="&gt;=3.9"
class="pkg"
href='https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0.tar.gz'
>
foo_bar-2.0.0.tar.gz
</a>
<a href="https://files.pythonhosted.org/packages/xx/yy/foo_bar-1.0.0.tar.gz">foo_bar-1.0.0.tar.gz</a>
`);
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",
};
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");
});
});

View file

@ -0,0 +1,176 @@
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;
const loggedVersions = new Set();
json.files = json.files.filter((/** @type {any} */ file) => {
const version = getPackageVersionFromMetadataFile(file, metadataUrl);
if (version && isNewlyReleasedPackage(packageName, version)) {
modified = true;
if (!loggedVersions.has(version)) {
logSuppressedVersion(packageName, version);
loggedVersions.add(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;
const loggedVersions = new Set();
json.urls = json.urls.filter((/** @type {any} */ file) => {
const version = getPackageVersionFromMetadataFile(file, metadataUrl);
if (version && isNewlyReleasedPackage(packageName, version)) {
modified = true;
if (!loggedVersions.has(version)) {
logSuppressedVersion(packageName, version);
loggedVersions.add(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);
}

View file

@ -0,0 +1,162 @@
/**
* 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 }}
*/
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[0] === "simple" && pathSegments[1]) {
return {
packageName: decodeURIComponent(pathSegments[1]),
type: "simple",
};
}
if (
pathSegments[0] === "pypi" &&
pathSegments[pathSegments.length - 1] === "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:
* - 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}}
*/
export function parsePipPackageFromUrl(url, registry) {
if (!registry || typeof url !== "string") {
return { packageName: undefined, version: undefined };
}
let urlObj;
try {
urlObj = new URL(url);
} catch {
return { packageName: undefined, version: undefined };
}
const lastSegment = urlObj.pathname.split("/").filter(Boolean).pop();
if (!lastSegment) {
return { packageName: undefined, version: undefined };
}
const filename = decodeURIComponent(lastSegment);
const wheelExtRe = /\.whl(?:\.metadata)?$/;
if (wheelExtRe.test(filename)) {
return parseWheelFilename(filename, wheelExtRe);
}
const sdistExtWithMetadataRe = /\.(tar\.gz|zip|tar\.bz2|tar\.xz)(\.metadata)?$/i;
if (!sdistExtWithMetadataRe.test(filename)) {
return { packageName: undefined, version: undefined };
}
return parseSdistFilename(filename, sdistExtWithMetadataRe);
}
/**
* 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}}
*/
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;
// "latest" is a resolver-style token, not an actual published artifact version.
if (version === "latest" || !packageName || !version) {
return { packageName: undefined, version: undefined };
}
return { packageName, version };
}
/**
* 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}}
*/
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);
// "latest" is a resolver-style token, not an actual published artifact version.
if (version === "latest" || !packageName || !version) {
return { packageName: undefined, version: undefined };
}
return { packageName, version };
}

View file

@ -0,0 +1,100 @@
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("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/"),
{
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 }
);
});
});

View file

@ -2,20 +2,36 @@ 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 = [];
mock.module("../../../config/settings.js", {
mock.module("../../../../config/settings.js", {
namedExports: {
ECOSYSTEM_PY: "py",
getEcoSystem: () => "py",
getLoggingLevel: () => "silent",
getMinimumPackageAgeHours: () => 48,
getMinimumPackageAgeExclusions: () => [],
getPipCustomRegistries: () => customRegistries,
LOGGING_SILENT: "silent",
LOGGING_VERBOSE: "verbose",
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 };
scannedPackages.push({ packageName, version });
return malwareResponse;
},
},
@ -30,42 +46,45 @@ 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 () => {
scannedPackages = [];
customRegistries = ["my-custom-registry.example.com"];
const url =
"https://my-custom-registry.example.com/packages/xx/yy/foobar-1.2.3.tar.gz";
const interceptor = pipInterceptorForUrl(url);
assert.ok(interceptor, "Interceptor should be created");
assert.ok(interceptor);
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";
const interceptor = pipInterceptorForUrl(url);
assert.ok(interceptor, "Interceptor should be created");
assert.ok(interceptor);
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 () => {
@ -82,14 +101,12 @@ 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 () => {
scannedPackages = [];
customRegistries = ["my-custom-registry.example.com"];
malwareResponse = true;
@ -97,26 +114,19 @@ 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;
});
it("should still work with known registries when custom registries are set", async () => {
scannedPackages = [];
customRegistries = ["my-custom-registry.example.com"];
const url =
@ -124,17 +134,16 @@ 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);
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", () => {
@ -143,11 +152,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,42 +162,44 @@ 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 () => {
scannedPackages = [];
customRegistries = ["private-pypi.internal.com"];
const url =
"https://private-pypi.internal.com/packages/foo_bar-2.0.0-py3-none-any.whl.metadata";
const interceptor = pipInterceptorForUrl(url);
assert.ok(interceptor, "Interceptor should be created");
assert.ok(interceptor);
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";
const interceptor = pipInterceptorForUrl(url);
assert.ok(interceptor, "Interceptor should be created");
assert.ok(interceptor);
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"
)
);
});
});
});

View file

@ -0,0 +1,124 @@
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";
import {
modifyPipInfoRequestHeaders,
modifyPipInfoResponse,
parsePipMetadataUrl,
} from "./modifyPipInfo.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(createPipRequestHandler(registry));
}
/**
* @param {string} registry
* @returns {(reqContext: import("../interceptorBuilder.js").RequestInterceptionContext) => Promise<void>}
*/
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.modifyRequestHeaders(modifyPipInfoRequestHeaders);
reqContext.modifyBody((body, headers) =>
modifyPipInfoResponse(
body,
headers,
reqContext.targetUrl,
newPackagesDatabase.isNewlyReleasedPackage,
metadataPackageName
)
);
return;
}
const { packageName, version } = parsePipPackageFromUrl(
reqContext.targetUrl,
registry
);
if (!packageName) {
return;
}
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);
return;
}
if (
version &&
minimumAgeChecksEnabled &&
!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})`
);
}
}
};
}

View file

@ -0,0 +1,168 @@
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",
getLoggingLevel: () => "silent",
getMinimumPackageAgeHours: () => 48,
getMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting,
getPipCustomRegistries: () => [],
LOGGING_SILENT: "silent",
LOGGING_VERBOSE: "verbose",
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 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(`
<a href="https://files.pythonhosted.org/packages/xx/yy/foo_bar-1.0.0.tar.gz">foo_bar-1.0.0.tar.gz</a>
<a href="https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0.tar.gz">foo_bar-2.0.0.tar.gz</a>
`),
{
"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";
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 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("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";
newlyReleasedPackageResponse = true;
minimumPackageAgeExclusionsSetting = ["foo-bar"];
const interceptor = pipInterceptorForUrl(url);
const result = await interceptor.handleRequest(url);
assert.equal(result.blockResponse, undefined);
minimumPackageAgeExclusionsSetting = [];
newlyReleasedPackageResponse = false;
});
});

View file

@ -0,0 +1,163 @@
import { describe, it, mock } from "node:test";
import assert from "node:assert";
describe("pipInterceptor", async () => {
let scannedPackages;
let malwareResponse = false;
mock.module("../../../../scanning/audit/index.js", {
namedExports: {
isMalwarePackage: async (packageName, version) => {
scannedPackages.push({ packageName, version });
return malwareResponse;
},
},
});
mock.module("../../../../scanning/newPackagesListCache.js", {
namedExports: {
openNewPackagesDatabase: async () => ({
isNewlyReleasedPackage: () => false,
}),
},
});
mock.module("../../../../config/settings.js", {
namedExports: {
ECOSYSTEM_PY: "py",
getEcoSystem: () => "py",
getLoggingLevel: () => "silent",
getMinimumPackageAgeHours: () => 48,
getMinimumPackageAgeExclusions: () => [],
getPipCustomRegistries: () => [],
LOGGING_SILENT: "silent",
LOGGING_VERBOSE: "verbose",
skipMinimumPackageAge: () => false,
},
});
const { pipInterceptorForUrl } = await import("./pipInterceptor.js");
const parserCases = [
{
url: "https://files.pythonhosted.org/packages/xx/yy/foobar-1.2.3.tar.gz",
expected: { packageName: "foobar", version: "1.2.3" },
},
{
url: "https://pypi.org/packages/source/f/foobar/foobar-1.2.3.tar.gz",
expected: { packageName: "foobar", version: "1.2.3" },
},
{
url: "https://pypi.org/packages/source/f/foo-bar/foo-bar-0.9.0.tar.gz",
expected: { packageName: "foo-bar", version: "0.9.0" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0-py3-none-any.whl",
expected: { packageName: "foo-bar", version: "2.0.0" },
},
{
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" },
},
{
url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl",
expected: { packageName: "foo-bar", version: "2.0.0" },
},
{
url: "https://pypi.org/packages/source/f/foo.bar/foo.bar-1.0.0.tar.gz",
expected: { packageName: "foo.bar", version: "1.0.0" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0b1.tar.gz",
expected: { packageName: "foo-bar", version: "2.0.0b1" },
},
{
url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0.tar.gz.metadata",
expected: { packageName: "foo-bar", version: "2.0.0" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0rc1.tar.gz",
expected: { packageName: "foo-bar", version: "2.0.0rc1" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0.post1.tar.gz",
expected: { packageName: "foo-bar", version: "2.0.0.post1" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0.dev1.tar.gz",
expected: { packageName: "foo-bar", version: "2.0.0.dev1" },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0a1.tar.gz",
expected: { packageName: "foo-bar", version: "2.0.0a1" },
},
{
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" },
},
{
url: "https://pypi.org/simple/",
expected: { packageName: undefined, version: undefined },
},
{
url: "https://pypi.org/project/foobar/",
expected: { packageName: undefined, version: undefined },
},
{
url: "https://files.pythonhosted.org/packages/xx/yy/foobar-latest.tar.gz",
expected: { packageName: undefined, version: undefined },
},
{
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-latest.tar.gz",
expected: { packageName: undefined, version: undefined },
},
];
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);
if (expected.packageName === undefined) {
assert.deepEqual(scannedPackages, []);
return;
}
assert.ok(
scannedPackages.some(
({ packageName, version }) =>
packageName === expected.packageName &&
version === expected.version
)
);
});
});
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);
});
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;
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"
);
malwareResponse = false;
});
});

View file

@ -2,22 +2,43 @@ 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", {
mock.module("../../../../scanning/audit/index.js", {
namedExports: {
isMalwarePackage: async (packageName, version) => {
lastPackage = { packageName, version };
scannedPackages.push({ packageName, version });
return malwareResponse;
},
},
});
mock.module("../../../../scanning/newPackagesListCache.js", {
namedExports: {
openNewPackagesDatabase: async () => ({
isNewlyReleasedPackage: () => false,
}),
},
});
mock.module("../../../../config/settings.js", {
namedExports: {
ECOSYSTEM_PY: "py",
getEcoSystem: () => "py",
getLoggingLevel: () => "silent",
getMinimumPackageAgeHours: () => 48,
getMinimumPackageAgeExclusions: () => [],
getPipCustomRegistries: () => [],
LOGGING_SILENT: "silent",
LOGGING_VERBOSE: "verbose",
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 +56,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 +72,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 +95,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 },
@ -97,49 +115,49 @@ 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 npm registry"
);
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
)
);
});
});
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 () => {
scannedPackages = [];
const url =
"https://files.pythonhosted.org/packages/xx/yy/malicious_package-1.0.0.tar.gz";
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;
});
});

View file

@ -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<string | string[]> | 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).`
);
}

View file

@ -0,0 +1,131 @@
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 [];
}
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";
}
/**
* @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);
}

View file

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

View file

@ -0,0 +1,21 @@
const state = {
hasSuppressedVersions: false,
};
/**
* 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() {
state.hasSuppressedVersions = true;
}
/**
* @returns {boolean}
*/
export function getHasSuppressedVersions() {
return state.hasSuppressedVersions;
}

View file

@ -2,7 +2,8 @@ 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";
import { omitHeaders } from "./http-utils.js";
/**
* @typedef {import("./interceptors/interceptorBuilder.js").Interceptor} Interceptor
@ -215,11 +216,16 @@ function createProxyRequest(hostname, port, req, res, requestHandler) {
buffer = requestHandler.modifyBody(buffer, headers);
if (proxyRes.headers["content-encoding"] === "gzip") {
buffer = gzipSync(buffer);
}
res.writeHead(statusCode, headers);
// For rewritten responses, send the final body uncompressed.
// This avoids mismatches between upstream compression metadata and the
// rewritten payload on the wire.
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);
});
} else {

View file

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

View file

@ -7,6 +7,9 @@ import tls from "node:tls";
import { X509Certificate } from "node:crypto";
import { ui } from "../environment/userInteraction.js";
/** @type {string | null} */
let bundlePath = null;
/**
* Check if a PEM string contains only parsable cert blocks.
* @param {string} pem - PEM-encoded certificate string
@ -54,6 +57,11 @@ function isParsable(pem) {
* @returns {string} Path to the combined CA bundle PEM file
*/
export function getCombinedCaBundlePath(proxyCaCert) {
if (bundlePath)
{
return bundlePath;
}
// 1) Safe Chain CA (for MITM'd registries)
const parts = [];
if (proxyCaCert && isParsable(proxyCaCert)) parts.push(proxyCaCert.trim());
@ -92,9 +100,23 @@ export function getCombinedCaBundlePath(proxyCaCert) {
}
const combined = parts.filter(Boolean).join("\n");
const target = path.join(os.tmpdir(), `safe-chain-ca-bundle-${Date.now()}.pem`);
fs.writeFileSync(target, combined, { encoding: "utf8" });
return target;
bundlePath = path.join(os.tmpdir(), `safe-chain-ca-bundle-${Date.now()}.pem`);
fs.writeFileSync(bundlePath, combined, { encoding: "utf8" });
return bundlePath;
}
/**
* Remove the generated CA bundle file from disk.
*/
export function cleanupCertBundle() {
if (bundlePath) {
try {
fs.unlinkSync(bundlePath);
} catch (err) {
ui.writeVerbose(`Failed to cleanup the create bundle at ${bundlePath}`, err)
}
bundlePath = null;
}
}
/**

View file

@ -4,7 +4,7 @@ import { createBuiltInProxyServer } from "./builtInProxy/createBuiltInProxyServe
import { getCombinedCaBundlePath } from "./certBundle.js";
/**
* @typedef {Object} MalwareBlockedEvent
* @typedef {Object} PackageBlockedEvent
* @prop {string} packageName
* @prop {string} packageVersion
*
@ -13,7 +13,8 @@ import { getCombinedCaBundlePath } from "./certBundle.js";
* @prop {string[]} packageVersions
*
* @typedef {{
* malwareBlocked: [MalwareBlockedEvent],
* malwareBlocked: [PackageBlockedEvent],
* minimumAgeRequestBlocked: [PackageBlockedEvent]
* minPackageAgeVersionsSuppressed: [MinPackageAgeSuppressionEvent]
* }} ProxyServerEvents
*
@ -61,7 +62,7 @@ export function getProxySettings() {
};
}
const proxyUrl = `http://localhost:${server.getServerPort()}`;
const proxyUrl = `http://127.0.0.1:${server.getServerPort()}`;
const caCert = server.getCaCert();
const caCertBundlePath = getCombinedCaBundlePath(caCert);

View file

@ -0,0 +1,67 @@
import { before, after, describe, it } from "node:test";
import assert from "node:assert";
import net from "node:net";
import os from "node:os";
import {
createSafeChainProxy,
mergeSafeChainProxyEnvironmentVariables,
} from "./registryProxy.js";
describe("registryProxy loopback binding", () => {
let proxy, proxyPort;
before(async () => {
proxy = createSafeChainProxy();
await proxy.startServer();
const envVars = mergeSafeChainProxyEnvironmentVariables([]);
proxyPort = parseInt(new URL(envVars.HTTPS_PROXY).port, 10);
});
after(async () => {
await proxy.stopServer();
});
it("advertises a loopback HTTPS_PROXY URL", () => {
const envVars = mergeSafeChainProxyEnvironmentVariables([]);
const hostname = new URL(envVars.HTTPS_PROXY).hostname;
assert.ok(
hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost",
`expected loopback hostname, got ${hostname}`
);
});
it("refuses connections on non-loopback interfaces", async () => {
const externalAddrs = Object.values(os.networkInterfaces())
.flat()
.filter((iface) => iface && iface.family === "IPv4" && !iface.internal)
.map((iface) => iface.address);
if (externalAddrs.length === 0) {
// No non-loopback interface available (e.g. locked-down CI) - skip.
return;
}
for (const addr of externalAddrs) {
await new Promise((resolve, reject) => {
const sock = net.createConnection({ host: addr, port: proxyPort });
const timer = setTimeout(() => {
sock.destroy();
resolve(); // Filtered / dropped is also fine - we just don't want success.
}, 500);
sock.once("connect", () => {
clearTimeout(timer);
sock.destroy();
reject(
new Error(
`proxy accepted a connection on non-loopback ${addr}:${proxyPort}`
)
);
});
sock.once("error", () => {
clearTimeout(timer);
resolve();
});
});
}
});
});

View file

@ -15,8 +15,12 @@ import { getEcoSystem, ECOSYSTEM_PY } from "../config/settings.js";
* @property {function(string, string): boolean} isMalware
*/
/** @type {MalwareDatabase | null} */
let cachedMalwareDatabase = null;
// Caching the Promise (rather than the resolved database) prevents duplicate fetches. If we cached the resolved
// value, multiple callers could pass the null-check before the first fetch completes (because each `await` yields
// control back to the event loop, allowing other callers to run). Since the Promise assignment is synchronous, all
// concurrent callers see it immediately and share a single fetch.
/** @type {Promise<MalwareDatabase> | null} */
let cachedMalwareDatabasePromise = null;
/**
* Normalize package name for comparison.
@ -34,45 +38,44 @@ 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: (/** @type {string} */ name, /** @type {string} */ version) => {
const status = getPackageStatus(name, version);
return isMalwareStatus(status);
},
};
}).catch((error) => {
cachedMalwareDatabasePromise = null;
throw error;
});
}
// 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;
}
/**

View file

@ -0,0 +1,267 @@
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 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;
let testHomeDir = "";
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("../environment/userInteraction.js", {
namedExports: {
ui: {
writeWarning: (msg) => writeWarningCalls.push(msg),
writeVerbose: () => {},
},
},
});
mock.module("../config/settings.js", {
namedExports: {
getMinimumPackageAgeHours: () => minimumPackageAgeHours,
getEcoSystem: () => ecosystem,
getMalwareListBaseUrl: () => "https://malware-list.aikido.dev",
ECOSYSTEM_JS: "js",
ECOSYSTEM_PY: "py",
},
});
// Import the warnings module so we can reset its state between tests.
const { resetWarningState } = await import("./newPackagesDatabaseWarnings.js");
describe("newPackagesDatabase", async () => {
beforeEach(() => {
fetchedList = [];
fetchedVersion = "etag-1";
fetchVersionResult = "etag-1";
minimumPackageAgeHours = 24;
ecosystem = "js";
writeWarningCalls = [];
fetchListError = null;
fetchVersionError = null;
resetWarningState();
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() {
const module = await import(
`./newPackagesListCache.js?test_case=${importCounter++}`
);
return module.openNewPackagesDatabase();
}
async function loadNewPackagesDatabaseModule() {
return import(`./newPackagesListCache.js?test_case=${importCounter++}`);
}
function hoursAgo(hours) {
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 = [
{ package_name: "foo", version: "1.0.0", released_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 = [
{ package_name: "foo", version: "1.0.0", released_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 = [
{ package_name: "foo", version: "2.0.0", released_on: hoursAgo(1) },
];
const db = await openNewPackagesDatabase();
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false);
});
it("matches the current feed ecosystem when source metadata is present", async () => {
fetchedList = [
{
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),
},
];
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 = [
{ package_name: "foo", version: "1.0.0", released_on: hoursAgo(100) },
];
const db = await openNewPackagesDatabase();
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true);
});
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"), true);
});
});
describe("caching behaviour", () => {
it("uses local cache when etag matches", async () => {
writeCachedList([
{ 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
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 () => {
writeCachedList([
{ 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) },
];
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 () => {
writeCachedList([
{
package_name: "cached-pkg",
version: "1.0.0",
released_on: hoursAgo(1),
},
], "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 = [
{ package_name: "foo", version: "1.0.0", released_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"));
});
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 with metadata-based minimum age checks only"
)
);
});
});
});

View file

@ -0,0 +1,71 @@
import {
getMinimumPackageAgeHours,
getEcoSystem,
ECOSYSTEM_JS,
ECOSYSTEM_PY,
} from "../config/settings.js";
import { getEquivalentPackageNames } from "./packageNameVariants.js";
/**
* @typedef {Object} NewPackagesDatabase
* @property {function(string | undefined, string | undefined): 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) {
const ecosystem = getEcoSystem();
/**
* @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) &&
candidateNames.includes(pkg.package_name) &&
pkg.version === version
);
if (!entry) {
return false;
}
const releasedOn = new Date(entry.released_on * 1000);
return releasedOn > cutOff;
}
return { isNewlyReleasedPackage };
}

View file

@ -0,0 +1,159 @@
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,
getMalwareListBaseUrl: () => "https://malware-list.aikido.dev",
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 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) },
]);
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
});
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";
});
});
});

View file

@ -0,0 +1,17 @@
import { ui } from "../environment/userInteraction.js";
let hasWarnedAboutUnavailableNewPackagesDatabase = false;
/** @param {Error} error */
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;
}

View file

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

View file

@ -0,0 +1,123 @@
import fs from "fs";
import {
fetchNewPackagesList,
fetchNewPackagesListVersion,
} from "../api/aikido.js";
import {
getNewPackagesListPath,
getNewPackagesListVersionPath,
} from "../config/configFile.js";
import { ui } from "../environment/userInteraction.js";
import { buildNewPackagesDatabase } from "./newPackagesDatabaseBuilder.js";
import { warnOnceAboutUnavailableDatabase } from "./newPackagesDatabaseWarnings.js";
/**
* @typedef {import("./newPackagesDatabaseBuilder.js").NewPackagesDatabase} NewPackagesDatabase
*/
// Shared per-process cache to avoid rebuilding the same feed-backed database on each request.
// Caching the Promise (rather than the resolved database) prevents duplicate fetches. If we cached the resolved
// value, multiple callers could pass the null-check before the first fetch completes (because each `await` yields
// control back to the event loop, allowing other callers to run). Since the Promise assignment is synchronous, all
// concurrent callers see it immediately and share a single fetch.
/** @type {Promise<NewPackagesDatabase> | null} */
let cachedNewPackagesDatabasePromise = null;
/**
* @returns {Promise<NewPackagesDatabase>}
*/
export function openNewPackagesDatabase() {
if (!cachedNewPackagesDatabasePromise) {
cachedNewPackagesDatabasePromise = getNewPackagesList()
.then((newPackagesList) => buildNewPackagesDatabase(newPackagesList))
.catch((/** @type {any} */ error) => {
warnOnceAboutUnavailableDatabase(error);
cachedNewPackagesDatabasePromise = null;
return { isNewlyReleasedPackage: () => false };
});
}
return cachedNewPackagesDatabasePromise;
}
/**
* @returns {Promise<import("../api/aikido.js").NewPackageEntry[]>}
*/
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 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 for direct package download request blocking. Using cached version."
);
return cachedList;
}
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 };
}
}

View file

@ -0,0 +1,178 @@
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,
getMalwareListBaseUrl: () => "https://malware-list.aikido.dev",
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", () => {
// 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");
assert.strictEqual(writeWarningCalls.length, 1);
assert.ok(writeWarningCalls[0].includes("local cache"));
});
});
});

View file

@ -0,0 +1,29 @@
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
* @returns {string[]}
*/
export function getEquivalentPackageNames(packageName, ecosystem) {
if (ecosystem !== ECOSYSTEM_PY) {
return [packageName];
}
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])];
}

View file

@ -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",
@ -121,20 +127,6 @@ export function getPackageManagerList() {
return `${tools.join(", ")}, and ${lastTool} commands`;
}
/**
* @returns {string}
*/
export function getShimsDir() {
return path.join(os.homedir(), ".safe-chain", "shims");
}
/**
* @returns {string}
*/
export function getScriptsDir() {
return path.join(os.homedir(), ".safe-chain", "scripts");
}
/**
* @param {string} executableName
*

View file

@ -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,30 @@ describe("removeLinesMatchingPatternTests", () => {
assert.strictEqual(resultLines.length, 5, "Should have exactly 5 lines");
});
});
describe("getSafeChainBaseDir / getBinDir / getShimsDir / getScriptsDir", () => {
it("defaults base dir to ~/.safe-chain when no packaged install dir is available", async () => {
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("../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("../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("../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"));
});
});

View file

@ -4,13 +4,28 @@
# Function to remove shim from PATH (POSIX-compliant)
remove_shim_from_path() {
echo "$PATH" | sed "s|$HOME/.safe-chain/shims:||g"
_safe_chain_phys=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd -P)
if [ -z "$_safe_chain_phys" ]; then
echo "$PATH"
return
fi
_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
# 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
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}})
if [ -n "$original_cmd" ]; then

View file

@ -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=%USERPROFILE%\.safe-chain\shims"
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
@ -21,4 +22,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
)
)

View file

@ -1,24 +1,14 @@
import chalk from "chalk";
import { ui } from "../environment/userInteraction.js";
import { getPackageManagerList, knownAikidoTools, getShimsDir } 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.
@ -31,7 +21,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 });
@ -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}`);

View file

@ -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}}\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\nREM Template for {{PACKAGE_MANAGER}}\n{{AIKIDO_COMMAND}} %*\n",
"@echo off\nset \"SHIM_DIR=%~dp0\"\n{{AIKIDO_COMMAND}} %*\n",
"utf-8"
);
@ -50,7 +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),
},
});
@ -63,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;
});
@ -119,6 +111,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("_safe_chain_shims=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" 2>/dev/null && pwd -P)"),
"npm shim should derive the shims directory from its own location",
);
});
it("should create Windows .cmd shims on win32 platform", async () => {
@ -142,6 +138,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('set "SHIM_DIR=%~dp0"'),
"npm.cmd should derive the shims directory from its own location",
);
// Verify Unix shims were NOT created
const unixNpmShim = path.join(mockShimsDir, "npm");

View file

@ -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.
@ -91,9 +73,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 +82,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;
@ -118,8 +104,7 @@ function copyStartupFiles() {
fs.mkdirSync(targetDir, { recursive: true });
}
// Use absolute path for source
const sourcePath = path.join(dirname, "startup-scripts", file);
const sourcePath = getStartupScriptSourcePath(import.meta.url, file);
fs.copyFileSync(sourcePath, targetPath);
}
}

View file

@ -11,6 +11,8 @@ import { ui } from "../environment/userInteraction.js";
* @property {() => boolean} isInstalled
* @property {(tools: import("./helpers.js").AikidoTool[]) => boolean|Promise<boolean>} setup
* @property {(tools: import("./helpers.js").AikidoTool[]) => boolean} teardown
* @property {() => string[]} getManualSetupInstructions
* @property {() => string[]} getManualTeardownInstructions
*/
/**

View file

@ -1,4 +1,7 @@
set -gx PATH $PATH $HOME/.safe-chain/bin
set -l safe_chain_script (status filename)
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
wrapSafeChainCommand "npx" $argv
@ -51,6 +54,10 @@ function uv
wrapSafeChainCommand "uv" $argv
end
function uvx
wrapSafeChainCommand "uvx" $argv
end
function poetry
wrapSafeChainCommand "poetry" $argv
end

View file

@ -1,4 +1,16 @@
export PATH="$PATH:$HOME/.safe-chain/bin"
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"
unset _sc_base _sc_script_path _sc_scripts_dir
function npx() {
wrapSafeChainCommand "npx" "$@"
@ -47,6 +59,10 @@ function uv() {
wrapSafeChainCommand "uv" "$@"
}
function uvx() {
wrapSafeChainCommand "uvx" "$@"
}
function poetry() {
wrapSafeChainCommand "poetry" "$@"
}

View file

@ -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 = Split-Path -Parent $PSScriptRoot
$safeChainBin = Join-Path $safeChainBase 'bin'
$env:PATH = "$env:PATH$pathSeparator$safeChainBin"
function npx {
@ -52,6 +53,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
}

View file

@ -3,8 +3,10 @@ import {
doesExecutableExistOnSystem,
removeLinesMatchingPattern,
} from "../helpers.js";
import { getScriptsDir } from "../../config/safeChainDir.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 (~/.aikido/scripts/init-posix.sh)
// Remove sourcing line to disable safe-chain shell integration
removeLinesMatchingPattern(
startupFile,
/^source\s+~\/\.safe-chain\/scripts\/init-posix\.sh/,
/^source\s+.*init-posix\.sh.*#\s*Safe-chain/,
eol
);
@ -44,10 +46,11 @@ function teardown(tools) {
function setup() {
const startupFile = getStartupFile();
const scriptsDir = getShellScriptsDir();
addLineToFile(
startupFile,
`source ~/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script`,
`source ${path.posix.join(scriptsDir, "init-posix.sh")} # Safe-chain bash initialization script`,
eol
);
@ -94,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 convertCygwinPathToUnix(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 });
@ -123,6 +171,44 @@ function cygpathw(path) {
}
}
/**
* @param {string} path
*
* @returns {string}
*/
function convertCygwinPathToUnix(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.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.posix.join(scriptsDir, "init-posix.sh")}`,
`Then restart your terminal or run: source ~/.bashrc`,
];
}
/**
* @type {import("../shellDetection.js").Shell}
*/
@ -131,4 +217,6 @@ export default {
isInstalled,
setup,
teardown,
getManualSetupInstructions,
getManualTeardownInstructions,
};

View file

@ -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 () => {
@ -35,6 +36,12 @@ describe("Bash shell integration", () => {
},
});
mock.module("../../config/safeChainDir.js", {
namedExports: {
getScriptsDir: () => mockScriptsDir,
},
});
// Mock child_process execSync
mock.module("child_process", {
namedExports: {
@ -61,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",
};
}
},
},
});
@ -87,6 +105,7 @@ describe("Bash shell integration", () => {
// Reset mocks
mock.reset();
mockScriptsDir = "/test-home/.safe-chain/scripts";
platform = "linux";
});
@ -109,7 +128,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 +148,24 @@ 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 /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"
)
);
});
@ -209,13 +245,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 +272,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 +283,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="));
});

View file

@ -3,7 +3,9 @@ import {
doesExecutableExistOnSystem,
removeLinesMatchingPattern,
} from "../helpers.js";
import { getScriptsDir } from "../../config/safeChainDir.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)
// Remove sourcing line to prevent safe-chain initialization in future shell sessions
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
);
@ -66,6 +68,22 @@ function getStartupFile() {
}
}
function getManualTeardownInstructions() {
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 [
`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`,
];
}
/**
* @type {import("../shellDetection.js").Shell}
*/
@ -74,4 +92,6 @@ export default {
isInstalled,
setup,
teardown,
getManualSetupInstructions,
getManualTeardownInstructions,
};

View file

@ -33,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: {
@ -72,7 +78,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 +87,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 +99,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 +113,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 +168,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 +182,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");
});
});

View file

@ -4,7 +4,9 @@ import {
removeLinesMatchingPattern,
validatePowerShellExecutionPolicy,
} from "../helpers.js";
import { getScriptsDir } from "../../config/safeChainDir.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 sourcing line to prevent shell from loading safe-chain after uninstallation
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;
@ -71,6 +73,22 @@ function getStartupFile() {
}
}
function getManualTeardownInstructions() {
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 [
`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`,
];
}
/**
* @type {import("../shellDetection.js").Shell}
*/
@ -79,4 +97,6 @@ export default {
isInstalled,
setup,
teardown,
getManualSetupInstructions,
getManualTeardownInstructions,
};

View file

@ -43,6 +43,12 @@ describe("PowerShell Core shell integration", () => {
},
});
mock.module("../../config/safeChainDir.js", {
namedExports: {
getScriptsDir: () => "/test-home/.safe-chain/scripts",
},
});
// Mock child_process execSync
mock.module("child_process", {
namedExports: {
@ -83,7 +89,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 +99,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 +111,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 +186,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 +204,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");

View file

@ -4,7 +4,9 @@ import {
removeLinesMatchingPattern,
validatePowerShellExecutionPolicy,
} from "../helpers.js";
import { getScriptsDir } from "../../config/safeChainDir.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 sourcing line to clean up safe-chain integration from the shell profile
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;
@ -71,6 +73,22 @@ function getStartupFile() {
}
}
function getManualTeardownInstructions() {
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 [
`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`,
];
}
/**
* @type {import("../shellDetection.js").Shell}
*/
@ -79,4 +97,6 @@ export default {
isInstalled,
setup,
teardown,
getManualSetupInstructions,
getManualTeardownInstructions,
};

View file

@ -43,6 +43,12 @@ describe("Windows PowerShell shell integration", () => {
},
});
mock.module("../../config/safeChainDir.js", {
namedExports: {
getScriptsDir: () => "/test-home/.safe-chain/scripts",
},
});
// Mock child_process execSync
mock.module("child_process", {
namedExports: {
@ -83,7 +89,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 +99,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 +111,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 +186,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 +204,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");

View file

@ -3,7 +3,9 @@ import {
doesExecutableExistOnSystem,
removeLinesMatchingPattern,
} from "../helpers.js";
import { getScriptsDir } from "../../config/safeChainDir.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 (~/.aikido/scripts/init-posix.sh)
// Remove sourcing line to complete shell integration cleanup
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
);
@ -66,9 +68,27 @@ function getStartupFile() {
}
}
function getManualTeardownInstructions() {
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 [
`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 {
name: shellName,
isInstalled,
setup,
teardown,
getManualSetupInstructions,
getManualTeardownInstructions,
};

View file

@ -33,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: {
@ -73,7 +79,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 +89,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 +120,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 +131,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 +186,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 +213,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 +224,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="));
});

View file

@ -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";
/**
@ -47,8 +48,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();
}
}
@ -103,4 +110,5 @@ export async function teardownDirectories() {
);
}
}
}

View file

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