From 833fa285aa84c849cbc4b06badbf1996e651ba85 Mon Sep 17 00:00:00 2001 From: galargh Date: Wed, 10 Dec 2025 13:27:18 +0100 Subject: [PATCH 1/3] feat: allow python custom registries configuration --- README.md | 19 ++ .../src/config/environmentVariables.js | 8 + packages/safe-chain/src/config/settings.js | 27 +++ .../interceptors/pipInterceptor.js | 9 +- ...pipInterceptor.pipCustomRegistries.spec.js | 199 ++++++++++++++++++ 5 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.pipCustomRegistries.spec.js diff --git a/README.md b/README.md index def262f..702f8bf 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,25 @@ You can set the minimum package age through multiple sources (in order of priori } ``` +## Custom Registries + +By default, Safe Chain monitors downloads from the official package registries (npm registry, PyPI, etc.). If you use a private or custom package registry, you can configure Safe Chain to also monitor downloads from those registries. + +⚠️ This feature **currently only applies to Python package managers** (pip, pip3, uv, poetry) and does not apply to npm-based package managers. + +### Configuration Options + +You can set custom registries through the following source: + +1. **Environment Variable**: + + ```shell + export SAFE_CHAIN_PIP_CUSTOM_REGISTRIES=my-custom-registry.example.com,private-pypi.internal.com + pip install mypackage + ``` + + Use a comma-separated list of registry hostnames to monitor multiple custom registries. + # Usage in CI/CD You can protect your CI/CD pipelines from malicious packages by integrating Aikido Safe Chain into your build process. This ensures that any packages installed during your automated builds are checked for malware before installation. diff --git a/packages/safe-chain/src/config/environmentVariables.js b/packages/safe-chain/src/config/environmentVariables.js index 5c6056a..fe54732 100644 --- a/packages/safe-chain/src/config/environmentVariables.js +++ b/packages/safe-chain/src/config/environmentVariables.js @@ -5,3 +5,11 @@ export function getMinimumPackageAgeHours() { return process.env.SAFE_CHAIN_MINIMUM_PACKAGE_AGE_HOURS; } + +/** + * Gets the custom pip registries from environment variable + * @returns {string | undefined} + */ +export function getPipCustomRegistries() { + return process.env.SAFE_CHAIN_PIP_CUSTOM_REGISTRIES; +} diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index 7c20358..0480709 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -98,3 +98,30 @@ export function skipMinimumPackageAge() { return defaultSkipMinimumPackageAge; } + +/** @type {string[]} */ +const defaultPipCustomRegistries = []; +/** @returns {string[]} */ +export function getPipCustomRegistries() { + // Priority 1: Environment variable + const envValue = validatePipCustomRegistries( + environmentVariables.getPipCustomRegistries() + ); + if (envValue !== undefined) { + return envValue; + } + + return defaultPipCustomRegistries; +} + +/** + * @param {string | undefined} value + * @returns {string[] | undefined} + */ +function validatePipCustomRegistries(value) { + if (!value) { + return undefined; + } + + return value.split(","); +} diff --git a/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js index 9a122a6..e781e30 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.js @@ -1,3 +1,4 @@ +import { getPipCustomRegistries } from "../../config/settings.js"; import { isMalwarePackage } from "../../scanning/audit/index.js"; import { interceptRequests } from "./interceptorBuilder.js"; @@ -13,7 +14,9 @@ const knownPipRegistries = [ * @returns {import("./interceptorBuilder.js").Interceptor | undefined} */ export function pipInterceptorForUrl(url) { - const registry = knownPipRegistries.find((reg) => url.includes(reg)); + const customRegistries = getPipCustomRegistries(); + const registries = [...knownPipRegistries, ...customRegistries]; + const registry = registries.find((reg) => url.includes(reg)); if (registry) { return buildPipInterceptor(registry); @@ -37,8 +40,8 @@ function buildPipInterceptor(registry) { // Per python, packages that differ only by hyphen vs underscore are considered the same. const hyphenName = packageName?.includes("_") ? packageName.replace(/_/g, "-") : packageName; - const isMalicious = - await isMalwarePackage(packageName, version) + const isMalicious = + await isMalwarePackage(packageName, version) || await isMalwarePackage(hyphenName, version); if (isMalicious) { diff --git a/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.pipCustomRegistries.spec.js b/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.pipCustomRegistries.spec.js new file mode 100644 index 0000000..fc9c91e --- /dev/null +++ b/packages/safe-chain/src/registryProxy/interceptors/pipInterceptor.pipCustomRegistries.spec.js @@ -0,0 +1,199 @@ +import { describe, it, mock } from "node:test"; +import assert from "node:assert"; + +describe("pipInterceptor custom registries", async () => { + let lastPackage; + let malwareResponse = false; + let customRegistries = []; + + mock.module("../../config/settings.js", { + namedExports: { + getPipCustomRegistries: () => customRegistries, + }, + }); + + mock.module("../../scanning/audit/index.js", { + namedExports: { + isMalwarePackage: async (packageName, version) => { + lastPackage = { packageName, version }; + return malwareResponse; + }, + }, + }); + + const { pipInterceptorForUrl } = await import("./pipInterceptor.js"); + + it("should create interceptor for custom registry", () => { + customRegistries = ["my-custom-registry.example.com"]; + const url = + "https://my-custom-registry.example.com/packages/xx/yy/foobar-1.2.3.tar.gz"; + + const interceptor = pipInterceptorForUrl(url); + + assert.ok( + interceptor, + "Interceptor should be created for custom registry" + ); + }); + + it("should parse package from custom registry URL", async () => { + customRegistries = ["my-custom-registry.example.com"]; + const url = + "https://my-custom-registry.example.com/packages/xx/yy/foobar-1.2.3.tar.gz"; + + const interceptor = pipInterceptorForUrl(url); + assert.ok(interceptor, "Interceptor should be created"); + + await interceptor.handleRequest(url); + + assert.deepEqual(lastPackage, { + packageName: "foobar", + version: "1.2.3", + }); + }); + + it("should parse wheel package from custom registry URL", async () => { + customRegistries = ["private-pypi.internal.com"]; + const url = + "https://private-pypi.internal.com/packages/foo_bar-2.0.0-py3-none-any.whl"; + + const interceptor = pipInterceptorForUrl(url); + assert.ok(interceptor, "Interceptor should be created"); + + await interceptor.handleRequest(url); + + assert.deepEqual(lastPackage, { + packageName: "foo-bar", + version: "2.0.0", + }); + }); + + it("should handle multiple custom registries", async () => { + customRegistries = [ + "registry-one.example.com", + "registry-two.example.com", + ]; + + const url1 = + "https://registry-one.example.com/packages/package1-1.0.0.tar.gz"; + const url2 = + "https://registry-two.example.com/packages/package2-2.0.0.tar.gz"; + + const interceptor1 = pipInterceptorForUrl(url1); + const interceptor2 = pipInterceptorForUrl(url2); + + assert.ok(interceptor1, "Interceptor should be created for first registry"); + assert.ok( + interceptor2, + "Interceptor should be created for second registry" + ); + }); + + it("should block malicious package from custom registry", async () => { + customRegistries = ["my-custom-registry.example.com"]; + malwareResponse = true; + + const url = + "https://my-custom-registry.example.com/packages/malicious_package-1.0.0.tar.gz"; + + const interceptor = pipInterceptorForUrl(url); + assert.ok(interceptor, "Interceptor should be created"); + + const result = await interceptor.handleRequest(url); + + assert.ok(result.blockResponse, "Should contain a blockResponse"); + assert.equal( + result.blockResponse.statusCode, + 403, + "Block response should have status code 403" + ); + assert.equal( + result.blockResponse.message, + "Forbidden - blocked by safe-chain", + "Block response should have correct status message" + ); + + malwareResponse = false; + }); + + it("should still work with known registries when custom registries are set", async () => { + customRegistries = ["my-custom-registry.example.com"]; + + const url = + "https://files.pythonhosted.org/packages/xx/yy/foobar-1.2.3.tar.gz"; + + const interceptor = pipInterceptorForUrl(url); + + assert.ok( + interceptor, + "Interceptor should be created for known registry even with custom registries set" + ); + + await interceptor.handleRequest(url); + + assert.deepEqual(lastPackage, { + packageName: "foobar", + version: "1.2.3", + }); + }); + + it("should not create interceptor for unknown registry when custom registries are set", () => { + customRegistries = ["my-custom-registry.example.com"]; + const url = "https://unknown-registry.example.com/packages/foobar-1.0.0.tar.gz"; + + const interceptor = pipInterceptorForUrl(url); + + assert.equal( + interceptor, + undefined, + "Interceptor should be undefined for unknown registry" + ); + }); + + it("should handle empty custom registries array", () => { + customRegistries = []; + const url = + "https://my-custom-registry.example.com/packages/foobar-1.0.0.tar.gz"; + + const interceptor = pipInterceptorForUrl(url); + + assert.equal( + interceptor, + undefined, + "Interceptor should be undefined when no custom registries are configured" + ); + }); + + it("should parse .whl.metadata from custom registry", async () => { + customRegistries = ["private-pypi.internal.com"]; + const url = + "https://private-pypi.internal.com/packages/foo_bar-2.0.0-py3-none-any.whl.metadata"; + + const interceptor = pipInterceptorForUrl(url); + assert.ok(interceptor, "Interceptor should be created"); + + await interceptor.handleRequest(url); + + assert.deepEqual(lastPackage, { + packageName: "foo-bar", + version: "2.0.0", + }); + }); + + it("should parse .tar.gz.metadata from custom registry", async () => { + customRegistries = ["private-pypi.internal.com"]; + const url = + "https://private-pypi.internal.com/packages/foo_bar-2.0.0.tar.gz.metadata"; + + const interceptor = pipInterceptorForUrl(url); + assert.ok(interceptor, "Interceptor should be created"); + + await interceptor.handleRequest(url); + + assert.deepEqual(lastPackage, { + packageName: "foo-bar", + version: "2.0.0", + }); + }); +}); + From c53a7347e22105f0e3304540393447166648e5c8 Mon Sep 17 00:00:00 2001 From: galargh Date: Mon, 22 Dec 2025 13:49:45 +0100 Subject: [PATCH 2/3] feat: allow python custom registries configuration through config file --- README.md | 12 +- packages/safe-chain/src/config/configFile.js | 26 ++ .../safe-chain/src/config/configFile.spec.js | 144 +++--- packages/safe-chain/src/config/settings.js | 5 +- .../safe-chain/src/config/settings.spec.js | 421 +++++++++--------- 5 files changed, 325 insertions(+), 283 deletions(-) diff --git a/README.md b/README.md index 29c6510..a55c63b 100644 --- a/README.md +++ b/README.md @@ -188,9 +188,13 @@ You can set the minimum package age through multiple sources (in order of priori } ``` -## Custom NPM Registries +## Custom Registries -Configure Safe Chain to scan packages from custom or private npm registries. +Configure Safe Chain to scan packages from custom or private registries. + +Supported ecosystems: +- Node.js +- Python ### Configuration Options @@ -200,6 +204,7 @@ You can set custom registries through environment variable or config file. Both ```shell export SAFE_CHAIN_NPM_CUSTOM_REGISTRIES="npm.company.com,registry.internal.net" + export SAFE_CHAIN_PIP_CUSTOM_REGISTRIES="pip.company.com,registry.internal.net" ``` 2. **Config File** (`~/.aikido/config.json`): @@ -208,6 +213,9 @@ You can set custom registries through environment variable or config file. Both { "npm": { "customRegistries": ["npm.company.com", "registry.internal.net"] + }, + "pip": { + "customRegistries": ["pip.company.com", "registry.internal.net"] } } ``` diff --git a/packages/safe-chain/src/config/configFile.js b/packages/safe-chain/src/config/configFile.js index 1b7525b..a98304e 100644 --- a/packages/safe-chain/src/config/configFile.js +++ b/packages/safe-chain/src/config/configFile.js @@ -11,6 +11,7 @@ import { getEcoSystem } from "./settings.js"; * @property {unknown | Number} scanTimeout * @property {unknown | Number} minimumPackageAgeHours * @property {unknown | SafeChainRegistryConfiguration} npm + * @property {unknown | SafeChainRegistryConfiguration} pip * * @typedef {Object} SafeChainRegistryConfiguration * We cannot trust the input and should add the necessary validations. @@ -104,6 +105,28 @@ export function getNpmCustomRegistries() { return customRegistries.filter((item) => typeof item === "string"); } +/** + * Gets the custom npm registries from the config file (format parsing only, no validation) + * @returns {string[]} + */ +export function getPipCustomRegistries() { + const config = readConfigFile(); + + if (!config || !config.pip) { + return []; + } + + // TypeScript needs help understanding that config.pip exists and has customRegistries + const pipConfig = /** @type {SafeChainRegistryConfiguration} */ (config.pip); + const customRegistries = pipConfig.customRegistries; + + if (!Array.isArray(customRegistries)) { + return []; + } + + return customRegistries.filter((item) => typeof item === "string"); +} + /** * @param {import("../api/aikido.js").MalwarePackage[]} data * @param {string | number} version @@ -169,6 +192,9 @@ function readConfigFile() { npm: { customRegistries: undefined, }, + pip: { + customRegistries: undefined, + }, }; const configFilePath = getConfigFilePath(); diff --git a/packages/safe-chain/src/config/configFile.spec.js b/packages/safe-chain/src/config/configFile.spec.js index f5c6df8..601b0d0 100644 --- a/packages/safe-chain/src/config/configFile.spec.js +++ b/packages/safe-chain/src/config/configFile.spec.js @@ -232,91 +232,95 @@ describe("getMinimumPackageAgeHours", async () => { }); }); -describe("getNpmCustomRegistries", async () => { - const { getNpmCustomRegistries } = await import("./configFile.js"); +for (const packageManager of ["npm", "pip"]) { + const fnName = `get${packageManager.charAt(0).toUpperCase()}${packageManager.slice(1)}CustomRegistries`; - afterEach(() => { - configFileContent = undefined; - }); + describe(fnName, async () => { + const fn = (await import("./configFile.js"))[fnName]; - it("should return empty array when config file doesn't exist", () => { - configFileContent = undefined; - - const registries = getNpmCustomRegistries(); - - assert.deepStrictEqual(registries, []); - }); - - it("should return empty array when npm config is not set", () => { - configFileContent = JSON.stringify({ scanTimeout: 5000 }); - - const registries = getNpmCustomRegistries(); - - assert.deepStrictEqual(registries, []); - }); - - it("should return empty array when customRegistries is not an array", () => { - configFileContent = JSON.stringify({ - npm: { customRegistries: "not-an-array" }, + afterEach(() => { + configFileContent = undefined; }); - const registries = getNpmCustomRegistries(); + it("should return empty array when config file doesn't exist", () => { + configFileContent = undefined; - assert.deepStrictEqual(registries, []); - }); + const registries = fn(); - it("should return array of custom registries when set", () => { - configFileContent = JSON.stringify({ - npm: { - customRegistries: ["npm.company.com", "registry.internal.net"], - }, + assert.deepStrictEqual(registries, []); }); - const registries = getNpmCustomRegistries(); + it(`should return empty array when ${packageManager} config is not set`, () => { + configFileContent = JSON.stringify({ scanTimeout: 5000 }); - assert.deepStrictEqual(registries, [ - "npm.company.com", - "registry.internal.net", - ]); - }); + const registries = fn(); - it("should filter out non-string values", () => { - configFileContent = JSON.stringify({ - npm: { - customRegistries: [ - "npm.company.com", - 123, - null, - "registry.internal.net", - undefined, - {}, - ], - }, + assert.deepStrictEqual(registries, []); }); - const registries = getNpmCustomRegistries(); + it("should return empty array when customRegistries is not an array", () => { + configFileContent = JSON.stringify({ + [packageManager]: { customRegistries: "not-an-array" }, + }); - assert.deepStrictEqual(registries, [ - "npm.company.com", - "registry.internal.net", - ]); - }); + const registries = fn(); - it("should return empty array for empty customRegistries array", () => { - configFileContent = JSON.stringify({ - npm: { customRegistries: [] }, + assert.deepStrictEqual(registries, []); }); - const registries = getNpmCustomRegistries(); + it("should return array of custom registries when set", () => { + configFileContent = JSON.stringify({ + [packageManager]: { + customRegistries: [`${packageManager}.company.com`, "registry.internal.net"], + }, + }); - assert.deepStrictEqual(registries, []); + const registries = fn(); + + assert.deepStrictEqual(registries, [ + `${packageManager}.company.com`, + "registry.internal.net", + ]); + }); + + it("should filter out non-string values", () => { + configFileContent = JSON.stringify({ + [packageManager]: { + customRegistries: [ + `${packageManager}.company.com`, + 123, + null, + "registry.internal.net", + undefined, + {}, + ], + }, + }); + + const registries = fn(); + + assert.deepStrictEqual(registries, [ + `${packageManager}.company.com`, + "registry.internal.net", + ]); + }); + + it("should return empty array for empty customRegistries array", () => { + configFileContent = JSON.stringify({ + [packageManager]: { customRegistries: [] }, + }); + + const registries = fn(); + + assert.deepStrictEqual(registries, []); + }); + + it("should handle malformed JSON and return empty array", () => { + configFileContent = "{ invalid json"; + + const registries = fn(); + + assert.deepStrictEqual(registries, []); + }); }); - - it("should handle malformed JSON and return empty array", () => { - configFileContent = "{ invalid json"; - - const registries = getNpmCustomRegistries(); - - assert.deepStrictEqual(registries, []); - }); -}); +} diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index 3a756ea..573c3ab 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -152,11 +152,10 @@ export function getPipCustomRegistries() { const envRegistries = parseRegistriesFromEnv( environmentVariables.getPipCustomRegistries() ); - // const configRegistries = configFile.getPipCustomRegistries(); + const configRegistries = configFile.getPipCustomRegistries(); // Merge both sources and remove duplicates - // const allRegistries = [...envRegistries, ...configRegistries]; - const allRegistries = [...envRegistries]; + const allRegistries = [...envRegistries, ...configRegistries]; const uniqueRegistries = [...new Set(allRegistries)]; // Normalize each registry (remove protocol if any) diff --git a/packages/safe-chain/src/config/settings.spec.js b/packages/safe-chain/src/config/settings.spec.js index 05d698f..778628b 100644 --- a/packages/safe-chain/src/config/settings.spec.js +++ b/packages/safe-chain/src/config/settings.spec.js @@ -11,239 +11,244 @@ mock.module("fs", { }, }); -describe("getNpmCustomRegistries", async () => { - let originalEnv; - const { getNpmCustomRegistries } = await import("./settings.js"); +for (const packageManager of ["npm", "pip"]) { + const fnName = `get${packageManager.charAt(0).toUpperCase()}${packageManager.slice(1)}CustomRegistries`; + const envVarName = `SAFE_CHAIN_${packageManager.toUpperCase()}_CUSTOM_REGISTRIES`; - beforeEach(() => { - originalEnv = process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; - }); + describe(fnName, async () => { + let originalEnv; + const fn = (await import("./settings.js"))[fnName]; - afterEach(() => { - if (originalEnv !== undefined) { - process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = originalEnv; - } else { - delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; - } - configFileContent = undefined; - }); - - it("should return empty array when no registries configured", () => { - configFileContent = undefined; - - const registries = getNpmCustomRegistries(); - - assert.deepStrictEqual(registries, []); - }); - - it("should return registries without protocol", () => { - configFileContent = JSON.stringify({ - npm: { - customRegistries: ["npm.company.com", "registry.internal.net"], - }, + beforeEach(() => { + originalEnv = process.env[envVarName]; }); - const registries = getNpmCustomRegistries(); - - assert.deepStrictEqual(registries, [ - "npm.company.com", - "registry.internal.net", - ]); - }); - - it("should strip https:// protocol from registries", () => { - configFileContent = JSON.stringify({ - npm: { - customRegistries: [ - "https://npm.company.com", - "https://registry.internal.net", - ], - }, + afterEach(() => { + if (originalEnv !== undefined) { + process.env[envVarName] = originalEnv; + } else { + delete process.env[envVarName]; + } + configFileContent = undefined; }); - const registries = getNpmCustomRegistries(); + it("should return empty array when no registries configured", () => { + configFileContent = undefined; - assert.deepStrictEqual(registries, [ - "npm.company.com", - "registry.internal.net", - ]); - }); + const registries = fn(); - it("should strip http:// protocol from registries", () => { - configFileContent = JSON.stringify({ - npm: { - customRegistries: [ - "http://npm.company.com", - "http://registry.internal.net", - ], - }, + assert.deepStrictEqual(registries, []); }); - const registries = getNpmCustomRegistries(); + it("should return registries without protocol", () => { + configFileContent = JSON.stringify({ + [packageManager]: { + customRegistries: [`${packageManager}.company.com`, "registry.internal.net"], + }, + }); - assert.deepStrictEqual(registries, [ - "npm.company.com", - "registry.internal.net", - ]); - }); + const registries = fn(); - it("should handle mixed protocols and no protocol", () => { - configFileContent = JSON.stringify({ - npm: { - customRegistries: [ - "https://npm.company.com", - "registry.internal.net", - "http://private.registry.io", - ], - }, + assert.deepStrictEqual(registries, [ + `${packageManager}.company.com`, + "registry.internal.net", + ]); }); - const registries = getNpmCustomRegistries(); + it("should strip https:// protocol from registries", () => { + configFileContent = JSON.stringify({ + [packageManager]: { + customRegistries: [ + `https://${packageManager}.company.com`, + "https://registry.internal.net", + ], + }, + }); - assert.deepStrictEqual(registries, [ - "npm.company.com", - "registry.internal.net", - "private.registry.io", - ]); - }); + const registries = fn(); - it("should preserve registry path after stripping protocol", () => { - configFileContent = JSON.stringify({ - npm: { - customRegistries: [ - "https://npm.company.com/custom/path", - "registry.internal.net/npm", - ], - }, + assert.deepStrictEqual(registries, [ + `${packageManager}.company.com`, + "registry.internal.net", + ]); }); - const registries = getNpmCustomRegistries(); + it("should strip http:// protocol from registries", () => { + configFileContent = JSON.stringify({ + [packageManager]: { + customRegistries: [ + `http://${packageManager}.company.com`, + "http://registry.internal.net", + ], + }, + }); - assert.deepStrictEqual(registries, [ - "npm.company.com/custom/path", - "registry.internal.net/npm", - ]); - }); + const registries = fn(); - it("should parse comma-separated registries from environment variable", () => { - delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; - process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = - "env1.registry.com,env2.registry.net"; - configFileContent = undefined; - - const registries = getNpmCustomRegistries(); - - assert.deepStrictEqual(registries, [ - "env1.registry.com", - "env2.registry.net", - ]); - }); - - it("should trim whitespace from environment variable registries", () => { - delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; - process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = - " env1.registry.com , env2.registry.net "; - configFileContent = undefined; - - const registries = getNpmCustomRegistries(); - - assert.deepStrictEqual(registries, [ - "env1.registry.com", - "env2.registry.net", - ]); - }); - - it("should merge environment variable and config file registries", () => { - delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; - process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = "env1.registry.com"; - configFileContent = JSON.stringify({ - npm: { - customRegistries: ["config1.registry.net"], - }, + assert.deepStrictEqual(registries, [ + `${packageManager}.company.com`, + "registry.internal.net", + ]); }); - const registries = getNpmCustomRegistries(); + it("should handle mixed protocols and no protocol", () => { + configFileContent = JSON.stringify({ + [packageManager]: { + customRegistries: [ + `https://${packageManager}.company.com`, + "registry.internal.net", + "http://private.registry.io", + ], + }, + }); - assert.deepStrictEqual(registries, [ - "env1.registry.com", - "config1.registry.net", - ]); - }); + const registries = fn(); - it("should remove duplicate registries when merging env and config", () => { - delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; - process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = - "npm.company.com,env.registry.com"; - configFileContent = JSON.stringify({ - npm: { - customRegistries: ["npm.company.com", "config.registry.net"], - }, + assert.deepStrictEqual(registries, [ + `${packageManager}.company.com`, + "registry.internal.net", + "private.registry.io", + ]); }); - const registries = getNpmCustomRegistries(); + it("should preserve registry path after stripping protocol", () => { + configFileContent = JSON.stringify({ + [packageManager]: { + customRegistries: [ + `https://${packageManager}.company.com/custom/path`, + `registry.internal.net/${packageManager}`, + ], + }, + }); - assert.deepStrictEqual(registries, [ - "npm.company.com", - "env.registry.com", - "config.registry.net", - ]); + const registries = fn(); + + assert.deepStrictEqual(registries, [ + `${packageManager}.company.com/custom/path`, + `registry.internal.net/${packageManager}`, + ]); + }); + + it("should parse comma-separated registries from environment variable", () => { + delete process.env[envVarName]; + process.env[envVarName] = + "env1.registry.com,env2.registry.net"; + configFileContent = undefined; + + const registries = fn(); + + assert.deepStrictEqual(registries, [ + "env1.registry.com", + "env2.registry.net", + ]); + }); + + it("should trim whitespace from environment variable registries", () => { + delete process.env[envVarName]; + process.env[envVarName] = + " env1.registry.com , env2.registry.net "; + configFileContent = undefined; + + const registries = fn(); + + assert.deepStrictEqual(registries, [ + "env1.registry.com", + "env2.registry.net", + ]); + }); + + it("should merge environment variable and config file registries", () => { + delete process.env[envVarName]; + process.env[envVarName] = "env1.registry.com"; + configFileContent = JSON.stringify({ + [packageManager]: { + customRegistries: ["config1.registry.net"], + }, + }); + + const registries = fn(); + + assert.deepStrictEqual(registries, [ + "env1.registry.com", + "config1.registry.net", + ]); + }); + + it("should remove duplicate registries when merging env and config", () => { + delete process.env[envVarName]; + process.env[envVarName] = + `${packageManager}.company.com,env.registry.com`; + configFileContent = JSON.stringify({ + [packageManager]: { + customRegistries: [`${packageManager}.company.com`, "config.registry.net"], + }, + }); + + const registries = fn(); + + assert.deepStrictEqual(registries, [ + `${packageManager}.company.com`, + "env.registry.com", + "config.registry.net", + ]); + }); + + it("should normalize protocols from environment variable registries", () => { + delete process.env[envVarName]; + process.env[envVarName] = + "https://env1.registry.com,http://env2.registry.net"; + configFileContent = undefined; + + const registries = fn(); + + assert.deepStrictEqual(registries, [ + "env1.registry.com", + "env2.registry.net", + ]); + }); + + it("should handle empty strings in comma-separated list", () => { + delete process.env[envVarName]; + process.env[envVarName] = + "env1.registry.com,,env2.registry.net,"; + configFileContent = undefined; + + const registries = fn(); + + assert.deepStrictEqual(registries, [ + "env1.registry.com", + "env2.registry.net", + ]); + }); + + it("should handle single registry in environment variable", () => { + delete process.env[envVarName]; + process.env[envVarName] = "single.registry.com"; + configFileContent = undefined; + + const registries = fn(); + + assert.deepStrictEqual(registries, ["single.registry.com"]); + }); + + it("should return empty array for empty environment variable", () => { + delete process.env[envVarName]; + process.env[envVarName] = ""; + configFileContent = undefined; + + const registries = fn(); + + assert.deepStrictEqual(registries, []); + }); + + it("should return empty array for whitespace-only environment variable", () => { + delete process.env[envVarName]; + process.env[envVarName] = " , , "; + configFileContent = undefined; + + const registries = fn(); + + assert.deepStrictEqual(registries, []); + }); }); - - it("should normalize protocols from environment variable registries", () => { - delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; - process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = - "https://env1.registry.com,http://env2.registry.net"; - configFileContent = undefined; - - const registries = getNpmCustomRegistries(); - - assert.deepStrictEqual(registries, [ - "env1.registry.com", - "env2.registry.net", - ]); - }); - - it("should handle empty strings in comma-separated list", () => { - delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; - process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = - "env1.registry.com,,env2.registry.net,"; - configFileContent = undefined; - - const registries = getNpmCustomRegistries(); - - assert.deepStrictEqual(registries, [ - "env1.registry.com", - "env2.registry.net", - ]); - }); - - it("should handle single registry in environment variable", () => { - delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; - process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = "single.registry.com"; - configFileContent = undefined; - - const registries = getNpmCustomRegistries(); - - assert.deepStrictEqual(registries, ["single.registry.com"]); - }); - - it("should return empty array for empty environment variable", () => { - delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; - process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = ""; - configFileContent = undefined; - - const registries = getNpmCustomRegistries(); - - assert.deepStrictEqual(registries, []); - }); - - it("should return empty array for whitespace-only environment variable", () => { - delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; - process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = " , , "; - configFileContent = undefined; - - const registries = getNpmCustomRegistries(); - - assert.deepStrictEqual(registries, []); - }); -}); +} From b23ba9d9c400f7d0e1a2bb063b3ebc774b91c379 Mon Sep 17 00:00:00 2001 From: galargh Date: Fri, 2 Jan 2026 10:39:15 +0100 Subject: [PATCH 3/3] chore: update test parametrization --- .../safe-chain/src/config/configFile.spec.js | 34 +++++++----- .../safe-chain/src/config/settings.spec.js | 52 ++++++++++++------- 2 files changed, 54 insertions(+), 32 deletions(-) diff --git a/packages/safe-chain/src/config/configFile.spec.js b/packages/safe-chain/src/config/configFile.spec.js index 601b0d0..eff4048 100644 --- a/packages/safe-chain/src/config/configFile.spec.js +++ b/packages/safe-chain/src/config/configFile.spec.js @@ -232,12 +232,22 @@ describe("getMinimumPackageAgeHours", async () => { }); }); -for (const packageManager of ["npm", "pip"]) { - const fnName = `get${packageManager.charAt(0).toUpperCase()}${packageManager.slice(1)}CustomRegistries`; - - describe(fnName, async () => { - const fn = (await import("./configFile.js"))[fnName]; +const { getNpmCustomRegistries, getPipCustomRegistries } = await import( + "./configFile.js" +); +for (const { packageManager, getCustomRegistries } of [ + { + packageManager: "npm", + getCustomRegistries: getNpmCustomRegistries, + }, + { + packageManager: "pip", + getCustomRegistries: getPipCustomRegistries, + }, +]) +{ + describe(getCustomRegistries.name, async () => { afterEach(() => { configFileContent = undefined; }); @@ -245,7 +255,7 @@ for (const packageManager of ["npm", "pip"]) { it("should return empty array when config file doesn't exist", () => { configFileContent = undefined; - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, []); }); @@ -253,7 +263,7 @@ for (const packageManager of ["npm", "pip"]) { it(`should return empty array when ${packageManager} config is not set`, () => { configFileContent = JSON.stringify({ scanTimeout: 5000 }); - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, []); }); @@ -263,7 +273,7 @@ for (const packageManager of ["npm", "pip"]) { [packageManager]: { customRegistries: "not-an-array" }, }); - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, []); }); @@ -275,7 +285,7 @@ for (const packageManager of ["npm", "pip"]) { }, }); - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, [ `${packageManager}.company.com`, @@ -297,7 +307,7 @@ for (const packageManager of ["npm", "pip"]) { }, }); - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, [ `${packageManager}.company.com`, @@ -310,7 +320,7 @@ for (const packageManager of ["npm", "pip"]) { [packageManager]: { customRegistries: [] }, }); - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, []); }); @@ -318,7 +328,7 @@ for (const packageManager of ["npm", "pip"]) { it("should handle malformed JSON and return empty array", () => { configFileContent = "{ invalid json"; - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, []); }); diff --git a/packages/safe-chain/src/config/settings.spec.js b/packages/safe-chain/src/config/settings.spec.js index 778628b..db513f3 100644 --- a/packages/safe-chain/src/config/settings.spec.js +++ b/packages/safe-chain/src/config/settings.spec.js @@ -11,13 +11,25 @@ mock.module("fs", { }, }); -for (const packageManager of ["npm", "pip"]) { - const fnName = `get${packageManager.charAt(0).toUpperCase()}${packageManager.slice(1)}CustomRegistries`; - const envVarName = `SAFE_CHAIN_${packageManager.toUpperCase()}_CUSTOM_REGISTRIES`; +const { getNpmCustomRegistries, getPipCustomRegistries } = await import( + "./settings.js" +); - describe(fnName, async () => { +for (const { packageManager, getCustomRegistries, envVarName } of [ + { + packageManager: "npm", + getCustomRegistries: getNpmCustomRegistries, + envVarName: "SAFE_CHAIN_NPM_CUSTOM_REGISTRIES", + }, + { + packageManager: "pip", + getCustomRegistries: getPipCustomRegistries, + envVarName: "SAFE_CHAIN_PIP_CUSTOM_REGISTRIES", + }, +]) +{ + describe(getCustomRegistries.name, async () => { let originalEnv; - const fn = (await import("./settings.js"))[fnName]; beforeEach(() => { originalEnv = process.env[envVarName]; @@ -35,7 +47,7 @@ for (const packageManager of ["npm", "pip"]) { it("should return empty array when no registries configured", () => { configFileContent = undefined; - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, []); }); @@ -47,7 +59,7 @@ for (const packageManager of ["npm", "pip"]) { }, }); - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, [ `${packageManager}.company.com`, @@ -65,7 +77,7 @@ for (const packageManager of ["npm", "pip"]) { }, }); - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, [ `${packageManager}.company.com`, @@ -83,7 +95,7 @@ for (const packageManager of ["npm", "pip"]) { }, }); - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, [ `${packageManager}.company.com`, @@ -102,7 +114,7 @@ for (const packageManager of ["npm", "pip"]) { }, }); - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, [ `${packageManager}.company.com`, @@ -121,7 +133,7 @@ for (const packageManager of ["npm", "pip"]) { }, }); - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, [ `${packageManager}.company.com/custom/path`, @@ -135,7 +147,7 @@ for (const packageManager of ["npm", "pip"]) { "env1.registry.com,env2.registry.net"; configFileContent = undefined; - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, [ "env1.registry.com", @@ -149,7 +161,7 @@ for (const packageManager of ["npm", "pip"]) { " env1.registry.com , env2.registry.net "; configFileContent = undefined; - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, [ "env1.registry.com", @@ -166,7 +178,7 @@ for (const packageManager of ["npm", "pip"]) { }, }); - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, [ "env1.registry.com", @@ -184,7 +196,7 @@ for (const packageManager of ["npm", "pip"]) { }, }); - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, [ `${packageManager}.company.com`, @@ -199,7 +211,7 @@ for (const packageManager of ["npm", "pip"]) { "https://env1.registry.com,http://env2.registry.net"; configFileContent = undefined; - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, [ "env1.registry.com", @@ -213,7 +225,7 @@ for (const packageManager of ["npm", "pip"]) { "env1.registry.com,,env2.registry.net,"; configFileContent = undefined; - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, [ "env1.registry.com", @@ -226,7 +238,7 @@ for (const packageManager of ["npm", "pip"]) { process.env[envVarName] = "single.registry.com"; configFileContent = undefined; - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, ["single.registry.com"]); }); @@ -236,7 +248,7 @@ for (const packageManager of ["npm", "pip"]) { process.env[envVarName] = ""; configFileContent = undefined; - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, []); }); @@ -246,7 +258,7 @@ for (const packageManager of ["npm", "pip"]) { process.env[envVarName] = " , , "; configFileContent = undefined; - const registries = fn(); + const registries = getCustomRegistries(); assert.deepStrictEqual(registries, []); });