Allow to configure custom/prinvate npm registries

This commit is contained in:
Sander Declerck 2025-12-18 13:52:49 +01:00
parent 0925279521
commit 41cc24d1f5
No known key found for this signature in database
9 changed files with 576 additions and 15 deletions

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

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