add a configuration option for custom malwaredb and newpackagelist urls.

This commit is contained in:
123Haynes 2026-03-31 11:52:26 +00:00
parent 5bc8b39f56
commit 1abe5932ad
8 changed files with 219 additions and 19 deletions

View file

@ -277,6 +277,41 @@ You can set custom registries through environment variable or config file. Both
} }
``` ```
## Malware List Base URL
Configure Safe Chain to fetch malware databases and new packages lists from a custom mirror URL. This allows you to host your own copy of the Aikido malware database.
### Configuration Options
You can set the malware list base URL through multiple sources (in order of priority):
1. **CLI Argument** (highest priority):
```shell
npm install express --safe-chain-malware-list-base-url=https://your-mirror.com
```
2. **Environment Variable**:
```shell
export SAFE_CHAIN_MALWARE_LIST_BASE_URL=https://your-mirror.com
npm install express
```
3. **Config File** (`~/.safe-chain/config.json`):
```json
{
"malwareListBaseUrl": "https://your-mirror.com"
}
```
The base URL should point to a server that mirrors the structure of `https://malware-list.aikido.dev/`, including the following paths:
- `/malware_predictions.json` (JavaScript ecosystem malware database)
- `/malware_pypi.json` (Python ecosystem malware database)
- `/releases/npm.json` (JavaScript new packages list)
- `/releases/pypi.json` (Python new packages list)
# Usage in CI/CD # 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. 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

