feat: allow python custom registries configuration through config file

This commit is contained in:
galargh 2025-12-22 13:49:45 +01:00
parent 39e2001d97
commit c53a7347e2
5 changed files with 325 additions and 283 deletions

View file

@ -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 ### Configuration Options
@ -200,6 +204,7 @@ You can set custom registries through environment variable or config file. Both
```shell ```shell
export SAFE_CHAIN_NPM_CUSTOM_REGISTRIES="npm.company.com,registry.internal.net" 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`): 2. **Config File** (`~/.aikido/config.json`):
@ -208,6 +213,9 @@ You can set custom registries through environment variable or config file. Both
{ {
"npm": { "npm": {
"customRegistries": ["npm.company.com", "registry.internal.net"] "customRegistries": ["npm.company.com", "registry.internal.net"]
},
"pip": {
"customRegistries": ["pip.company.com", "registry.internal.net"]
} }
} }
``` ```

View file

@ -11,6 +11,7 @@ import { getEcoSystem } from "./settings.js";
* @property {unknown | Number} scanTimeout * @property {unknown | Number} scanTimeout
* @property {unknown | Number} minimumPackageAgeHours * @property {unknown | Number} minimumPackageAgeHours
* @property {unknown | SafeChainRegistryConfiguration} npm * @property {unknown | SafeChainRegistryConfiguration} npm
* @property {unknown | SafeChainRegistryConfiguration} pip
* *
* @typedef {Object} SafeChainRegistryConfiguration * @typedef {Object} SafeChainRegistryConfiguration
* We cannot trust the input and should add the necessary validations. * 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"); 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 {import("../api/aikido.js").MalwarePackage[]} data
* @param {string | number} version * @param {string | number} version
@ -169,6 +192,9 @@ function readConfigFile() {
npm: { npm: {
customRegistries: undefined, customRegistries: undefined,
}, },
pip: {
customRegistries: undefined,
},
}; };
const configFilePath = getConfigFilePath(); const configFilePath = getConfigFilePath();

View file

@ -232,91 +232,95 @@ describe("getMinimumPackageAgeHours", async () => {
}); });
}); });
describe("getNpmCustomRegistries", async () => { for (const packageManager of ["npm", "pip"]) {
const { getNpmCustomRegistries } = await import("./configFile.js"); const fnName = `get${packageManager.charAt(0).toUpperCase()}${packageManager.slice(1)}CustomRegistries`;
afterEach(() => { describe(fnName, async () => {
configFileContent = undefined; const fn = (await import("./configFile.js"))[fnName];
});
it("should return empty array when config file doesn't exist", () => { afterEach(() => {
configFileContent = undefined; 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(); 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", () => { assert.deepStrictEqual(registries, []);
configFileContent = JSON.stringify({
npm: {
customRegistries: ["npm.company.com", "registry.internal.net"],
},
}); });
const registries = getNpmCustomRegistries(); it(`should return empty array when ${packageManager} config is not set`, () => {
configFileContent = JSON.stringify({ scanTimeout: 5000 });
assert.deepStrictEqual(registries, [ const registries = fn();
"npm.company.com",
"registry.internal.net",
]);
});
it("should filter out non-string values", () => { assert.deepStrictEqual(registries, []);
configFileContent = JSON.stringify({
npm: {
customRegistries: [
"npm.company.com",
123,
null,
"registry.internal.net",
undefined,
{},
],
},
}); });
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, [ const registries = fn();
"npm.company.com",
"registry.internal.net",
]);
});
it("should return empty array for empty customRegistries array", () => { assert.deepStrictEqual(registries, []);
configFileContent = JSON.stringify({
npm: { customRegistries: [] },
}); });
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, []);
});
});

View file

@ -152,11 +152,10 @@ export function getPipCustomRegistries() {
const envRegistries = parseRegistriesFromEnv( const envRegistries = parseRegistriesFromEnv(
environmentVariables.getPipCustomRegistries() environmentVariables.getPipCustomRegistries()
); );
// const configRegistries = configFile.getPipCustomRegistries(); const configRegistries = configFile.getPipCustomRegistries();
// Merge both sources and remove duplicates // Merge both sources and remove duplicates
// const allRegistries = [...envRegistries, ...configRegistries]; const allRegistries = [...envRegistries, ...configRegistries];
const allRegistries = [...envRegistries];
const uniqueRegistries = [...new Set(allRegistries)]; const uniqueRegistries = [...new Set(allRegistries)];
// Normalize each registry (remove protocol if any) // Normalize each registry (remove protocol if any)

View file

@ -11,239 +11,244 @@ mock.module("fs", {
}, },
}); });
describe("getNpmCustomRegistries", async () => { for (const packageManager of ["npm", "pip"]) {
let originalEnv; const fnName = `get${packageManager.charAt(0).toUpperCase()}${packageManager.slice(1)}CustomRegistries`;
const { getNpmCustomRegistries } = await import("./settings.js"); const envVarName = `SAFE_CHAIN_${packageManager.toUpperCase()}_CUSTOM_REGISTRIES`;
beforeEach(() => { describe(fnName, async () => {
originalEnv = process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; let originalEnv;
}); const fn = (await import("./settings.js"))[fnName];
afterEach(() => { beforeEach(() => {
if (originalEnv !== undefined) { originalEnv = process.env[envVarName];
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(); afterEach(() => {
if (originalEnv !== undefined) {
assert.deepStrictEqual(registries, [ process.env[envVarName] = originalEnv;
"npm.company.com", } else {
"registry.internal.net", delete process.env[envVarName];
]); }
}); configFileContent = undefined;
it("should strip https:// protocol from registries", () => {
configFileContent = JSON.stringify({
npm: {
customRegistries: [
"https://npm.company.com",
"https://registry.internal.net",
],
},
}); });
const registries = getNpmCustomRegistries(); it("should return empty array when no registries configured", () => {
configFileContent = undefined;
assert.deepStrictEqual(registries, [ const registries = fn();
"npm.company.com",
"registry.internal.net",
]);
});
it("should strip http:// protocol from registries", () => { assert.deepStrictEqual(registries, []);
configFileContent = JSON.stringify({
npm: {
customRegistries: [
"http://npm.company.com",
"http://registry.internal.net",
],
},
}); });
const registries = getNpmCustomRegistries(); it("should return registries without protocol", () => {
configFileContent = JSON.stringify({
[packageManager]: {
customRegistries: [`${packageManager}.company.com`, "registry.internal.net"],
},
});
assert.deepStrictEqual(registries, [ const registries = fn();
"npm.company.com",
"registry.internal.net",
]);
});
it("should handle mixed protocols and no protocol", () => { assert.deepStrictEqual(registries, [
configFileContent = JSON.stringify({ `${packageManager}.company.com`,
npm: { "registry.internal.net",
customRegistries: [ ]);
"https://npm.company.com",
"registry.internal.net",
"http://private.registry.io",
],
},
}); });
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, [ const registries = fn();
"npm.company.com",
"registry.internal.net",
"private.registry.io",
]);
});
it("should preserve registry path after stripping protocol", () => { assert.deepStrictEqual(registries, [
configFileContent = JSON.stringify({ `${packageManager}.company.com`,
npm: { "registry.internal.net",
customRegistries: [ ]);
"https://npm.company.com/custom/path",
"registry.internal.net/npm",
],
},
}); });
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, [ const registries = fn();
"npm.company.com/custom/path",
"registry.internal.net/npm",
]);
});
it("should parse comma-separated registries from environment variable", () => { assert.deepStrictEqual(registries, [
delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; `${packageManager}.company.com`,
process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = "registry.internal.net",
"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(); 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, [ const registries = fn();
"env1.registry.com",
"config1.registry.net",
]);
});
it("should remove duplicate registries when merging env and config", () => { assert.deepStrictEqual(registries, [
delete process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES; `${packageManager}.company.com`,
process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES = "registry.internal.net",
"npm.company.com,env.registry.com"; "private.registry.io",
configFileContent = JSON.stringify({ ]);
npm: {
customRegistries: ["npm.company.com", "config.registry.net"],
},
}); });
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, [ const registries = fn();
"npm.company.com",
"env.registry.com", assert.deepStrictEqual(registries, [
"config.registry.net", `${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, []);
});
});