@aikidosec/safe-chain 1.4.3 → 1.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +65 -8
  2. package/bin/safe-chain.js +1 -81
  3. package/package.json +1 -1
  4. package/src/api/aikido.js +93 -18
  5. package/src/config/cliArguments.js +24 -1
  6. package/src/config/configFile.js +64 -6
  7. package/src/config/environmentVariables.js +13 -2
  8. package/src/config/settings.js +53 -4
  9. package/src/main.js +6 -2
  10. package/src/packagemanager/_shared/commandErrors.js +17 -0
  11. package/src/packagemanager/bun/createBunPackageManager.js +2 -7
  12. package/src/packagemanager/npm/runNpmCommand.js +2 -7
  13. package/src/packagemanager/npx/runNpxCommand.js +2 -7
  14. package/src/packagemanager/pip/runPipCommand.js +2 -7
  15. package/src/packagemanager/pipx/runPipXCommand.js +2 -7
  16. package/src/packagemanager/pnpm/runPnpmCommand.js +3 -7
  17. package/src/packagemanager/poetry/createPoetryPackageManager.js +2 -7
  18. package/src/packagemanager/uv/runUvCommand.js +2 -7
  19. package/src/packagemanager/yarn/runYarnCommand.js +2 -7
  20. package/src/registryProxy/certBundle.js +25 -3
  21. package/src/registryProxy/http-utils.js +63 -0
  22. package/src/registryProxy/interceptors/createInterceptorForEcoSystem.js +1 -1
  23. package/src/registryProxy/interceptors/interceptorBuilder.js +37 -4
  24. package/src/registryProxy/interceptors/minimumPackageAgeExclusions.js +33 -0
  25. package/src/registryProxy/interceptors/npm/modifyNpmInfo.js +18 -41
  26. package/src/registryProxy/interceptors/npm/npmInterceptor.js +47 -2
  27. package/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js +20 -3
  28. package/src/registryProxy/interceptors/pip/modifyPipInfo.js +167 -0
  29. package/src/registryProxy/interceptors/pip/modifyPipJsonResponse.js +176 -0
  30. package/src/registryProxy/interceptors/pip/parsePipPackageUrl.js +162 -0
  31. package/src/registryProxy/interceptors/pip/pipInterceptor.js +122 -0
  32. package/src/registryProxy/interceptors/pip/pipMetadataResponseUtils.js +27 -0
  33. package/src/registryProxy/interceptors/pip/pipMetadataVersionUtils.js +131 -0
  34. package/src/registryProxy/interceptors/suppressedVersionsState.js +21 -0
  35. package/src/registryProxy/mitmRequestHandler.js +12 -6
  36. package/src/registryProxy/registryProxy.js +72 -9
  37. package/src/scanning/newPackagesDatabaseBuilder.js +71 -0
  38. package/src/scanning/newPackagesDatabaseWarnings.js +17 -0
  39. package/src/scanning/newPackagesListCache.js +126 -0
  40. package/src/scanning/packageNameVariants.js +29 -0
  41. package/src/shell-integration/setup.js +7 -3
  42. package/src/shell-integration/shellDetection.js +2 -0
  43. package/src/shell-integration/supported-shells/bash.js +19 -1
  44. package/src/shell-integration/supported-shells/fish.js +18 -0
  45. package/src/shell-integration/supported-shells/powershell.js +18 -0
  46. package/src/shell-integration/supported-shells/windowsPowershell.js +18 -0
  47. package/src/shell-integration/supported-shells/zsh.js +19 -1
  48. package/src/shell-integration/teardown.js +7 -1
  49. package/src/ultimate/ultimateTroubleshooting.js +1 -1
  50. package/src/installation/downloadAgent.js +0 -125
  51. package/src/installation/installOnMacOS.js +0 -155
  52. package/src/installation/installOnWindows.js +0 -203
  53. package/src/installation/installUltimate.js +0 -35
  54. package/src/registryProxy/interceptors/pipInterceptor.js +0 -132
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  - ✅ **Block malware on developer laptops and CI/CD**
9
9
  - ✅ **Supports npm and PyPI** more package managers coming