@ -3,17 +3,18 @@ import {
getEcoSystem, getEcoSystem,
ECOSYSTEM_JS, ECOSYSTEM_JS,
ECOSYSTEM_PY, ECOSYSTEM_PY,
getMalwareListBaseUrl,
} from "../config/settings.js"; } from "../config/settings.js";
import { ui } from "../environment/userInteraction.js"; import { ui } from "../environment/userInteraction.js";
const malwareDatabaseUrls = { const malwareDatabasePaths = {
[ECOSYSTEM_JS]: "https://malware-list.aikido.dev/malware_predictions.json", [ECOSYSTEM_JS]: "malware_predictions.json",
[ECOSYSTEM_PY]: "https://malware-list.aikido.dev/malware_pypi.json", [ECOSYSTEM_PY]: "malware_pypi.json",
}; };
const newPackagesListUrls = { const newPackagesListPaths = {
[ECOSYSTEM_JS]: "https://malware-list.aikido.dev/releases/npm.json", [ECOSYSTEM_JS]: "releases/npm.json",
[ECOSYSTEM_PY]: "https://malware-list.aikido.dev/releases/pypi.json", [ECOSYSTEM_PY]: "releases/pypi.json",
}; };
const DEFAULT_FETCH_RETRY_ATTEMPTS = 4; const DEFAULT_FETCH_RETRY_ATTEMPTS = 4;
@ -40,10 +41,11 @@ const DEFAULT_FETCH_RETRY_ATTEMPTS = 4;
export async function fetchMalwareDatabase() { export async function fetchMalwareDatabase() {
return retry(async () => { return retry(async () => {
const ecosystem = getEcoSystem(); const ecosystem = getEcoSystem();
const malwareDatabaseUrl = const baseUrl = getMalwareListBaseUrl();
malwareDatabaseUrls[ const path = malwareDatabasePaths[
/** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem) /** @type {keyof typeof malwareDatabasePaths} */ (ecosystem)
]; ];
const malwareDatabaseUrl = `${baseUrl}/${path}`;
const response = await fetch(malwareDatabaseUrl); const response = await fetch(malwareDatabaseUrl);
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(
@ -69,10 +71,11 @@ export async function fetchMalwareDatabase() {
export async function fetchMalwareDatabaseVersion() { export async function fetchMalwareDatabaseVersion() {
return retry(async () => { return retry(async () => {
const ecosystem = getEcoSystem(); const ecosystem = getEcoSystem();
const malwareDatabaseUrl = const baseUrl = getMalwareListBaseUrl();
malwareDatabaseUrls[ const path = malwareDatabasePaths[
/** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem) /** @type {keyof typeof malwareDatabasePaths} */ (ecosystem)
]; ];
const malwareDatabaseUrl = `${baseUrl}/${path}`;
const response = await fetch(malwareDatabaseUrl, { const response = await fetch(malwareDatabaseUrl, {
method: "HEAD", method: "HEAD",
}); });
@ -92,8 +95,9 @@ export async function fetchMalwareDatabaseVersion() {
export async function fetchNewPackagesList() { export async function fetchNewPackagesList() {
return retry(async () => { return retry(async () => {
const ecosystem = getEcoSystem(); const ecosystem = getEcoSystem();
const url = const baseUrl = getMalwareListBaseUrl();
newPackagesListUrls[/** @type {keyof typeof newPackagesListUrls} */ (ecosystem)]; const path = newPackagesListPaths[/** @type {keyof typeof newPackagesListPaths} */ (ecosystem)];
const url = `${baseUrl}/${path}`;
if (!url) { if (!url) {
return { newPackagesList: [], version: undefined }; return { newPackagesList: [], version: undefined };
@ -124,8 +128,9 @@ export async function fetchNewPackagesList() {
export async function fetchNewPackagesListVersion() { export async function fetchNewPackagesListVersion() {
return retry(async () => { return retry(async () => {
const ecosystem = getEcoSystem(); const ecosystem = getEcoSystem();
const url = const baseUrl = getMalwareListBaseUrl();
newPackagesListUrls[/** @type {keyof typeof newPackagesListUrls} */ (ecosystem)]; const path = newPackagesListPaths[/** @type {keyof typeof newPackagesListPaths} */ (ecosystem)];
const url = `${baseUrl}/${path}`;
if (!url) { if (!url) {
return undefined; return undefined;

View file

@ -22,6 +22,7 @@ describe("aikido API", async () => {
getEcoSystem: () => ecosystem, getEcoSystem: () => ecosystem,
ECOSYSTEM_JS: "js", ECOSYSTEM_JS: "js",
ECOSYSTEM_PY: "py", ECOSYSTEM_PY: "py",
getMalwareListBaseUrl: () => "https://malware-list.aikido.dev",
}, },
}); });

View file

@ -1,12 +1,13 @@
import { ui } from "../environment/userInteraction.js"; import { ui } from "../environment/userInteraction.js";
/** /**
* @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, minimumPackageAgeHours: string | undefined}} * @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, minimumPackageAgeHours: string | undefined, malwareListBaseUrl: string | undefined}}
*/ */
const state = { const state = {
loggingLevel: undefined, loggingLevel: undefined,
skipMinimumPackageAge: undefined, skipMinimumPackageAge: undefined,
minimumPackageAgeHours: undefined, minimumPackageAgeHours: undefined,
malwareListBaseUrl: undefined,
}; };
const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-"; const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-";
@ -20,6 +21,7 @@ export function initializeCliArguments(args) {
state.loggingLevel = undefined; state.loggingLevel = undefined;
state.skipMinimumPackageAge = undefined; state.skipMinimumPackageAge = undefined;
state.minimumPackageAgeHours = undefined; state.minimumPackageAgeHours = undefined;
state.malwareListBaseUrl = undefined;
const safeChainArgs = []; const safeChainArgs = [];
const remainingArgs = []; const remainingArgs = [];
@ -35,6 +37,7 @@ export function initializeCliArguments(args) {
setLoggingLevel(safeChainArgs); setLoggingLevel(safeChainArgs);
setSkipMinimumPackageAge(safeChainArgs); setSkipMinimumPackageAge(safeChainArgs);
setMinimumPackageAgeHours(safeChainArgs); setMinimumPackageAgeHours(safeChainArgs);
setMalwareListBaseUrl(safeChainArgs);
checkDeprecatedPythonFlag(args); checkDeprecatedPythonFlag(args);
return remainingArgs; return remainingArgs;
} }
@ -109,6 +112,26 @@ export function getMinimumPackageAgeHours() {
return state.minimumPackageAgeHours; return state.minimumPackageAgeHours;
} }
/**
* @param {string[]} args
* @returns {void}
*/
function setMalwareListBaseUrl(args) {
const argName = SAFE_CHAIN_ARG_PREFIX + "malware-list-base-url=";
const value = getLastArgEqualsValue(args, argName);
if (value) {
state.malwareListBaseUrl = value;
}
}
/**
* @returns {string | undefined}
*/
export function getMalwareListBaseUrl() {
return state.malwareListBaseUrl;
}
/** /**
* @param {string[]} args * @param {string[]} args
* @param {string} flagName * @param {string} flagName

View file

@ -10,6 +10,7 @@ import { getEcoSystem } from "./settings.js";
* We cannot trust the input and should add the necessary validations * We cannot trust the input and should add the necessary validations
* @property {unknown | Number} scanTimeout * @property {unknown | Number} scanTimeout
* @property {unknown | Number} minimumPackageAgeHours * @property {unknown | Number} minimumPackageAgeHours
* @property {unknown | string} malwareListBaseUrl
* @property {unknown | SafeChainRegistryConfiguration} npm * @property {unknown | SafeChainRegistryConfiguration} npm
* @property {unknown | SafeChainRegistryConfiguration} pip * @property {unknown | SafeChainRegistryConfiguration} pip
* *
@ -84,6 +85,18 @@ export function getMinimumPackageAgeHours() {
return undefined; return undefined;
} }
/**
* Gets the malware list base URL from config file only
* @returns {string | undefined}
*/
export function getMalwareListBaseUrl() {
const config = readConfigFile();
if (config.malwareListBaseUrl && typeof config.malwareListBaseUrl === "string") {
return config.malwareListBaseUrl;
}
return undefined;
}
/** /**
* Gets the custom npm registries from the config file (format parsing only, no validation) * Gets the custom npm registries from the config file (format parsing only, no validation)
* @returns {string[]} * @returns {string[]}
@ -214,6 +227,7 @@ function readConfigFile() {
const emptyConfig = { const emptyConfig = {
scanTimeout: undefined, scanTimeout: undefined,
minimumPackageAgeHours: undefined, minimumPackageAgeHours: undefined,
malwareListBaseUrl: undefined,
npm: { npm: {
customRegistries: undefined, customRegistries: undefined,
}, },

View file

@ -45,3 +45,13 @@ export function getMinimumPackageAgeExclusions() {
return process.env.SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS || return process.env.SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS ||
process.env.SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS; process.env.SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS;
} }
/**
* Gets the malware list base URL from environment variable
* Expected format: full URL without trailing slash
* Example: "https://malware-list.aikido.dev"
* @returns {string | undefined}
*/
export function getMalwareListBaseUrl() {
return process.env.SAFE_CHAIN_MALWARE_LIST_BASE_URL;
}

View file

@ -198,3 +198,30 @@ export function getMinimumPackageAgeExclusions() {
const allExclusions = [...envExclusions, ...configExclusions]; const allExclusions = [...envExclusions, ...configExclusions];
return [...new Set(allExclusions)]; return [...new Set(allExclusions)];
} }
/**
* Gets the malware list base URL with priority: CLI argument > environment variable > config file > default
* @returns {string}
*/
export function getMalwareListBaseUrl() {
// Priority 1: CLI argument
const cliValue = cliArguments.getMalwareListBaseUrl();
if (cliValue) {
return cliValue;
}
// Priority 2: Environment variable
const envValue = environmentVariables.getMalwareListBaseUrl();
if (envValue) {
return envValue;
}
// Priority 3: Config file
const configValue = configFile.getMalwareListBaseUrl();
if (configValue) {
return configValue;
}
// Default
return "https://malware-list.aikido.dev";
}

View file

@ -15,6 +15,7 @@ const {
getNpmCustomRegistries, getNpmCustomRegistries,
getPipCustomRegistries, getPipCustomRegistries,
getMinimumPackageAgeExclusions, getMinimumPackageAgeExclusions,
getMalwareListBaseUrl,
setEcoSystem, setEcoSystem,
ECOSYSTEM_JS, ECOSYSTEM_JS,
ECOSYSTEM_PY, ECOSYSTEM_PY,
@ -534,3 +535,87 @@ describe("getMinimumPackageAgeExclusions", () => {
assert.deepStrictEqual(exclusions, ["requests", "urllib3"]); assert.deepStrictEqual(exclusions, ["requests", "urllib3"]);
}); });
}); });
describe("getMalwareListBaseUrl", () => {
let originalEnv;
const envVarName = "SAFE_CHAIN_MALWARE_LIST_BASE_URL";
beforeEach(() => {
originalEnv = process.env[envVarName];
delete process.env[envVarName];
// Reset CLI arguments state
initializeCliArguments([]);
});
afterEach(() => {
if (originalEnv !== undefined) {
process.env[envVarName] = originalEnv;
} else {
delete process.env[envVarName];
}
configFileContent = undefined;
});
it("should return default URL when nothing is configured", () => {
const url = getMalwareListBaseUrl();
assert.strictEqual(url, "https://malware-list.aikido.dev");
});
it("should return CLI argument value with highest priority", () => {
initializeCliArguments(["--safe-chain-malware-list-base-url=https://cli-mirror.com"]);
const url = getMalwareListBaseUrl();
assert.strictEqual(url, "https://cli-mirror.com");
});
it("should return environment variable value when no CLI argument", () => {
process.env[envVarName] = "https://env-mirror.com";
const url = getMalwareListBaseUrl();
assert.strictEqual(url, "https://env-mirror.com");
});
it("should return config file value when no CLI or env", () => {
configFileContent = JSON.stringify({
malwareListBaseUrl: "https://config-mirror.com",
});
const url = getMalwareListBaseUrl();
assert.strictEqual(url, "https://config-mirror.com");
});
it("should prioritize CLI over environment variable", () => {
process.env[envVarName] = "https://env-mirror.com";
initializeCliArguments(["--safe-chain-malware-list-base-url=https://cli-mirror.com"]);
const url = getMalwareListBaseUrl();
assert.strictEqual(url, "https://cli-mirror.com");
});
it("should prioritize environment variable over config file", () => {
process.env[envVarName] = "https://env-mirror.com";
configFileContent = JSON.stringify({
malwareListBaseUrl: "https://config-mirror.com",
});
const url = getMalwareListBaseUrl();
assert.strictEqual(url, "https://env-mirror.com");
});
it("should prioritize CLI over config file", () => {
initializeCliArguments(["--safe-chain-malware-list-base-url=https://cli-mirror.com"]);
configFileContent = JSON.stringify({
malwareListBaseUrl: "https://config-mirror.com",
});
const url = getMalwareListBaseUrl();
assert.strictEqual(url, "https://cli-mirror.com");
});
});