@aikidosec/safe-chain 1.3.2 → 1.3.4

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.
package/README.md CHANGED
@@ -23,15 +23,16 @@ Aikido Safe Chain supports the following package managers:
23
23
  - 📦 **pip3**
24
24
  - 📦 **uv**
25
25
  - 📦 **poetry**
26
+ - 📦 **pipx**
26
27
 
27
28
  # Usage
28
29
 
30
+ ![Aikido Safe Chain demo](https://raw.githubusercontent.com/AikidoSec/safe-chain/main/docs/safe-package-manager-demo.gif)
31
+
29
32
  ## Installation
30
33
 
31
34
  Installing the Aikido Safe Chain is easy with our one-line installer.
32
35
 
33
- > ⚠️ **Already installed via npm?** See the [migration guide](https://github.com/AikidoSec/safe-chain/blob/main/docs/npm-to-binary-migration.md) to switch to the binary version.
34
-
35
36
  ### Unix/Linux/macOS
36
37
 
37
38
  ```shell
@@ -49,11 +50,13 @@ iex (iwr "https://github.com/AikidoSec/safe-chain/releases/latest/download/insta
49
50
  To install a specific version instead of the latest, replace `latest` with the version number in the URL (available from version 1.3.2 onwards):
50
51
 
51
52
  **Unix/Linux/macOS:**
53
+
52
54
  ```shell
53
55
  curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/x.x.x/install-safe-chain.sh | sh
54
56
  ```
55
57
 
56
58
  **Windows (PowerShell):**
59
+
57
60
  ```powershell
58
61
  iex (iwr "https://github.com/AikidoSec/safe-chain/releases/download/x.x.x/install-safe-chain.ps1" -UseBasicParsing)
59
62
  ```
@@ -64,9 +67,22 @@ You can find all available versions on the [releases page](https://github.com/Ai
64
67
 
65
68
  1. **❗Restart your terminal** to start using the Aikido Safe Chain.
66
69
 
67
- - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, and pip/pip3 are loaded correctly. If you do not restart your terminal, the aliases will not be available.
70
+ - This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, poetry, uv and pipx are loaded correctly. If you do not restart your terminal, the aliases will not be available.
71
+
72
+ 2. **Verify the installation** by running the verification command:
73
+
74
+ ```shell
75
+ npm safe-chain-verify
76
+ pnpm safe-chain-verify
77
+ pip safe-chain-verify
78
+ uv safe-chain-verify
79
+
80
+ # Any other supported package manager: {packagemanager} safe-chain-verify
81
+ ```
68
82
 
69
- 2. **Verify the installation** by running one of the following commands:
83
+ - The output should display "OK: Safe-chain works!" confirming that Aikido Safe Chain is properly installed and running.
84
+
85
+ 3. **(Optional) Test malware blocking** by attempting to install a test package:
70
86
 
71
87
  For JavaScript/Node.js:
72
88
 
@@ -82,7 +98,7 @@ You can find all available versions on the [releases page](https://github.com/Ai
82
98
 
83
99
  - The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware.
84
100
 
85
- When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `uv`, `pip`, `pip3` or `poetry` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command.
101
+ When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry` and `pipx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command.
86
102
 
87
103
  You can check the installed version by running:
88
104
 
@@ -94,17 +110,17 @@ safe-chain --version
94
110
 
95
111
  ### Malware Blocking
96
112
 
97
- 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, uv, pip, pip3 or poetry 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
+ 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.
98
114
 
99
115
  ### Minimum package age (npm only)
100
116
 
101
117
  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.
102
118
 
103
- ⚠️ 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).
119
+ ⚠️ 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).
104
120
 
105
121
  ### Shell Integration
106
122
 
107
- The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and Python package managers (uv, pip). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support:
123
+ The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and Python package managers (pip, uv, poetry, pipx). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support:
108
124
 
109
125
  - ✅ **Bash**
110
126
  - ✅ **Zsh**
@@ -183,6 +199,39 @@ You can set the minimum package age through multiple sources (in order of priori
183
199
  }
184
200
  ```
185
201
 
202
+ ## Custom Registries
203
+
204
+ Configure Safe Chain to scan packages from custom or private registries.
205
+
206
+ Supported ecosystems:
207
+
208
+ - Node.js
209
+ - Python
210
+
211
+ ### Configuration Options
212
+
213
+ You can set custom registries through environment variable or config file. Both sources are merged together.
214
+
215
+ 1. **Environment Variable** (comma-separated):
216
+
217
+ ```shell
218
+ export SAFE_CHAIN_NPM_CUSTOM_REGISTRIES="npm.company.com,registry.internal.net"
219
+ export SAFE_CHAIN_PIP_CUSTOM_REGISTRIES="pip.company.com,registry.internal.net"
220
+ ```
221
+
222
+ 2. **Config File** (`~/.aikido/config.json`):
223
+
224
+ ```json
225
+ {
226
+ "npm": {
227
+ "customRegistries": ["npm.company.com", "registry.internal.net"]
228
+ },
229
+ "pip": {
230
+ "customRegistries": ["pip.company.com", "registry.internal.net"]
231
+ }
232
+ }
233
+ ```
234
+
186
235
  # Usage in CI/CD
187
236
 
188
237
  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.
@@ -207,6 +256,8 @@ iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download
207
256
 
208
257
  - ✅ **GitHub Actions**
209
258
  - ✅ **Azure Pipelines**
259
+ - ✅ **CircleCI**
260
+ - ✅ **Jenkins**
210
261
 
211
262
  ## GitHub Actions Example
212
263
 
@@ -239,4 +290,61 @@ iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download
239
290
  displayName: "Install dependencies"
240
291
  ```
241
292
 
293
+ ## CircleCI Example
294
+
295
+ ```yaml
296
+ version: 2.1
297
+ jobs:
298
+ build:
299
+ docker:
300
+ - image: cimg/node:lts
301
+ steps:
302
+ - checkout
303
+ - run: |
304
+ curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci
305
+ - run: npm ci
306
+ workflows:
307
+ build_and_test:
308
+ jobs:
309
+ - build
310
+ ```
311
+
312
+ ## Jenkins Example
313
+
314
+ Note: This assumes Node.js and npm are installed on the Jenkins agent.
315
+
316
+ ```groovy
317
+ pipeline {
318
+ agent any
319
+
320
+ environment {
321
+ // Jenkins does not automatically persist PATH updates from setup-ci,
322
+ // so add the shims + binary directory explicitly for all stages.
323
+ PATH = "${env.HOME}/.safe-chain/shims:${env.HOME}/.safe-chain/bin:${env.PATH}"
324
+ }
325
+
326
+ stages {
327
+ stage('Install safe-chain') {
328
+ steps {
329
+ sh '''
330
+ set -euo pipefail
331
+
332
+ # Install Safe Chain for CI
333
+ curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
334
+ '''
335
+ }
336
+ }
337
+
338
+ stage('Install project dependencies etc...') {
339
+ steps {
340
+ sh '''
341
+ set -euo pipefail
342
+ npm ci
343
+ '''
344
+ }
345
+ }
346
+ }
347
+ }
348
+ ```
349
+
242
350
  After setup, all subsequent package manager commands in your CI pipeline will automatically be protected by Aikido Safe Chain's malware detection.
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { main } from "../src/main.js";
4
+ import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
5
+ import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js";
6
+
7
+ // Set eco system
8
+ setEcoSystem(ECOSYSTEM_PY);
9
+
10
+ initializePackageManager("pipx");
11
+
12
+ (async () => {
13
+ // Pass through only user-supplied pipx args
14
+ var exitCode = await main(process.argv.slice(2));
15
+ process.exit(exitCode);
16
+ })();
package/bin/safe-chain.js CHANGED
@@ -3,7 +3,10 @@
3
3
  import chalk from "chalk";
4
4
  import { ui } from "../src/environment/userInteraction.js";
5
5
  import { setup } from "../src/shell-integration/setup.js";
6
- import { teardown, teardownDirectories } from "../src/shell-integration/teardown.js";
6
+ import {
7
+ teardown,
8
+ teardownDirectories,
9
+ } from "../src/shell-integration/teardown.js";
7
10
  import { setupCi } from "../src/shell-integration/setup-ci.js";
8
11
  import { initializeCliArguments } from "../src/config/cliArguments.js";
9
12
  import { setEcoSystem } from "../src/config/settings.js";
@@ -45,7 +48,7 @@ if (tool) {
45
48
  const args = process.argv.slice(3);
46
49
 
47
50
  setEcoSystem(tool.ecoSystem);
48
-
51
+
49
52
  // Provide tool context to PM (pip uses this; others ignore)
50
53
  const toolContext = { tool: tool.tool, args };
51
54
  initializePackageManager(tool.internalPackageManagerName, toolContext);
Binary file
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Overview
4
4
 
5
- The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`) with Aikido's security scanning functionality. It also intercepts Python module invocations for pip when available: `python -m pip`, `python -m pip3`, `python3 -m pip`, `python3 -m pip3`. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents.
5
+ The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry`, `pipx`) with Aikido's security scanning functionality. It also intercepts Python module invocations for pip when available: `python -m pip`, `python -m pip3`, `python3 -m pip`, `python3 -m pip3`. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents.
6
6
 
7
7
  ## Supported Shells
8
8
 
@@ -28,7 +28,7 @@ This command:
28
28
 
29
29
  - Copies necessary startup scripts to Safe Chain's installation directory (`~/.safe-chain/scripts`)
30
30
  - Detects all supported shells on your system
31
- - Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, and `pip3`
31
+ - Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry` and `pipx`
32
32
  - Adds lightweight interceptors so `python -m pip[...]` and `python3 -m pip[...]` route through Safe Chain when invoked by name
33
33
 
34
34
  ❗ After running this command, **you must restart your terminal** for the changes to take effect. This ensures that the startup scripts are sourced correctly.
@@ -78,7 +78,7 @@ The system modifies the following files to source Safe Chain startup scripts:
78
78
  This means the shell functions are working but the Aikido commands aren't installed or available in your PATH:
79
79
 
80
80
  - Make sure Aikido Safe Chain is properly installed on your system
81
- - Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-bun`, `aikido-bunx`, `aikido-pip`, and `aikido-pip3` commands exist
81
+ - Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-bun`, `aikido-bunx`, `aikido-pip`, `aikido-pip3`, `aikido-uv`, `aikido-poetry` and `aikido-pipx` commands exist
82
82
  - Check that these commands are in your system's PATH
83
83
 
84
84
  ### Manual Verification
@@ -121,7 +121,7 @@ npm() {
121
121
  }
122
122
  ```
123
123
 
124
- Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, and `pip3` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes.
124
+ Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry` and `pipx` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes.
125
125
 
126
126
  To intercept Python module invocations for pip without altering Python itself, you can add small forwarding functions:
127
127
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aikidosec/safe-chain",
3
- "version": "1.3.2",
3
+ "version": "1.3.4",
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'",
@@ -21,6 +21,7 @@
21
21
  "aikido-python": "bin/aikido-python.js",
22
22
  "aikido-python3": "bin/aikido-python3.js",
23
23
  "aikido-poetry": "bin/aikido-poetry.js",
24
+ "aikido-pipx": "bin/aikido-pipx.js",
24
25
  "safe-chain": "bin/safe-chain.js"
25
26
  },
