Merge pull request #259 from AikidoSec/configure-custom-npm-registries

Allow to configure custom/private npm registries
This commit is contained in:
Sander Declerck 2025-12-19 10:42:51 +01:00 committed by GitHub
commit 120e12fd34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 584 additions and 24 deletions

View file

@ -184,6 +184,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.

View file

@ -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,28 @@ 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;
if (!Array.isArray(customRegistries)) {
return [];
}
return customRegistries.filter((item) => typeof item === "string");
}
/**
* @param {import("../api/aikido.js").MalwarePackage[]} data
* @param {string | number} version
@ -136,23 +162,26 @@ export function readDatabaseFromLocalCache() {
* @returns {SafeChainConfig}
*/
function readConfigFile() {
/** @type {SafeChainConfig} */
const emptyConfig = {
scanTimeout: undefined,
minimumPackageAgeHours: undefined,
npm: {
customRegistries: undefined,
},
};
const configFilePath = getConfigFilePath();
if (!fs.existsSync(configFilePath)) {
return {
scanTimeout: undefined,
minimumPackageAgeHours: undefined,
};
return emptyConfig;
}
try {
const data = fs.readFileSync(configFilePath, "utf8");
return JSON.parse(data);
} catch {
return {
scanTimeout: undefined,
minimumPackageAgeHours: undefined,
};
return emptyConfig;
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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 {
@ -8,14 +11,20 @@ import {
} from "./modifyNpmInfo.js";
import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js";
const knownJsRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"];
const knownJsRegistries = [
"registry.npmjs.org",
"registry.yarnpkg.com",
"registry.npmjs.com",
];
/**
* @param {string} url
* @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);

View file

@ -9,6 +9,7 @@ describe("npmInterceptor minimum package age", async () => {
namedExports: {
getMinimumPackageAgeHours: () => minimumPackageAgeSettings,
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
getNpmCustomRegistries: () => [],
},
});

View file

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