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