10
- - ✅ **Blocks packages newer than 24 hours** without breaking your build
10
+ - ✅ **Blocks packages newer than 48 hours** without breaking your build
11
11
  - ✅ **Tokenless, free, no build data shared**
12
12
 
13
13
  Aikido Safe Chain supports the following package managers:
@@ -111,11 +111,20 @@ safe-chain --version
111
111
 
112
112
  The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, uv, poetry or pipx commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine.
113
113
 
114
- ### Minimum package age (npm only)
114
+ ### Minimum package age
115
115
 
116
- For npm packages, Safe Chain temporarily suppresses packages published within the last 24 hours (by default) until they have been validated against malware. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can configure this threshold or bypass this protection entirely - see the [Minimum Package Age Configuration](#minimum-package-age) section below.
116
+ Safe Chain applies minimum package age checks to supported ecosystems.
117
117
 
118
- ⚠️ This feature **only applies to npm-based package managers** (npm, npx, yarn, pnpm, pnpx, bun, bunx) and does not apply to Python package managers (uv, pip, pip3, poetry, pipx).
118
+ Current enforcement differs by ecosystem:
119
+
120
+ - npm-based package managers:
121
+ - during normal package resolution, Safe Chain suppresses versions that are newer than the configured minimum age from the package metadata returned by the registry
122
+ - for direct package download requests that bypass that metadata flow, Safe Chain can block the request itself using a cached list of newly released packages
123
+ - Python package managers:
124
+ - during package resolution, Safe Chain suppresses too-young files and releases from PyPI metadata responses
125
+ - for direct package download requests that bypass that metadata flow, Safe Chain can block the request itself using a cached list of newly released packages
126
+
127
+ By default, the minimum package age is 48 hours. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can configure this threshold or bypass this protection entirely - see the [Minimum Package Age Configuration](#minimum-package-age) section below.
119
128
 
120
129
  ### Shell Integration
121
130
 
@@ -183,7 +192,17 @@ You can set the logging level through multiple sources (in order of priority):
183
192
 
184
193
  ## Minimum Package Age
185
194
 
186
- You can configure how long packages must exist before Safe Chain allows their installation. By default, packages must be at least 24 hours old before they can be installed through npm-based package managers.
195
+ You can configure how long packages must exist before Safe Chain allows their installation. By default, packages must be at least 48 hours old before they can be installed.
196
+
197
+ For npm-based package managers, this check currently has two enforcement modes:
198
+
199
+ - Safe Chain suppresses too-young versions from package metadata during normal dependency resolution.
200
+ - Safe Chain blocks direct package download requests when they are matched against the cached newly released packages list.
201
+
202
+ For Python package managers, this check currently has two enforcement modes:
203
+
204
+ - Safe Chain suppresses too-young files and releases from PyPI metadata during dependency resolution.
205
+ - Safe Chain blocks direct package download requests when they are matched against the cached newly released packages list.
187
206
 
188
207
  ### Configuration Options
189
208
 
@@ -202,7 +221,7 @@ You can set the minimum package age through multiple sources (in order of priori
202
221
  npm install express
203
222
  ```
204
223
 
205
- 3. **Config File** (`~/.aikido/config.json`):
224
+ 3. **Config File** (`~/.safe-chain/config.json`):
206
225
 
207
226
  ```json
208
227
  {
@@ -215,13 +234,16 @@ You can set the minimum package age through multiple sources (in order of priori
215
234
  Exclude trusted packages from minimum age filtering via environment variable or config file (both are merged). Use `@scope/*` to trust all packages from an organization:
216
235
 
217
236
  ```shell
218
- export SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS="@aikidosec/*"
237
+ export SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS="@aikidosec/*"
219
238
  ```
220
239
 
221
240
  ```json
222
241
  {
223
242
  "npm": {
224
243
  "minimumPackageAgeExclusions": ["@aikidosec/*"]
244
+ },
245
+ "pip": {
246
+ "minimumPackageAgeExclusions": ["requests"]
225
247
  }
226
248
  }
227
249
  ```
@@ -246,7 +268,7 @@ You can set custom registries through environment variable or config file. Both
246
268
  export SAFE_CHAIN_PIP_CUSTOM_REGISTRIES="pip.company.com,registry.internal.net"
247
269
  ```
248
270
 
249
- 2. **Config File** (`~/.aikido/config.json`):
271
+ 2. **Config File** (`~/.safe-chain/config.json`):
250
272
 
251
273
  ```json
252
274
  {
@@ -259,6 +281,41 @@ You can set custom registries through environment variable or config file. Both
259
281
  }
260
282
  ```
261
283
 
284
+ ## Malware List Base URL
285
+
286
+ 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.
287
+
288
+ ### Configuration Options
289
+
290
+ You can set the malware list base URL through multiple sources (in order of priority):
291
+
292
+ 1. **CLI Argument** (highest priority):
293
+
294
+ ```shell
295
+ npm install express --safe-chain-malware-list-base-url=https://your-mirror.com
296
+ ```
297
+
298
+ 2. **Environment Variable**:
299
+
300
+ ```shell
301
+ export SAFE_CHAIN_MALWARE_LIST_BASE_URL=https://your-mirror.com
302
+ npm install express
303
+ ```
304
+
305
+ 3. **Config File** (`~/.safe-chain/config.json`):
306
+
307
+ ```json
308
+ {
309
+ "malwareListBaseUrl": "https://your-mirror.com"
310
+ }
311
+ ```
312
+
313
+ The base URL should point to a server that mirrors the structure of `https://malware-list.aikido.dev/`, including the following paths:
314
+ - `/malware_predictions.json` (JavaScript ecosystem malware database)
315
+ - `/malware_pypi.json` (Python ecosystem malware database)
316
+ - `/releases/npm.json` (JavaScript new packages list)
317
+ - `/releases/pypi.json` (Python new packages list)
318
+
262
319
  # Usage in CI/CD
263
320
 
264
321
  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.
package/bin/safe-chain.js CHANGED
@@ -16,14 +16,6 @@ import path from "path";
16
16
  import { fileURLToPath } from "url";
17
17
  import fs from "fs";
18
18
  import { knownAikidoTools } from "../src/shell-integration/helpers.js";
19
- import {
20
- installUltimate,
21
- uninstallUltimate,
22
- } from "../src/installation/installUltimate.js";
23
- import {
24
- printUltimateLogs,
25
- troubleshootingExport,
26
- } from "../src/ultimate/ultimateTroubleshooting.js";
27
19
 
28
20
  /** @type {string} */
29
21
  // This checks the current file's dirname in a way that's compatible with:
@@ -70,39 +62,6 @@ if (tool) {
70
62
  process.exit(0);
71
63
  } else if (command === "setup") {
72
64
  setup();
73
- } else if (command === "ultimate") {
74
- const cliArgs = initializeCliArguments(process.argv.slice(2));
75
- const subCommand = cliArgs[1];
76
- if (subCommand === "uninstall") {
77
- guardCliArgsMaxLenght(2, cliArgs, "safe-chain ultimate uninstall");
78
- (async () => {
79
- await uninstallUltimate();
80
- })();
81
- } else if (subCommand === "troubleshooting-logs") {
82
- guardCliArgsMaxLenght(
83
- 2,
84
- cliArgs,
85
- "safe-chain ultimate troubleshooting-logs",
86
- );
87
- (async () => {
88
- await printUltimateLogs();
89
- })();
90
- } else if (subCommand === "troubleshooting-export") {
91
- guardCliArgsMaxLenght(
92
- 2,
93
- cliArgs,
94
- "safe-chain ultimate troubleshooting-export",
95
- );
96
- (async () => {
97
- await troubleshootingExport();
98
- })();
99
- } else {
100
- guardCliArgsMaxLenght(1, cliArgs, "safe-chain ultimate");
101
- // Install command = when no subcommand is provided (safe-chain ultimate)
102
- (async () => {
103
- await installUltimate();
104
- })();
105
- }
106
65
  } else if (command === "teardown") {
107
66
  teardown();
108
67
  teardownDirectories();
@@ -121,22 +80,6 @@ if (tool) {
121
80
  process.exit(1);
122
81
  }
123
82
 
124
- /**
125
- * @param {Number} maxLength
126
- * @param {String[]} args
127
- * @param {String} command
128
- */
129
- function guardCliArgsMaxLenght(maxLength, args, command) {
130
- if (args.length > maxLength) {
131
- ui.writeError(`Unexpected number of arguments for command ${command}.`);
132
- ui.emptyLine();
133
-
134
- writeHelp();
135
-
136
- process.exit(1);
137
- }
138
- }
139
-
140
83
  function writeHelp() {
141
84
  ui.writeInformation(
142
85
  chalk.bold("Usage: ") + chalk.cyan("safe-chain <command>"),
@@ -145,7 +88,7 @@ function writeHelp() {
145
88
  ui.writeInformation(
146
89
  `Available commands: ${chalk.cyan("setup")}, ${chalk.cyan(
147
90
  "teardown",
148
- )}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("ultimate")}, ${chalk.cyan("help")}, ${chalk.cyan(
91
+ )}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("help")}, ${chalk.cyan(
149
92
  "--version",
150
93
  )}`,
151
94
  );
@@ -171,29 +114,6 @@ function writeHelp() {
171
114
  )}): Display the current version of safe-chain.`,
172
115
  );
173
116
  ui.emptyLine();
174
- ui.writeInformation(chalk.bold("Ultimate commands:"));
175
- ui.emptyLine();
176
- ui.writeInformation(
177
- `- ${chalk.cyan(
178
- "safe-chain ultimate",
179
- )}: Install the ultimate version of safe-chain, enabling protection for more eco-systems.`,
180
- );
181
- ui.writeInformation(
182
- `- ${chalk.cyan(
183
- "safe-chain ultimate troubleshooting-logs",
184
- )}: Prints standard and error logs for safe-chain ultimate and it's proxy.`,
185
- );
186
- ui.writeInformation(
187
- `- ${chalk.cyan(
188
- "safe-chain ultimate troubleshooting-export",
189
- )}: Creates a zip archive of useful data for troubleshooting safe-chain ultimate, that can be shared with our support team.`,
190
- );
191
- ui.writeInformation(
192
- `- ${chalk.cyan(
193
- "safe-chain ultimate uninstall",
194
- )}: Uninstall the ultimate version of safe-chain.`,
195
- );
196
- ui.emptyLine();
197
117
  }
198
118
 
199
119
  async function getVersion() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aikidosec/safe-chain",
3
- "version": "1.4.3",
3
+ "version": "1.4.7",
4
4
  "scripts": {
5
5
  "test": "node --test --experimental-test-module-mocks 'src/**/*.spec.js'",
6
6
  "test:watch": "node --test --watch --experimental-test-module-mocks 'src/**/*.spec.js'",
package/src/api/aikido.js CHANGED
@@ -3,14 +3,22 @@ import {
3
3
  getEcoSystem,
4
4
  ECOSYSTEM_JS,
5
5
  ECOSYSTEM_PY,
6
+ getMalwareListBaseUrl,
6
7
  } from "../config/settings.js";
7
8
  import { ui } from "../environment/userInteraction.js";
8
9
 
9
- const malwareDatabaseUrls = {
10
- [ECOSYSTEM_JS]: "https://malware-list.aikido.dev/malware_predictions.json",
11
- [ECOSYSTEM_PY]: "https://malware-list.aikido.dev/malware_pypi.json",
10
+ const malwareDatabasePaths = {
11
+ [ECOSYSTEM_JS]: "malware_predictions.json",
12
+ [ECOSYSTEM_PY]: "malware_pypi.json",
12
13
  };
13
14
 
15
+ const newPackagesListPaths = {
16
+ [ECOSYSTEM_JS]: "releases/npm.json",
17
+ [ECOSYSTEM_PY]: "releases/pypi.json",
18
+ };
19
+
20
+ const DEFAULT_FETCH_RETRY_ATTEMPTS = 4;
21
+
14
22
  /**
15
23
  * @typedef {Object} MalwarePackage
16
24
  * @property {string} package_name
@@ -18,18 +26,26 @@ const malwareDatabaseUrls = {
18
26
  * @property {string} reason
19
27
  */
20
28
 
29
+ /**
30
+ * @typedef {Object} NewPackageEntry
31
+ * @property {string} [source]
32
+ * @property {string} package_name
33
+ * @property {string} version
34
+ * @property {number} released_on - Unix timestamp (seconds)
35
+ * @property {number} scraped_on - Unix timestamp (seconds)
36
+ */
37
+
21
38
  /**
22
39
  * @returns {Promise<{malwareDatabase: MalwarePackage[], version: string | undefined}>}
23
40
  */
24
41
  export async function fetchMalwareDatabase() {
25
- const numberOfAttempts = 4;
26
-
27
42
  return retry(async () => {
28
43
  const ecosystem = getEcoSystem();
29
- const malwareDatabaseUrl =
30
- malwareDatabaseUrls[
31
- /** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem)
32
- ];
44
+ const baseUrl = getMalwareListBaseUrl();
45
+ const path = malwareDatabasePaths[
46
+ /** @type {keyof typeof malwareDatabasePaths} */ (ecosystem)
47
+ ];
48
+ const malwareDatabaseUrl = `${baseUrl}/${path}`;
33
49
  const response = await fetch(malwareDatabaseUrl);
34
50
  if (!response.ok) {
35
51
  throw new Error(
@@ -46,21 +62,20 @@ export async function fetchMalwareDatabase() {
46
62
  } catch (/** @type {any} */ error) {
47
63
  throw new Error(`Error parsing malware database: ${error.message}`);
48
64
  }
49
- }, numberOfAttempts);
65
+ }, DEFAULT_FETCH_RETRY_ATTEMPTS);
50
66
  }
51
67
 
52
68
  /**
53
69
  * @returns {Promise<string | undefined>}
54
70
  */
55
71
  export async function fetchMalwareDatabaseVersion() {
56
- const numberOfAttempts = 4;
57
-
58
72
  return retry(async () => {
59
73
  const ecosystem = getEcoSystem();
60
- const malwareDatabaseUrl =
61
- malwareDatabaseUrls[
62
- /** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem)
63
- ];
74
+ const baseUrl = getMalwareListBaseUrl();
75
+ const path = malwareDatabasePaths[
76
+ /** @type {keyof typeof malwareDatabasePaths} */ (ecosystem)
77
+ ];
78
+ const malwareDatabaseUrl = `${baseUrl}/${path}`;
64
79
  const response = await fetch(malwareDatabaseUrl, {
65
80
  method: "HEAD",
66
81
  });
@@ -71,7 +86,67 @@ export async function fetchMalwareDatabaseVersion() {
71
86
  );
72
87
  }
73
88
  return response.headers.get("etag") || undefined;
74
- }, numberOfAttempts);
89
+ }, DEFAULT_FETCH_RETRY_ATTEMPTS);
90
+ }
91
+
92
+ /**
93
+ * @returns {Promise<{newPackagesList: NewPackageEntry[], version: string | undefined}>}
94
+ */
95
+ export async function fetchNewPackagesList() {
96
+ return retry(async () => {
97
+ const ecosystem = getEcoSystem();
98
+ const baseUrl = getMalwareListBaseUrl();
99
+ const path = newPackagesListPaths[/** @type {keyof typeof newPackagesListPaths} */ (ecosystem)];
100
+
101
+ if (!path) {
102
+ return { newPackagesList: [], version: undefined };
103
+ }
104
+
105
+ const url = `${baseUrl}/${path}`;
106
+
107
+ const response = await fetch(url);
108
+ if (!response.ok) {
109
+ throw new Error(
110
+ `Error fetching ${ecosystem} new packages list: ${response.statusText}`
111
+ );
112
+ }
113
+
114
+ try {
115
+ const newPackagesList = await response.json();
116
+ return {
117
+ newPackagesList,
118
+ version: response.headers.get("etag") || undefined,
119
+ };
120
+ } catch (/** @type {any} */ error) {
121
+ throw new Error(`Error parsing new packages list: ${error.message}`);
122
+ }
123
+ }, DEFAULT_FETCH_RETRY_ATTEMPTS);
124
+ }
125
+
126
+ /**
127
+ * @returns {Promise<string | undefined>}
128
+ */
129
+ export async function fetchNewPackagesListVersion() {
130
+ return retry(async () => {
131
+ const ecosystem = getEcoSystem();
132
+ const baseUrl = getMalwareListBaseUrl();
133
+ const path = newPackagesListPaths[/** @type {keyof typeof newPackagesListPaths} */ (ecosystem)];
134
+
135
+ if (!path) {
136
+ return undefined;
137
+ }
138
+
139
+ const url = `${baseUrl}/${path}`;
140
+
141
+ const response = await fetch(url, { method: "HEAD" });
142
+ if (!response.ok) {
143
+ throw new Error(
144
+ `Error fetching ${ecosystem} new packages list version: ${response.statusText}`
145
+ );
146
+ }
147
+
148
+ return response.headers.get("etag") || undefined;
149
+ }, DEFAULT_FETCH_RETRY_ATTEMPTS);
75
150
  }
76
151
 
77
152
  /**
@@ -91,7 +166,7 @@ async function retry(func, attempts) {
91
166
  return await func();
92
167
  } catch (error) {
93
168
  ui.writeVerbose(
94
- "An error occurred while trying to download the Aikido Malware database",
169
+ "An error occurred while trying to download Aikido data",
95
170
  error
96
171
  );
97
172
  lastError = error;
@@ -1,12 +1,13 @@
1
1
  import { ui } from "../environment/userInteraction.js";
2
2
 
3
3
  /**
4
- * @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, minimumPackageAgeHours: string | undefined}}
4
+ * @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, minimumPackageAgeHours: string | undefined, malwareListBaseUrl: string | undefined}}
5
5
  */
6
6
  const state = {
7
7
  loggingLevel: undefined,
8
8
  skipMinimumPackageAge: undefined,
9
9
  minimumPackageAgeHours: undefined,
10
+ malwareListBaseUrl: undefined,
10
11
  };
11
12
 
12
13
  const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-";
@@ -20,6 +21,7 @@ export function initializeCliArguments(args) {
20
21
  state.loggingLevel = undefined;
21
22
  state.skipMinimumPackageAge = undefined;
22
23
  state.minimumPackageAgeHours = undefined;
24
+ state.malwareListBaseUrl = undefined;
23
25
 
24
26
  const safeChainArgs = [];
25
27
  const remainingArgs = [];
@@ -35,6 +37,7 @@ export function initializeCliArguments(args) {
35
37
  setLoggingLevel(safeChainArgs);
36
38
  setSkipMinimumPackageAge(safeChainArgs);
37
39
  setMinimumPackageAgeHours(safeChainArgs);
40
+ setMalwareListBaseUrl(safeChainArgs);
38
41
  checkDeprecatedPythonFlag(args);
39
42
  return remainingArgs;
40
43
  }
@@ -109,6 +112,26 @@ export function getMinimumPackageAgeHours() {
109
112
  return state.minimumPackageAgeHours;
110
113
  }
111
114
 
115
+ /**
116
+ * @param {string[]} args
117
+ * @returns {void}
118
+ */
119
+ function setMalwareListBaseUrl(args) {
120
+ const argName = SAFE_CHAIN_ARG_PREFIX + "malware-list-base-url=";
121
+
122
+ const value = getLastArgEqualsValue(args, argName);
123
+ if (value) {
124
+ state.malwareListBaseUrl = value;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * @returns {string | undefined}
130
+ */
131
+ export function getMalwareListBaseUrl() {
132
+ return state.malwareListBaseUrl;
133
+ }
134
+
112
135
  /**
113
136
  * @param {string[]} args
114
137
  * @param {string} flagName
@@ -10,6 +10,7 @@ import { getEcoSystem } from "./settings.js";
10
10
  * We cannot trust the input and should add the necessary validations
11
11
  * @property {unknown | Number} scanTimeout
12
12
  * @property {unknown | Number} minimumPackageAgeHours
13
+ * @property {unknown | string} malwareListBaseUrl
13
14
  * @property {unknown | SafeChainRegistryConfiguration} npm
14
15
  * @property {unknown | SafeChainRegistryConfiguration} pip
15
16
  *
@@ -84,6 +85,18 @@ export function getMinimumPackageAgeHours() {
84
85
  return undefined;
85
86
  }
86
87
 
88
+ /**
89
+ * Gets the malware list base URL from config file only
90
+ * @returns {string | undefined}
91
+ */
92
+ export function getMalwareListBaseUrl() {
93
+ const config = readConfigFile();
94
+ if (config.malwareListBaseUrl && typeof config.malwareListBaseUrl === "string") {
95
+ return config.malwareListBaseUrl;
96
+ }
97
+ return undefined;
98
+ }
99
+
87
100
  /**
88
101
  * Gets the custom npm registries from the config file (format parsing only, no validation)
89
102
  * @returns {string[]}
@@ -129,18 +142,21 @@ export function getPipCustomRegistries() {
129
142
  }
130
143
 
131
144
  /**
132
- * Gets the minimum package age exclusions from the config file
145
+ * Gets the minimum package age exclusions from the config file for the current ecosystem
133
146
  * @returns {string[]}
134
147
  */
135
- export function getNpmMinimumPackageAgeExclusions() {
148
+ export function getMinimumPackageAgeExclusions() {
136
149
  const config = readConfigFile();
150
+ const ecosystem = getEcoSystem();
151
+ const registryConfig = ecosystem === "py" ? config.pip : config.npm;
137
152
 
138
- if (!config || !config.npm) {
153
+ if (!config || !registryConfig) {
139
154
  return [];
140
155
  }
141
156
 
142
- const npmConfig = /** @type {SafeChainRegistryConfiguration} */ (config.npm);
143
- const exclusions = npmConfig.minimumPackageAgeExclusions;
157
+ const typedRegistryConfig =
158
+ /** @type {SafeChainRegistryConfiguration} */ (registryConfig);
159
+ const exclusions = typedRegistryConfig.minimumPackageAgeExclusions;
144
160
 
145
161
  if (!Array.isArray(exclusions)) {
146
162
  return [];
@@ -211,6 +227,7 @@ function readConfigFile() {
211
227
  const emptyConfig = {
212
228
  scanTimeout: undefined,
213
229
  minimumPackageAgeHours: undefined,
230
+ malwareListBaseUrl: undefined,
214
231
  npm: {
215
232
  customRegistries: undefined,
216
233
  },
@@ -248,11 +265,52 @@ function getDatabaseVersionPath() {
248
265
  return path.join(aikidoDir, `version_${ecosystem}.txt`);
249
266
  }
250
267
 
268
+ /**
269
+ * @returns {string}
270
+ */
271
+ export function getNewPackagesListPath() {
272
+ const safeChainDir = getSafeChainDirectory();
273
+ const ecosystem = getEcoSystem();
274
+ return path.join(safeChainDir, `newPackagesList_${ecosystem}.json`);
275
+ }
276
+
277
+ /**
278
+ * @returns {string}
279
+ */
280
+ export function getNewPackagesListVersionPath() {
281
+ const safeChainDir = getSafeChainDirectory();
282
+ const ecosystem = getEcoSystem();
283
+ return path.join(safeChainDir, `newPackagesList_version_${ecosystem}.txt`);
284
+ }
285
+
251
286
  /**
252
287
  * @returns {string}
253
288
  */
254
289
  function getConfigFilePath() {
255
- return path.join(getAikidoDirectory(), "config.json");
290
+ const primaryPath = path.join(getSafeChainDirectory(), "config.json");
291
+ if (fs.existsSync(primaryPath)) {
292
+ return primaryPath;
293
+ }
294
+
295
+ const legacyPath = path.join(getAikidoDirectory(), "config.json");
296
+ if (fs.existsSync(legacyPath)) {
297
+ return legacyPath;
298
+ }
299
+
300
+ return primaryPath;
301
+ }
302
+
303
+ /**
304
+ * @returns {string}
305
+ */
306
+ export function getSafeChainDirectory() {
307
+ const homeDir = os.homedir();
308
+ const safeChainDir = path.join(homeDir, ".safe-chain");
309
+
310
+ if (!fs.existsSync(safeChainDir)) {
311
+ fs.mkdirSync(safeChainDir, { recursive: true });
312
+ }
313
+ return safeChainDir;
256
314
  }
257
315
 
258
316
  /**
@@ -41,6 +41,17 @@ export function getLoggingLevel() {
41
41
  * Example: "react,@aikidosec/safe-chain,lodash"
42
42
  * @returns {string | undefined}
43
43
  */
44
- export function getNpmMinimumPackageAgeExclusions() {
45
- return process.env.SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS;
44
+ export function getMinimumPackageAgeExclusions() {
45
+ return process.env.SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS ||
46
+ process.env.SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS;
47
+ }
48
+
49
+ /**
50
+ * Gets the malware list base URL from environment variable
51
+ * Expected format: full URL without trailing slash
52
+ * Example: "https://malware-list.aikido.dev"
53
+ * @returns {string | undefined}
54
+ */
55
+ export function getMalwareListBaseUrl() {
56
+ return process.env.SAFE_CHAIN_MALWARE_LIST_BASE_URL;
46
57
  }
@@ -1,6 +1,7 @@
1
1
  import * as cliArguments from "./cliArguments.js";
2
2
  import * as configFile from "./configFile.js";
3
3
  import * as environmentVariables from "./environmentVariables.js";
4
+ import { ui } from "../environment/userInteraction.js";
4
5
 
5
6
  export const LOGGING_SILENT = "silent";
6
7
  export const LOGGING_NORMAL = "normal";
@@ -45,7 +46,7 @@ export function setEcoSystem(setting) {
45
46
  ecosystemSettings.ecoSystem = setting;
46
47
  }
47
48
 
48
- const defaultMinimumPackageAge = 24;
49
+ const defaultMinimumPackageAge = 48;
49
50
  /** @returns {number} */
50
51
  export function getMinimumPackageAgeHours() {
51
52
  // Priority 1: CLI argument
@@ -188,13 +189,61 @@ function parseExclusionsFromEnv(envValue) {
188
189
  * Gets the minimum package age exclusions from both environment variable and config file (merged)
189
190
  * @returns {string[]}
190
191
  */
191
- export function getNpmMinimumPackageAgeExclusions() {
192
+ export function getMinimumPackageAgeExclusions() {
192
193
  const envExclusions = parseExclusionsFromEnv(
193
- environmentVariables.getNpmMinimumPackageAgeExclusions()
194
+ environmentVariables.getMinimumPackageAgeExclusions()
194
195
  );
195
- const configExclusions = configFile.getNpmMinimumPackageAgeExclusions();
196
+ const configExclusions = configFile.getMinimumPackageAgeExclusions();
196
197
 
197
198
  // Merge both sources and remove duplicates
198
199
  const allExclusions = [...envExclusions, ...configExclusions];
199
200
  return [...new Set(allExclusions)];
200
201
  }
202
+
203
+ /**
204
+ * Gets the malware list base URL with priority: CLI argument > environment variable > config file > default
205
+ * @returns {string}
206
+ */
207
+ export function getMalwareListBaseUrl() {
208
+ // Priority 1: CLI argument
209
+ const cliValue = cliArguments.getMalwareListBaseUrl();
210
+ if (cliValue) {
211
+ const url = removeTrailingSlashes(cliValue);
212
+ ui.writeInformation(`Fetching malware lists from ${url} as defined by CLI argument --safe-chain-malware-list-base-url`);
213
+ return url;
214
+ }
215
+
216
+ // Priority 2: Environment variable
217
+ const envValue = environmentVariables.getMalwareListBaseUrl();
218
+ if (envValue) {
219
+ const url = removeTrailingSlashes(envValue);
220
+ ui.writeInformation(`Fetching malware lists from ${url} as defined by environment variable SAFE_CHAIN_MALWARE_LIST_BASE_URL`);
221
+ return url;
222
+ }
223
+
224
+ // Priority 3: Config file
225
+ const configValue = configFile.getMalwareListBaseUrl();
226
+ if (configValue) {
227
+ const url = removeTrailingSlashes(configValue);
228
+ ui.writeInformation(`Fetching malware lists from ${url} as defined by config file (malwareListBaseUrl)`);
229
+ return url;
230
+ }
231
+
232
+ // Default
233
+ const url = removeTrailingSlashes("https://malware-list.aikido.dev");
234
+ ui.writeInformation(`Fetching malware lists from ${url} (default)`);
235
+ return url;
236
+ }
237
+
238
+ /**
239
+ * Removes trailing slashes from a URL-like string.
240
+ * @param {string} value
241
+ * @returns {string}
242
+ */
243
+ function removeTrailingSlashes(value) {
244
+ if (!value || typeof value !== "string") {
245
+ return value;
246
+ }
247
+
248
+ return value.replace(/\/+$/, "");
249
+ }