26
27
  "type": "module",
@@ -7,10 +7,15 @@ import { getEcoSystem } from "./settings.js";
7
7
  /**
8
8
  * @typedef {Object} SafeChainConfig
9
9
  *
10
- * This should be a number, but can be anything because it is user-input.
10
+ * We cannot trust the input and should add the necessary validations
11
+ * @property {unknown | Number} scanTimeout
12
+ * @property {unknown | Number} minimumPackageAgeHours
13
+ * @property {unknown | SafeChainRegistryConfiguration} npm
14
+ * @property {unknown | SafeChainRegistryConfiguration} pip
15
+ *
16
+ * @typedef {Object} SafeChainRegistryConfiguration
11
17
  * We cannot trust the input and should add the necessary validations.
12
- * @property {unknown} scanTimeout
13
- * @property {unknown} minimumPackageAgeHours
18
+ * @property {unknown | string[]} customRegistries
14
19
  */
15
20
 
16
21
  /**
@@ -78,6 +83,50 @@ export function getMinimumPackageAgeHours() {
78
83
  return undefined;
79
84
  }
80
85
 
86
+ /**
87
+ * Gets the custom npm registries from the config file (format parsing only, no validation)
88
+ * @returns {string[]}
89
+ */
90
+ export function getNpmCustomRegistries() {
91
+ const config = readConfigFile();
92
+
93
+ if (!config || !config.npm) {
94
+ return [];
95
+ }
96
+
97
+ // TypeScript needs help understanding that config.npm exists and has customRegistries
98
+ const npmConfig = /** @type {SafeChainRegistryConfiguration} */ (config.npm);
99
+ const customRegistries = npmConfig.customRegistries;
100
+
101
+ if (!Array.isArray(customRegistries)) {
102
+ return [];
103
+ }
104
+
105
+ return customRegistries.filter((item) => typeof item === "string");
106
+ }
107
+
108
+ /**
109
+ * Gets the custom npm registries from the config file (format parsing only, no validation)
110
+ * @returns {string[]}
111
+ */
112
+ export function getPipCustomRegistries() {
113
+ const config = readConfigFile();
114
+
115
+ if (!config || !config.pip) {
116
+ return [];
117
+ }
118
+
119
+ // TypeScript needs help understanding that config.pip exists and has customRegistries
120
+ const pipConfig = /** @type {SafeChainRegistryConfiguration} */ (config.pip);
121
+ const customRegistries = pipConfig.customRegistries;
122
+
123
+ if (!Array.isArray(customRegistries)) {
124
+ return [];
125
+ }
126
+
127
+ return customRegistries.filter((item) => typeof item === "string");
128
+ }
129
+
81
130
  /**
82
131
  * @param {import("../api/aikido.js").MalwarePackage[]} data
83
132
  * @param {string | number} version
@@ -136,23 +185,29 @@ export function readDatabaseFromLocalCache() {
136
185
  * @returns {SafeChainConfig}
137
186
  */
138
187
  function readConfigFile() {
188
+ /** @type {SafeChainConfig} */
189
+ const emptyConfig = {
190
+ scanTimeout: undefined,
191
+ minimumPackageAgeHours: undefined,
192
+ npm: {
193
+ customRegistries: undefined,
194
+ },
195
+ pip: {
196
+ customRegistries: undefined,
197
+ },
198
+ };
199
+
139
200
  const configFilePath = getConfigFilePath();
140
201
 
141
202
  if (!fs.existsSync(configFilePath)) {
142
- return {
143
- scanTimeout: undefined,
144
- minimumPackageAgeHours: undefined,
145
- };
203
+ return emptyConfig;
146
204
  }
147
205
 
148
206
  try {
149
207
  const data = fs.readFileSync(configFilePath, "utf8");
150
208
  return JSON.parse(data);
151
209
  } catch {
152
- return {
153
- scanTimeout: undefined,
154
- minimumPackageAgeHours: undefined,
155
- };
210
+ return emptyConfig;
156
211
  }
157
212
  }
158
213
 
@@ -5,3 +5,23 @@
5
5
  export function getMinimumPackageAgeHours() {
6
6
  return process.env.SAFE_CHAIN_MINIMUM_PACKAGE_AGE_HOURS;
7
7
  }
