mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Merge branch 'rama-integration-beta' into rama-min-package-age-reporting
This commit is contained in:
commit
5f82e45b2b
131 changed files with 6372 additions and 2461 deletions
16
packages/safe-chain/bin/aikido-uvx.js
Executable file
16
packages/safe-chain/bin/aikido-uvx.js
Executable 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);
|
||||
})();
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
71
packages/safe-chain/src/config/safeChainDir.js
Normal file
71
packages/safe-chain/src/config/safeChainDir.js
Normal 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);
|
||||
}
|
||||
|
|
@ -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(/\/+$/, "");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
42
packages/safe-chain/src/installLocation.js
Normal file
42
packages/safe-chain/src/installLocation.js
Normal 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,
|
||||
);
|
||||
}
|
||||
51
packages/safe-chain/src/installLocation.spec.js
Normal file
51
packages/safe-chain/src/installLocation.spec.js
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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})`
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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?",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: () => [],
|
||||
};
|
||||
}
|
||||
|
|
@ -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(), []);
|
||||
});
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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=">=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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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})`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
|
|
@ -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).`
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
267
packages/safe-chain/src/scanning/newPackagesDatabase.spec.js
Normal file
267
packages/safe-chain/src/scanning/newPackagesDatabase.spec.js
Normal 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"
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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";
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
123
packages/safe-chain/src/scanning/newPackagesListCache.js
Normal file
123
packages/safe-chain/src/scanning/newPackagesListCache.js
Normal 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 };
|
||||
}
|
||||
}
|
||||
178
packages/safe-chain/src/scanning/newPackagesListCache.spec.js
Normal file
178
packages/safe-chain/src/scanning/newPackagesListCache.spec.js
Normal 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"));
|
||||
});
|
||||
});
|
||||
});
|
||||
29
packages/safe-chain/src/scanning/packageNameVariants.js
Normal file
29
packages/safe-chain/src/scanning/packageNameVariants.js
Normal 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])];
|
||||
}
|
||||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" "$@"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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="));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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="));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue