diff --git a/README.md b/README.md index 73735f4..7e764f3 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,30 @@ You can set the minimum package age through multiple sources (in order of priori } ``` +## Custom NPM Registries + +Configure Safe Chain to scan packages from custom or private npm registries. + +### Configuration Options + +You can set custom registries through environment variable or config file. Both sources are merged together. + +1. **Environment Variable** (comma-separated): + + ```shell + export SAFE_CHAIN_NPM_CUSTOM_REGISTRIES="npm.company.com,registry.internal.net" + ``` + +2. **Config File** (`~/.aikido/config.json`): + + ```json + { + "npm": { + "customRegistries": ["npm.company.com", "registry.internal.net"] + } + } + ``` + # 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/configFile.js b/packages/safe-chain/src/config/configFile.js index 23387f5..e13c1ff 100644 --- a/packages/safe-chain/src/config/configFile.js +++ b/packages/safe-chain/src/config/configFile.js @@ -7,10 +7,14 @@ import { getEcoSystem } from "./settings.js"; /** * @typedef {Object} SafeChainConfig * - * This should be a number, but can be anything because it is user-input. + * We cannot trust the input and should add the necessary validations + * @property {unknown | Number} scanTimeout + * @property {unknown | Number} minimumPackageAgeHours + * @property {unknown | SafeChainRegistryConfiguration} npm + * + * @typedef {Object} SafeChainRegistryConfiguration * We cannot trust the input and should add the necessary validations. - * @property {unknown} scanTimeout - * @property {unknown} minimumPackageAgeHours + * @property {unknown | string[]} customRegistries */ /** @@ -78,6 +82,30 @@ export function getMinimumPackageAgeHours() { return undefined; } +/** + * Gets the custom npm registries from the config file (format parsing only, no validation) + * @returns {string[]} + */ +export function getNpmCustomRegistries() { + const config = readConfigFile(); + + if (!config || !config.npm) { + return []; + } + + // TypeScript needs help understanding that config.npm exists and has customRegistries + const npmConfig = /** @type {SafeChainRegistryConfiguration} */ (config.npm); + const customRegistries = npmConfig.customRegistries; + + // Handle format: ensure it's an array of strings + if (!Array.isArray(customRegistries)) { + return []; + } + + // Filter to only string values (format checking, not validation) + return customRegistries.filter((item) => typeof item === "string"); +} + /** * @param {import("../api/aikido.js").MalwarePackage[]} data * @param {string | number} version @@ -142,6 +170,9 @@ function readConfigFile() { return { scanTimeout: undefined, minimumPackageAgeHours: undefined, + npm: { + customRegistries: undefined, + }, }; } @@ -152,6 +183,9 @@ function readConfigFile() { return { scanTimeout: undefined, minimumPackageAgeHours: undefined, + npm: { + customRegistries: undefined, + }, }; } } diff --git a/packages/safe-chain/src/config/configFile.spec.js b/packages/safe-chain/src/config/configFile.spec.js index 7da7e8d..f5c6df8 100644 --- a/packages/safe-chain/src/config/configFile.spec.js +++ b/packages/safe-chain/src/config/configFile.spec.js @@ -231,3 +231,92 @@ describe("getMinimumPackageAgeHours", async () => { assert.strictEqual(hours, -48); }); }); + +describe("getNpmCustomRegistries", async () => { + const { getNpmCustomRegistries } = await import("./configFile.js"); + + afterEach(() => { + configFileContent = undefined; + }); + + 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" }, + }); + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, []); + }); + + it("should return array of custom registries when set", () => { + configFileContent = JSON.stringify({ + npm: { + customRegistries: ["npm.company.com", "registry.internal.net"], + }, + }); + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, [ + "npm.company.com", + "registry.internal.net", + ]); + }); + + it("should filter out non-string values", () => { + configFileContent = JSON.stringify({ + npm: { + customRegistries: [ + "npm.company.com", + 123, + null, + "registry.internal.net", + undefined, + {}, + ], + }, + }); + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, [ + "npm.company.com", + "registry.internal.net", + ]); + }); + + it("should return empty array for empty customRegistries array", () => { + configFileContent = JSON.stringify({ + npm: { customRegistries: [] }, + }); + + const registries = getNpmCustomRegistries(); + + 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/environmentVariables.js b/packages/safe-chain/src/config/environmentVariables.js index 5c6056a..b11234a 100644 --- a/packages/safe-chain/src/config/environmentVariables.js +++ b/packages/safe-chain/src/config/environmentVariables.js @@ -5,3 +5,13 @@ export function getMinimumPackageAgeHours() { return process.env.SAFE_CHAIN_MINIMUM_PACKAGE_AGE_HOURS; } + +/** + * Gets the custom npm registries from environment variable + * Expected format: comma-separated list of registry domains + * Example: "npm.company.com,registry.internal.net" + * @returns {string | undefined} + */ +export function getNpmCustomRegistries() { + return process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; +} diff --git a/packages/safe-chain/src/config/settings.js b/packages/safe-chain/src/config/settings.js index e1cec34..1f4a058 100644 --- a/packages/safe-chain/src/config/settings.js +++ b/packages/safe-chain/src/config/settings.js @@ -98,3 +98,48 @@ export function skipMinimumPackageAge() { return defaultSkipMinimumPackageAge; } + +/** + * Normalizes a registry URL by removing protocol if present + * @param {string} registry + * @returns {string} + */ +function normalizeRegistry(registry) { + // Remove protocol (http://, https://) if present + return registry.replace(/^https?:\/\//, ""); +} + +/** + * Parses comma-separated registries from environment variable + * @param {string | undefined} envValue + * @returns {string[]} + */ +function parseRegistriesFromEnv(envValue) { + if (!envValue || typeof envValue !== "string") { + return []; + } + + // Split by comma and trim whitespace + return envValue + .split(",") + .map((registry) => registry.trim()) + .filter((registry) => registry.length > 0); +} + +/** + * Gets the custom npm registries from both environment variable and config file (merged) + * @returns {string[]} + */ +export function getNpmCustomRegistries() { + const envRegistries = parseRegistriesFromEnv( + environmentVariables.getNpmCustomRegistries() + ); + const configRegistries = configFile.getNpmCustomRegistries(); + + // Merge both sources and remove duplicates + const allRegistries = [...envRegistries, ...configRegistries]; + const uniqueRegistries = [...new Set(allRegistries)]; + + // Normalize each registry (remove protocol if any) + return uniqueRegistries.map(normalizeRegistry); +} diff --git a/packages/safe-chain/src/config/settings.spec.js b/packages/safe-chain/src/config/settings.spec.js new file mode 100644 index 0000000..05d698f --- /dev/null +++ b/packages/safe-chain/src/config/settings.spec.js @@ -0,0 +1,249 @@ +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; + +let configFileContent = undefined; +mock.module("fs", { + namedExports: { + existsSync: () => configFileContent !== undefined, + readFileSync: () => configFileContent, + writeFileSync: (content) => (configFileContent = content), + mkdirSync: () => {}, + }, +}); + +describe("getNpmCustomRegistries", async () => { + let originalEnv; + const { getNpmCustomRegistries } = await import("./settings.js"); + + beforeEach(() => { + originalEnv = process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; + }); + + 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"], + }, + }); + + 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", + ], + }, + }); + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, [ + "npm.company.com", + "registry.internal.net", + ]); + }); + + it("should strip http:// protocol from registries", () => { + configFileContent = JSON.stringify({ + npm: { + customRegistries: [ + "http://npm.company.com", + "http://registry.internal.net", + ], + }, + }); + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, [ + "npm.company.com", + "registry.internal.net", + ]); + }); + + it("should handle mixed protocols and no protocol", () => { + configFileContent = JSON.stringify({ + npm: { + customRegistries: [ + "https://npm.company.com", + "registry.internal.net", + "http://private.registry.io", + ], + }, + }); + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, [ + "npm.company.com", + "registry.internal.net", + "private.registry.io", + ]); + }); + + it("should preserve registry path after stripping protocol", () => { + configFileContent = JSON.stringify({ + npm: { + customRegistries: [ + "https://npm.company.com/custom/path", + "registry.internal.net/npm", + ], + }, + }); + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, [ + "npm.company.com/custom/path", + "registry.internal.net/npm", + ]); + }); + + 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"], + }, + }); + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, [ + "env1.registry.com", + "config1.registry.net", + ]); + }); + + 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"], + }, + }); + + const registries = getNpmCustomRegistries(); + + assert.deepStrictEqual(registries, [ + "npm.company.com", + "env.registry.com", + "config.registry.net", + ]); + }); + + 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, []); + }); +}); diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js index eaf50db..d7c13c0 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js @@ -1,4 +1,7 @@ -import { skipMinimumPackageAge } from "../../../config/settings.js"; +import { + getNpmCustomRegistries, + skipMinimumPackageAge, +} from "../../../config/settings.js"; import { isMalwarePackage } from "../../../scanning/audit/index.js"; import { interceptRequests } from "../interceptorBuilder.js"; import { @@ -15,7 +18,9 @@ const knownJsRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"]; * @returns {import("../interceptorBuilder.js").Interceptor | undefined} */ export function npmInterceptorForUrl(url) { - const registry = knownJsRegistries.find((reg) => url.includes(reg)); + const registry = [...knownJsRegistries, ...getNpmCustomRegistries()].find( + (reg) => url.includes(reg) + ); if (registry) { return buildNpmInterceptor(registry); diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js index 999e64a..fb7ae56 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.minPackageAge.spec.js @@ -9,6 +9,7 @@ describe("npmInterceptor minimum package age", async () => { namedExports: { getMinimumPackageAgeHours: () => minimumPackageAgeSettings, skipMinimumPackageAge: () => skipMinimumPackageAgeSetting, + getNpmCustomRegistries: () => [], }, }); diff --git a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js index a90432e..88fcbd0 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.packageDownload.spec.js @@ -1,19 +1,36 @@ import { describe, it, mock } from "node:test"; import assert from "node:assert"; -describe("npmInterceptor", async () => { - let lastPackage; - let malwareResponse = false; +let lastPackage; +let malwareResponse = false; +let customRegistries = []; - mock.module("../../../scanning/audit/index.js", { - namedExports: { - isMalwarePackage: async (packageName, version) => { - lastPackage = { packageName, version }; - return malwareResponse; - }, +mock.module("../../../scanning/audit/index.js", { + namedExports: { + isMalwarePackage: async (packageName, version) => { + lastPackage = { packageName, version }; + return malwareResponse; }, - }); + }, +}); +mock.module("../../../config/settings.js", { + namedExports: { + LOGGING_SILENT: "silent", + LOGGING_NORMAL: "normal", + LOGGING_VERBOSE: "verbose", + ECOSYSTEM_JS: "js", + ECOSYSTEM_PY: "py", + getLoggingLevel: () => "normal", + getEcoSystem: () => "js", + setEcoSystem: () => {}, + getMinimumPackageAgeHours: () => 24, + getNpmCustomRegistries: () => customRegistries, + skipMinimumPackageAge: () => false, + }, +}); + +describe("npmInterceptor", async () => { const { npmInterceptorForUrl } = await import("./npmInterceptor.js"); const parserCases = [ @@ -161,3 +178,90 @@ describe("npmInterceptor", async () => { ); }); }); + +describe("npmInterceptor with custom registries", async () => { + const { npmInterceptorForUrl } = await import("./npmInterceptor.js"); + + it("should create interceptor for custom registry", async () => { + // Set custom registries for this test + customRegistries = ["npm.company.com", "registry.internal.net"]; + const url = "https://npm.company.com/lodash/-/lodash-4.17.21.tgz"; + + const interceptor = npmInterceptorForUrl(url); + + assert.ok(interceptor, "Interceptor should be created for custom registry"); + + await interceptor.handleRequest(url); + + assert.deepEqual(lastPackage, { + packageName: "lodash", + version: "4.17.21", + }); + }); + + it("should create interceptor for custom registry with scoped packages", async () => { + // Set custom registries for this test + customRegistries = ["npm.company.com", "registry.internal.net"]; + malwareResponse = false; + + const url = + "https://registry.internal.net/@company/package/-/package-1.0.0.tgz"; + + const interceptor = npmInterceptorForUrl(url); + + assert.ok( + interceptor, + "Interceptor should be created for custom registry with scoped package" + ); + + await interceptor.handleRequest(url); + + assert.deepEqual(lastPackage, { + packageName: "@company/package", + version: "1.0.0", + }); + }); + + it("should handle multiple custom registries", async () => { + // Set custom registries for this test + customRegistries = ["npm.company.com", "registry.internal.net"]; + malwareResponse = false; + + const url1 = "https://npm.company.com/lodash/-/lodash-4.17.21.tgz"; + const url2 = "https://registry.internal.net/express/-/express-4.18.2.tgz"; + + const interceptor1 = npmInterceptorForUrl(url1); + const interceptor2 = npmInterceptorForUrl(url2); + + assert.ok(interceptor1, "Should create interceptor for first registry"); + assert.ok(interceptor2, "Should create interceptor for second registry"); + + await interceptor1.handleRequest(url1); + assert.deepEqual(lastPackage, { + packageName: "lodash", + version: "4.17.21", + }); + + await interceptor2.handleRequest(url2); + assert.deepEqual(lastPackage, { + packageName: "express", + version: "4.18.2", + }); + }); + + it("should not create interceptor for non-custom registry", () => { + // Set custom registries for this test + customRegistries = ["npm.company.com", "registry.internal.net"]; + malwareResponse = false; + + const url = "https://unknown.registry.com/package/-/package-1.0.0.tgz"; + + const interceptor = npmInterceptorForUrl(url); + + assert.equal( + interceptor, + undefined, + "Should not create interceptor for unknown registry" + ); + }); +});