8
+
9
+ /**
10
+ * Gets the custom npm registries from environment variable
11
+ * Expected format: comma-separated list of registry domains
12
+ * Example: "npm.company.com,registry.internal.net"
13
+ * @returns {string | undefined}
14
+ */
15
+ export function getNpmCustomRegistries() {
16
+ return process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES;
17
+ }
18
+
19
+ /**
20
+ * Gets the custom pip registries from environment variable
21
+ * Expected format: comma-separated list of registry domains
22
+ * Example: "pip.company.com,registry.internal.net"
23
+ * @returns {string | undefined}
24
+ */
25
+ export function getPipCustomRegistries() {
26
+ return process.env.SAFE_CHAIN_PIP_CUSTOM_REGISTRIES;
27
+ }
@@ -98,3 +98,66 @@ export function skipMinimumPackageAge() {
98
98
 
99
99
  return defaultSkipMinimumPackageAge;
100
100
  }
101
+
102
+ /**
103
+ * Normalizes a registry URL by removing protocol if present
104
+ * @param {string} registry
105
+ * @returns {string}
106
+ */
107
+ function normalizeRegistry(registry) {
108
+ // Remove protocol (http://, https://) if present
109
+ return registry.replace(/^https?:\/\//, "");
110
+ }
111
+
112
+ /**
113
+ * Parses comma-separated registries from environment variable
114
+ * @param {string | undefined} envValue
115
+ * @returns {string[]}
116
+ */
117
+ function parseRegistriesFromEnv(envValue) {
118
+ if (!envValue || typeof envValue !== "string") {
119
+ return [];
120
+ }
121
+
122
+ // Split by comma and trim whitespace
123
+ return envValue
124
+ .split(",")
125
+ .map((registry) => registry.trim())
126
+ .filter((registry) => registry.length > 0);
127
+ }
128
+
129
+ /**
130
+ * Gets the custom npm registries from both environment variable and config file (merged)
131
+ * @returns {string[]}
132
+ */
133
+ export function getNpmCustomRegistries() {
134
+ const envRegistries = parseRegistriesFromEnv(
135
+ environmentVariables.getNpmCustomRegistries()
136
+ );
137
+ const configRegistries = configFile.getNpmCustomRegistries();
138
+
139
+ // Merge both sources and remove duplicates
140
+ const allRegistries = [...envRegistries, ...configRegistries];
141
+ const uniqueRegistries = [...new Set(allRegistries)];
142
+
143
+ // Normalize each registry (remove protocol if any)
144
+ return uniqueRegistries.map(normalizeRegistry);
145
+ }
146
+
147
+ /**
148
+ * Gets the custom npm registries from both environment variable and config file (merged)
149
+ * @returns {string[]}
150
+ */
151
+ export function getPipCustomRegistries() {
152
+ const envRegistries = parseRegistriesFromEnv(
153
+ environmentVariables.getPipCustomRegistries()
154
+ );
155
+ const configRegistries = configFile.getPipCustomRegistries();
156
+
157
+ // Merge both sources and remove duplicates
158
+ const allRegistries = [...envRegistries, ...configRegistries];
159
+ const uniqueRegistries = [...new Set(allRegistries)];
160
+
161
+ // Normalize each registry (remove protocol if any)
162
+ return uniqueRegistries.map(normalizeRegistry);
163
+ }
package/src/main.js CHANGED
@@ -13,6 +13,10 @@ import { getAuditStats } from "./scanning/audit/index.js";
13
13
  * @returns {Promise<number>}
14
14
  */
15
15
  export async function main(args) {
16
+ if (isSafeChainVerify(args)) {
17
+ return 0;
18
+ }
19
+
16
20
  process.on("SIGINT", handleProcessTermination);
17
21
  process.on("SIGTERM", handleProcessTermination);
18
22
 
@@ -104,3 +108,12 @@ export async function main(args) {
104
108
  function handleProcessTermination() {
105
109
  ui.writeBufferedLogsAndStopBuffering();
106
110
  }
111
+
112
+ /** @param {string[]} args */
113
+ function isSafeChainVerify(args) {
114
+ const safeChainCheckCommand = "safe-chain-verify";
115
+ if (args.length > 0 && args[0] === safeChainCheckCommand) {
116
+ ui.writeInformation("OK: Safe-chain works!");
117
+ return true;
118
+ }
119
+ }
@@ -12,6 +12,7 @@ import { createYarnPackageManager } from "./yarn/createPackageManager.js";
12
12
  import { createPipPackageManager } from "./pip/createPackageManager.js";
13
13
  import { createUvPackageManager } from "./uv/createUvPackageManager.js";
14
14
  import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js";
15
+ import { createPipXPackageManager } from "./pipx/createPipXPackageManager.js";
15
16
 
16
17
  /**
17
18
  * @type {{packageManagerName: PackageManager | null}}
@@ -61,6 +62,8 @@ export function initializePackageManager(packageManagerName, context) {
61
62
  state.packageManagerName = createUvPackageManager();
62
63
  } else if (packageManagerName === "poetry") {
63
64
  state.packageManagerName = createPoetryPackageManager();
65
+ } else if (packageManagerName === "pipx") {
66
+ state.packageManagerName = createPipXPackageManager();
64
67
  } else {
65
68
  throw new Error("Unsupported package manager: " + packageManagerName);
66
69
  }
@@ -0,0 +1,18 @@
1
+ import { runPipX } from "./runPipXCommand.js";
2
+
3
+ /**
4
+ * @returns {import("../currentPackageManager.js").PackageManager}
5
+ */
6
+ export function createPipXPackageManager() {
7
+ return {
8
+ /**
9
+ * @param {string[]} args
10
+ */
11
+ runCommand: (args) => {
12
+ return runPipX("pipx", args);
13
+ },
14
+ // MITM only
15
+ isSupportedCommand: () => false,
16
+ getDependencyUpdatesForCommand: () => [],
17
+ };
18
+ }
@@ -0,0 +1,65 @@
1
+ import { ui } from "../../environment/userInteraction.js";
2
+ import { safeSpawn } from "../../utils/safeSpawn.js";
3
+ import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
4
+ import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js";
5
+
6
+ /**
7
+ * Sets CA bundle environment variables used by Python libraries and pipx.
8
+ *
9
+ * @param {NodeJS.ProcessEnv} env - Env object
10
+ * @param {string} combinedCaPath - Path to the combined CA bundle
11
+ * @return {NodeJS.ProcessEnv} Modified environment object
12
+ */
13
+ function getPipXCaBundleEnvironmentVariables(env, combinedCaPath) {
14
+ let retVal = { ...env };
15
+
16
+ if (env.SSL_CERT_FILE) {
17
+ ui.writeWarning("Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten.");
18
+ }
19
+ retVal.SSL_CERT_FILE = combinedCaPath;
20
+
21
+ if (env.REQUESTS_CA_BUNDLE) {
22
+ ui.writeWarning("Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten.");
23
+ }
24
+ retVal.REQUESTS_CA_BUNDLE = combinedCaPath;
25
+
26
+ if (env.PIP_CERT) {
27
+ ui.writeWarning("Safe-chain: User defined PIP_CERT found in environment. It will be overwritten.");
28
+ }
29
+ retVal.PIP_CERT = combinedCaPath;
30
+ return retVal;
31
+ }
32
+
33
+ /**
34
+ * Runs a pipx command with safe-chain's certificate bundle and proxy configuration.
35
+ *
36
+ * @param {string} command - The command to execute
37
+ * @param {string[]} args - Command line arguments
38
+ * @returns {Promise<{status: number}>} Exit status of the command
39
+ */
40
+ export async function runPipX(command, args) {
41
+ try {
42
+ const env = mergeSafeChainProxyEnvironmentVariables(process.env);
43
+
44
+ const combinedCaPath = getCombinedCaBundlePath();
45
+ const modifiedEnv = getPipXCaBundleEnvironmentVariables(env, combinedCaPath);
46
+
47
+ // Note: pipx uses HTTPS_PROXY and HTTP_PROXY environment variables for proxy configuration
48
+ // These are already set by mergeSafeChainProxyEnvironmentVariables
49
+
50
+ const result = await safeSpawn(command, args, {
51
+ stdio: "inherit",
52
+ env: modifiedEnv,
53
+ });
54
+
55
+ return { status: result.status };
56
+ } catch (/** @type any */ error) {
57
+ if (error.status) {
58
+ return { status: error.status };
59
+ } else {
60
+ ui.writeError(`Error executing command: ${error.message}`);
61
+ ui.writeError(`Is '${command}' installed and available on your system?`);
62
+ return { status: 1 };
63
+ }
64
+ }
65
+ }
@@ -1,4 +1,7 @@
1
- import { skipMinimumPackageAge } from "../../../config/settings.js";
1
+ import {
2
+ getNpmCustomRegistries,
3
+ skipMinimumPackageAge,
4
+ } from "../../../config/settings.js";
2
5
  import { isMalwarePackage } from "../../../scanning/audit/index.js";
3
6
  import { interceptRequests } from "../interceptorBuilder.js";
4
7
  import {
@@ -8,14 +11,20 @@ import {
8
11
  } from "./modifyNpmInfo.js";
9
12
  import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js";
10
13
 
11
- const knownJsRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"];
14
+ const knownJsRegistries = [
15
+ "registry.npmjs.org",
16
+ "registry.yarnpkg.com",
17
+ "registry.npmjs.com",
18
+ ];
12
19
 
13
20
  /**
14
21
  * @param {string} url
15
22
  * @returns {import("../interceptorBuilder.js").Interceptor | undefined}
16
23
  */
17
24
  export function npmInterceptorForUrl(url) {
18
- const registry = knownJsRegistries.find((reg) => url.includes(reg));
25
+ const registry = [...knownJsRegistries, ...getNpmCustomRegistries()].find(
26
+ (reg) => url.includes(reg)
27
+ );
19
28
 
20
29
  if (registry) {
21
30
  return buildNpmInterceptor(registry);
@@ -1,3 +1,4 @@
1
+ import { getPipCustomRegistries } from "../../config/settings.js";
1
2
  import { isMalwarePackage } from "../../scanning/audit/index.js";
2
3
  import { interceptRequests } from "./interceptorBuilder.js";
3
4
 
@@ -13,7 +14,9 @@ const knownPipRegistries = [
13
14
  * @returns {import("./interceptorBuilder.js").Interceptor | undefined}
14
15
  */
15
16
  export function pipInterceptorForUrl(url) {
16
- const registry = knownPipRegistries.find((reg) => url.includes(reg));
17
+ const customRegistries = getPipCustomRegistries();
18
+ const registries = [...knownPipRegistries, ...customRegistries];
19
+ const registry = registries.find((reg) => url.includes(reg));
17
20
 
18
21
  if (registry) {
19
22
  return buildPipInterceptor(registry);
@@ -37,8 +40,8 @@ function buildPipInterceptor(registry) {
37
40
  // Per python, packages that differ only by hyphen vs underscore are considered the same.
38
41
  const hyphenName = packageName?.includes("_") ? packageName.replace(/_/g, "-") : packageName;
39
42
 
40
- const isMalicious =
41
- await isMalwarePackage(packageName, version)
43
+ const isMalicious =
44
+ await isMalwarePackage(packageName, version)
42
45
  || await isMalwarePackage(hyphenName, version);
43
46
 
44
47
  if (isMalicious) {
@@ -15,7 +15,7 @@ import { gunzipSync, gzipSync } from "zlib";
15
15
  */
16
16
  export function mitmConnect(req, clientSocket, interceptor) {
17
17
  ui.writeVerbose(`Safe-chain: Set up MITM tunnel for ${req.url}`);
18
- const { hostname } = new URL(`http://${req.url}`);
18
+ const { hostname, port } = new URL(`http://${req.url}`);
19
19
 
20
20
  clientSocket.on("error", (err) => {
21
21
  ui.writeVerbose(
@@ -26,7 +26,7 @@ export function mitmConnect(req, clientSocket, interceptor) {
26
26
  // Not subscribing to 'close' event will cause node to throw and crash.
27
27
  });
28
28
 
29
- const server = createHttpsServer(hostname, interceptor);
29
+ const server = createHttpsServer(hostname, port, interceptor);
30
30
 
31
31
  server.on("error", (err) => {
32
32
  ui.writeError(`Safe-chain: HTTPS server error: ${err.message}`);
@@ -46,10 +46,11 @@ export function mitmConnect(req, clientSocket, interceptor) {
46
46
 
47
47
  /**
48
48
  * @param {string} hostname
49
+ * @param {string} port
49
50
  * @param {Interceptor} interceptor
50
51
  * @returns {import("https").Server}
51
52
  */
52
- function createHttpsServer(hostname, interceptor) {
53
+ function createHttpsServer(hostname, port, interceptor) {
53
54
  const cert = generateCertForHost(hostname);
54
55
 
55
56
  /**
@@ -80,7 +81,7 @@ function createHttpsServer(hostname, interceptor) {
80
81
  }
81
82
 
82
83
  // Collect request body
83
- forwardRequest(req, hostname, res, requestInterceptor);
84
+ forwardRequest(req, hostname, port, res, requestInterceptor);
84
85
  }
85
86
 
86
87
  const server = https.createServer(
@@ -109,11 +110,12 @@ function getRequestPathAndQuery(url) {
109
110
  /**
110
111
  * @param {import("http").IncomingMessage} req
111
112
  * @param {string} hostname
113
+ * @param {string} port
112
114
  * @param {import("http").ServerResponse} res
113
115
  * @param {import("./interceptors/interceptorBuilder.js").RequestInterceptionHandler} requestHandler
114
116
  */
115
- function forwardRequest(req, hostname, res, requestHandler) {
116
- const proxyReq = createProxyRequest(hostname, req, res, requestHandler);
117
+ function forwardRequest(req, hostname, port, res, requestHandler) {
118
+ const proxyReq = createProxyRequest(hostname, port, req, res, requestHandler);
117
119
 
118
120
  proxyReq.on("error", (err) => {
119
121
  ui.writeVerbose(
@@ -144,13 +146,14 @@ function forwardRequest(req, hostname, res, requestHandler) {
144
146
 
145
147
  /**
146
148
  * @param {string} hostname
149
+ * @param {string} port
147
150
  * @param {import("http").IncomingMessage} req
148
151
  * @param {import("http").ServerResponse} res
149
152
  * @param {import("./interceptors/interceptorBuilder.js").RequestInterceptionHandler} requestHandler
150
153
  *
151
154
  * @returns {import("http").ClientRequest}
152
155
  */
153
- function createProxyRequest(hostname, req, res, requestHandler) {
156
+ function createProxyRequest(hostname, port, req, res, requestHandler) {
154
157
  /** @type {NodeJS.Dict<string | string[]> | undefined} */
155
158
  let headers = { ...req.headers };
156
159
  // Remove the host header from the incoming request before forwarding.
@@ -163,7 +166,7 @@ function createProxyRequest(hostname, req, res, requestHandler) {
163
166
  /** @type {import("http").RequestOptions} */
164
167
  const options = {
165
168
  hostname: hostname,
166
- port: 443,
169
+ port: port || 443,
167
170
  path: req.url,
168
171
  method: req.method,
169
172
  headers: { ...headers },
@@ -43,6 +43,7 @@ export function tunnelRequest(req, clientSocket, head) {
43
43
  function tunnelRequestToDestination(req, clientSocket, head) {
44
44
  const { port, hostname } = new URL(`http://${req.url}`);
45
45
  const isImds = isImdsEndpoint(hostname);
46
+ const targetPort = Number.parseInt(port) || 443;
46
47
 
47
48
  if (timedoutImdsEndpoints.includes(hostname)) {
48
49
  clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
@@ -58,64 +59,77 @@ function tunnelRequestToDestination(req, clientSocket, head) {
58
59
  return;
59
60
  }
60
61
 
61
- const serverSocket = net.connect(
62
- Number.parseInt(port) || 443,
63
- hostname,
64
- () => {
65
- clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
66
- serverSocket.write(head);
67
- serverSocket.pipe(clientSocket);
68
- clientSocket.pipe(serverSocket);
69
- }
70
- );
71
-
72
- // Set explicit connection timeout to avoid waiting for OS default (~2 minutes).
73
- // IMDS endpoints get shorter timeout (3s) since they're commonly unreachable outside cloud environments.
74
62
  const connectTimeout = getConnectTimeout(hostname);
75
- serverSocket.setTimeout(connectTimeout);
76
63
 
77
- serverSocket.on("timeout", () => {
78
- // Suppress error logging for IMDS endpoints - timeouts are expected when not in cloud
64
+ // Use JS setTimeout for true connection timeout (not idle timeout).
65
+ // socket.setTimeout() measures inactivity, not time since connection attempt.
66
+ const connectTimer = setTimeout(() => {
79
67
  if (isImds) {
80
68
  timedoutImdsEndpoints.push(hostname);
81
69
  ui.writeVerbose(
82
- `Safe-chain: connect to ${hostname}:${
83
- port || 443
84
- } timed out after ${connectTimeout}ms`
70
+ `Safe-chain: connect to ${hostname}:${targetPort} timed out after ${connectTimeout}ms`
85
71
  );
86
72
  } else {
87
73
  ui.writeError(
88
- `Safe-chain: connect to ${hostname}:${
89
- port || 443
90
- } timed out after ${connectTimeout}ms`
74
+ `Safe-chain: connect to ${hostname}:${targetPort} timed out after ${connectTimeout}ms`
91
75
  );
92
76
  }
93
- serverSocket.destroy(); // Clean up socket to prevent event loop hanging
94
- clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
77
+ serverSocket.destroy();
78
+ if (clientSocket.writable) {
79
+ clientSocket.end("HTTP/1.1 504 Gateway Timeout\r\n\r\n");
80
+ }
81
+ }, connectTimeout);
82
+
83
+ const serverSocket = net.connect(targetPort, hostname, () => {
84
+ // Clear timer to prevent false timeout errors after successful connection
85
+ clearTimeout(connectTimer);
86
+
87
+ clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
88
+ serverSocket.write(head);
89
+ serverSocket.pipe(clientSocket);
90
+ clientSocket.pipe(serverSocket);
95
91
  });
96
92
 
97
93
  clientSocket.on("error", () => {
98
94
  // This can happen if the client TCP socket sends RST instead of FIN.
99
95
  // Not subscribing to 'error' event will cause node to throw and crash.
96
+ clearTimeout(connectTimer);
97
+ if (serverSocket.writable) {
98
+ serverSocket.end();
99
+ }
100
+ });
101
+
102
+ clientSocket.on("close", () => {
103
+ // Client closed connection - clean up server socket
104
+ clearTimeout(connectTimer);
100
105
  if (serverSocket.writable) {
101
106
  serverSocket.end();
102
107
  }
103
108
  });
104
109
 
105
110
  serverSocket.on("error", (err) => {
111
+ clearTimeout(connectTimer);
106
112
  if (isImds) {
107
113
  ui.writeVerbose(
108
- `Safe-chain: error connecting to ${hostname}:${port} - ${err.message}`
114
+ `Safe-chain: error connecting to ${hostname}:${targetPort} - ${err.message}`
109
115
  );
110
116
  } else {
111
117
  ui.writeError(
112
- `Safe-chain: error connecting to ${hostname}:${port} - ${err.message}`
118
+ `Safe-chain: error connecting to ${hostname}:${targetPort} - ${err.message}`
113
119
  );
114
120
  }
115
121
  if (clientSocket.writable) {
116
122
  clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
117
123
  }
118
124
  });
125
+
126
+ serverSocket.on("close", () => {
127
+ // Server closed connection - clean up client socket
128
+ clearTimeout(connectTimer);
129
+ if (clientSocket.writable) {
130
+ clientSocket.end();
131
+ }
132
+ });
119
133
  }
120
134
 
121
135
  /**
@@ -94,6 +94,12 @@ export const knownAikidoTools = [
94
94
  ecoSystem: ECOSYSTEM_PY,
95
95
  internalPackageManagerName: "pip",
96
96
  },
97
+ {
98
+ tool: "pipx",
99
+ aikidoCommand: "aikido-pipx",
100
+ ecoSystem: ECOSYSTEM_PY,
101
+ internalPackageManagerName: "pipx",
102
+ }
97
103
  // When adding a new tool here, also update the documentation for the new tool in the README.md
98
104
  ];
99
105
 
@@ -157,6 +157,14 @@ function modifyPathForCi(shimsDir, binDir) {
157
157
  ui.writeInformation("##vso[task.prependpath]" + shimsDir);
158
158
  ui.writeInformation("##vso[task.prependpath]" + binDir);
159
159
  }
160
+
161
+ if (process.env.BASH_ENV) {
162
+ // In CircleCI, persisting PATH across steps is done by appending shell exports
163
+ // to the file referenced by BASH_ENV. CircleCI sources this file for 'run' each step.
164
+ const exportLine = `export PATH="${shimsDir}:${binDir}:$PATH"` + os.EOL;
165
+ fs.appendFileSync(process.env.BASH_ENV, exportLine, "utf-8");
166
+ ui.writeInformation(`Added shims directory to BASH_ENV for CircleCI.`);
167
+ }
160
168
  }
161
169
 
162
170
  function getToolsToSetup() {
@@ -39,7 +39,6 @@ function npm
39
39
  wrapSafeChainCommand "npm" $argv
40
40
  end
41
41
 
42
-
43
42
  function pip
44
43
  wrapSafeChainCommand "pip" $argv
45
44
  end
@@ -66,6 +65,10 @@ function python3
66
65
  wrapSafeChainCommand "python3" $argv
67
66
  end
68
67
 
68
+ function pipx
69
+ wrapSafeChainCommand "pipx" $argv
70
+ end
71
+
69
72
  function printSafeChainWarning
70
73
  set original_cmd $argv[1]
71
74
 
@@ -35,7 +35,6 @@ function npm() {
35
35
  wrapSafeChainCommand "npm" "$@"
36
36
  }
37
37
 
38
-
39
38
  function pip() {
40
39
  wrapSafeChainCommand "pip" "$@"
41
40
  }
@@ -62,6 +61,10 @@ function python3() {
62
61
  wrapSafeChainCommand "python3" "$@"
63
62
  }
64
63
 
64
+ function pipx() {
65
+ wrapSafeChainCommand "pipx" "$@"
66
+ }
67
+
65
68
  function printSafeChainWarning() {
66
69
  # \033[43;30m is used to set the background color to yellow and text color to black
67
70
  # \033[0m is used to reset the text formatting
@@ -66,6 +66,9 @@ function python3 {
66
66
  Invoke-WrappedCommand 'python3' $args
67
67
  }
68
68
 
69
+ function pipx {
70
+ Invoke-WrappedCommand "pipx" $args
71
+ }
69
72
 
70
73
  function Write-SafeChainWarning {
71
74
  param([string]$Command)