@aikidosec/safe-chain 1.2.2 β†’ 1.3.0

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 (28) hide show
  1. package/README.md +24 -55
  2. package/bin/aikido-poetry.js +13 -0
  3. package/bin/safe-chain.js +2 -11
  4. package/package.json +2 -1
  5. package/src/config/cliArguments.js +1 -17
  6. package/src/config/configFile.js +1 -1
  7. package/src/config/settings.js +1 -1
  8. package/src/main.js +4 -2
  9. package/src/packagemanager/currentPackageManager.js +3 -0
  10. package/src/packagemanager/pip/runPipCommand.js +6 -3
  11. package/src/packagemanager/poetry/createPoetryPackageManager.js +77 -0
  12. package/src/registryProxy/certBundle.js +99 -13
  13. package/src/registryProxy/certUtils.js +55 -5
  14. package/src/registryProxy/getConnectTimeout.js +13 -0
  15. package/src/registryProxy/interceptors/interceptorBuilder.js +6 -0
  16. package/src/registryProxy/interceptors/pipInterceptor.js +23 -9
  17. package/src/registryProxy/registryProxy.js +15 -7
  18. package/src/registryProxy/tunnelRequestHandler.js +4 -14
  19. package/src/shell-integration/helpers.js +20 -0
  20. package/src/shell-integration/setup-ci.js +3 -9
  21. package/src/shell-integration/setup.js +4 -6
  22. package/src/shell-integration/startup-scripts/init-fish.fish +27 -0
  23. package/src/shell-integration/startup-scripts/init-posix.sh +27 -0
  24. package/src/shell-integration/startup-scripts/init-pwsh.ps1 +30 -1
  25. package/src/shell-integration/teardown.js +43 -1
  26. package/src/shell-integration/startup-scripts/include-python/init-fish.fish +0 -94
  27. package/src/shell-integration/startup-scripts/include-python/init-posix.sh +0 -81
  28. package/src/shell-integration/startup-scripts/include-python/init-pwsh.ps1 +0 -115
package/README.md CHANGED
@@ -19,9 +19,10 @@ Aikido Safe Chain supports the following package managers:
19
19
  - πŸ“¦ **pnpx**
20
20
  - πŸ“¦ **bun**
21
21
  - πŸ“¦ **bunx**
22
- - πŸ“¦ **pip** (beta)
23
- - πŸ“¦ **pip3** (beta)
24
- - πŸ“¦ **uv** (beta)
22
+ - πŸ“¦ **pip**
23
+ - πŸ“¦ **pip3**
24
+ - πŸ“¦ **uv**
25
+ - πŸ“¦ **poetry**
25
26
 
26
27
  # Usage
27
28
 
@@ -33,32 +34,16 @@ Installing the Aikido Safe Chain is easy with our one-line installer.
33
34
 
34
35
  ### Unix/Linux/macOS
35
36
 
36
- **Default installation (JavaScript packages only):**
37
-
38
37
  ```shell
39
38
  curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh
40
39
  ```
41
40
 
42
- **Include Python support (pip/pip3/uv):**
43
-
44
- ```shell
45
- curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --include-python
46
- ```
47
-
48
41
  ### Windows (PowerShell)
49
42
 
50
- **Default installation (JavaScript packages only):**
51
-
52
43
  ```powershell
53
44
  iex (iwr "https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.ps1" -UseBasicParsing)
54
45
  ```
55
46
 
56
- **Include Python support (pip/pip3/uv):**
57
-
58
- ```powershell
59
- iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.ps1' -UseBasicParsing) } -includepython"
60
- ```
61
-
62
47
  ### Verify the installation
63
48
 
64
49
  1. **❗Restart your terminal** to start using the Aikido Safe Chain.
@@ -73,7 +58,7 @@ iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/inst
73
58
  npm install safe-chain-test
74
59
  ```
75
60
 
76
- For Python (if you enabled Python support):
61
+ For Python:
77
62
 
78
63
  ```shell
79
64
  pip3 install safe-chain-pi-test
@@ -81,7 +66,7 @@ iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/inst
81
66
 
82
67
  - The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware.
83
68
 
84
- When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `uv`, `pip`, or `pip3` 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.
69
+ 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.
85
70
 
86
71
  You can check the installed version by running:
87
72
 
@@ -93,13 +78,13 @@ safe-chain --version
93
78
 
94
79
  ### Malware Blocking
95
80
 
96
- 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`, or `pip3` 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.
81
+ 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.
97
82
 
98
83
  ### Minimum package age (npm only)
99
84
 
100
85
  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.
101
86
 
102
- ⚠️ 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).
87
+ ⚠️ 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).
103
88
 
104
89
  ### Shell Integration
105
90
 
@@ -115,17 +100,21 @@ More information about the shell integration can be found in the [shell integrat
115
100
 
116
101
  ## Uninstallation
117
102
 
118
- To uninstall the Aikido Safe Chain, you can run the following command:
103
+ To uninstall the Aikido Safe Chain, use our one-line uninstaller:
119
104
 
120
- 1. **Remove all aliases from your shell** by running:
121
- ```shell
122
- safe-chain teardown
123
- ```
124
- 2. **Uninstall the Aikido Safe Chain package** using npm:
125
- ```shell
126
- npm uninstall -g @aikidosec/safe-chain
127
- ```
128
- 3. **❗Restart your terminal** to remove the aliases.
105
+ ### Unix/Linux/macOS
106
+
107
+ ```shell
108
+ curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/uninstall-safe-chain.sh | sh
109
+ ```
110
+
111
+ ### Windows (PowerShell)
112
+
113
+ ```powershell
114
+ iex (iwr "https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/uninstall-safe-chain.ps1" -UseBasicParsing)
115
+ ```
116
+
117
+ **❗Restart your terminal** after uninstalling to ensure all aliases are removed.
129
118
 
130
119
  # Configuration
131
120
 
@@ -188,32 +177,16 @@ Use the `--ci` flag to automatically configure Aikido Safe Chain for CI/CD envir
188
177
 
189
178
  ### Unix/Linux/macOS (GitHub Actions, Azure Pipelines, etc.)
190
179
 
191
- **JavaScript only:**
192
-
193
180
  ```shell
194
181
  curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci
195
182
  ```
196
183
 
197
- **With Python support:**
198
-
199
- ```shell
200
- curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci --include-python
201
- ```
202
-
203
184
  ### Windows (Azure Pipelines, etc.)
204
185
 
205
- **JavaScript only:**
206
-
207
186
  ```powershell
208
187
  iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.ps1' -UseBasicParsing) } -ci"
209
188
  ```
210
189
 
211
- **With Python support:**
212
-
213
- ```powershell
214
- iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.ps1' -UseBasicParsing) } -ci -includepython"
215
- ```
216
-
217
190
  ## Supported Platforms
218
191
 
219
192
  - βœ… **GitHub Actions**
@@ -229,14 +202,12 @@ iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/inst
229
202
  cache: "npm"
230
203
 
231
204
  - name: Install safe-chain
232
- run: curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci --include-python
205
+ run: curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci
233
206
 
234
207
  - name: Install dependencies
235
208
  run: npm ci
236
209
  ```
237
210
 
238
- > **Note:** Remove `--include-python` if you don't need Python (pip/pip3/uv) support.
239
-
240
211
  ## Azure DevOps Example
241
212
 
242
213
  ```yaml
@@ -245,13 +216,11 @@ iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/inst
245
216
  versionSpec: "22.x"
246
217
  displayName: "Install Node.js"
247
218
 
248
- - script: curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci --include-python
219
+ - script: curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci
249
220
  displayName: "Install safe-chain"
250
221
 
251
222
  - script: npm ci
252
223
  displayName: "Install dependencies"
253
224
  ```
254
225
 
255
- > **Note:** Remove `--include-python` if you don't need Python (pip/pip3/uv) support.
256
-
257
226
  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,13 @@
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
+ setEcoSystem(ECOSYSTEM_PY);
8
+ initializePackageManager("poetry");
9
+
10
+ (async () => {
11
+ var exitCode = await main(process.argv.slice(2));
12
+ process.exit(exitCode);
13
+ })();
package/bin/safe-chain.js CHANGED
@@ -3,7 +3,7 @@
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 } from "../src/shell-integration/teardown.js";
6
+ import { teardown, teardownDirectories } from "../src/shell-integration/teardown.js";
7
7
  import { setupCi } from "../src/shell-integration/setup-ci.js";
8
8
  import { initializeCliArguments } from "../src/config/cliArguments.js";
9
9
  import { setEcoSystem } from "../src/config/settings.js";
@@ -60,6 +60,7 @@ if (tool) {
60
60
  } else if (command === "setup") {
61
61
  setup();
62
62
  } else if (command === "teardown") {
63
+ teardownDirectories();
63
64
  teardown();
64
65
  } else if (command === "setup-ci") {
65
66
  setupCi();
@@ -94,11 +95,6 @@ function writeHelp() {
94
95
  "safe-chain setup"
95
96
  )}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, bun, bunx, pip and pip3.`
96
97
  );
97
- ui.writeInformation(
98
- ` ${chalk.yellow(
99
- "--include-python"
100
- )}: Experimental: include Python package managers (pip, pip3) in the setup.`
101
- );
102
98
  ui.writeInformation(
103
99
  `- ${chalk.cyan(
104
100
  "safe-chain teardown"
@@ -109,11 +105,6 @@ function writeHelp() {
109
105
  "safe-chain setup-ci"
110
106
  )}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.`
111
107
  );
112
- ui.writeInformation(
113
- ` ${chalk.yellow(
114
- "--include-python"
115
- )}: Experimental: include Python package managers (pip, pip3) in the setup.`
116
- );
117
108
  ui.writeInformation(
118
109
  `- ${chalk.cyan("safe-chain --version")} (or ${chalk.cyan(
119
110
  "-v"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aikidosec/safe-chain",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
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'",
@@ -20,6 +20,7 @@
20
20
  "aikido-pip3": "bin/aikido-pip3.js",
21
21
  "aikido-python": "bin/aikido-python.js",
22
22
  "aikido-python3": "bin/aikido-python3.js",
23
+ "aikido-poetry": "bin/aikido-poetry.js",
23
24
  "safe-chain": "bin/safe-chain.js"
24
25
  },
25
26
  "type": "module",
@@ -1,11 +1,10 @@
1
1
  /**
2
- * @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, minimumPackageAgeHours: string | undefined, includePython: boolean}}
2
+ * @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, minimumPackageAgeHours: string | undefined}}
3
3
  */
4
4
  const state = {
5
5
  loggingLevel: undefined,
6
6
  skipMinimumPackageAge: undefined,
7
7
  minimumPackageAgeHours: undefined,
8
- includePython: false,
9
8
  };
10
9
 
11
10
  const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-";
@@ -34,7 +33,6 @@ export function initializeCliArguments(args) {
34
33
  setLoggingLevel(safeChainArgs);
35
34
  setSkipMinimumPackageAge(safeChainArgs);
36
35
  setMinimumPackageAgeHours(safeChainArgs);
37
- setIncludePython(args);
38
36
 
39
37
  return remainingArgs;
40
38
  }
@@ -109,20 +107,6 @@ export function getMinimumPackageAgeHours() {
109
107
  return state.minimumPackageAgeHours;
110
108
  }
111
109
 
112
- /**
113
- * @param {string[]} args
114
- */
115
- function setIncludePython(args) {
116
- // This flag doesn't have the --safe-chain- prefix because
117
- // it is only used for the safe-chain command itself and
118
- // not when wrapped around package manager commands.
119
- state.includePython = hasFlagArg(args, "--include-python");
120
- }
121
-
122
- export function includePython() {
123
- return state.includePython;
124
- }
125
-
126
110
  /**
127
111
  * @param {string[]} args
128
112
  * @param {string} flagName
@@ -67,7 +67,7 @@ function validateMinimumPackageAgeHours(value) {
67
67
  */
68
68
  export function getMinimumPackageAgeHours() {
69
69
  const config = readConfigFile();
70
- if (config.minimumPackageAgeHours) {
70
+ if (config.minimumPackageAgeHours !== undefined) {
71
71
  const validated = validateMinimumPackageAgeHours(
72
72
  config.minimumPackageAgeHours
73
73
  );
@@ -81,7 +81,7 @@ function validateMinimumPackageAgeHours(value) {
81
81
  return undefined;
82
82
  }
83
83
 
84
- if (numericValue > 0) {
84
+ if (numericValue >= 0) {
85
85
  return numericValue;
86
86
  }
87
87
 
package/src/main.js CHANGED
@@ -23,6 +23,7 @@ export async function main(args) {
23
23
  process.on("uncaughtException", (error) => {
24
24
  ui.writeError(`Safe-chain: Uncaught exception: ${error.message}`);
25
25
  ui.writeVerbose(`Stack trace: ${error.stack}`);
26
+ ui.writeBufferedLogsAndStopBuffering();
26
27
  process.exit(1);
27
28
  });
28
29
 
@@ -31,6 +32,7 @@ export async function main(args) {
31
32
  if (reason instanceof Error) {
32
33
  ui.writeVerbose(`Stack trace: ${reason.stack}`);
33
34
  }
35
+ ui.writeBufferedLogsAndStopBuffering();
34
36
  process.exit(1);
35
37
  });
36
38
 
@@ -64,8 +66,7 @@ export async function main(args) {
64
66
 
65
67
  const auditStats = getAuditStats();
66
68
  if (auditStats.totalPackages > 0) {
67
- ui.emptyLine();
68
- ui.writeInformation(
69
+ ui.writeVerbose(
69
70
  `${chalk.green("βœ”")} Safe-chain: Scanned ${
70
71
  auditStats.totalPackages
71
72
  } packages, no malware found.`
@@ -90,6 +91,7 @@ export async function main(args) {
90
91
  return packageManagerResult.status;
91
92
  } catch (/** @type any */ error) {
92
93
  ui.writeError("Failed to check for malicious packages:", error.message);
94
+ ui.writeBufferedLogsAndStopBuffering();
93
95
 
94
96
  // Returning the exit code back to the caller allows the promise
95
97
  // to be awaited in the bin files and return the correct exit code
@@ -11,6 +11,7 @@ import {
11
11
  import { createYarnPackageManager } from "./yarn/createPackageManager.js";
12
12
  import { createPipPackageManager } from "./pip/createPackageManager.js";
13
13
  import { createUvPackageManager } from "./uv/createUvPackageManager.js";
14
+ import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js";
14
15
 
15
16
  /**
16
17
  * @type {{packageManagerName: PackageManager | null}}
@@ -58,6 +59,8 @@ export function initializePackageManager(packageManagerName, context) {
58
59
  state.packageManagerName = createPipPackageManager(context);
59
60
  } else if (packageManagerName === "uv") {
60
61
  state.packageManagerName = createUvPackageManager();
62
+ } else if (packageManagerName === "poetry") {
63
+ state.packageManagerName = createPoetryPackageManager();
61
64
  } else {
62
65
  throw new Error("Unsupported package manager: " + packageManagerName);
63
66
  }
@@ -8,6 +8,7 @@ import fsSync from "node:fs";
8
8
  import os from "node:os";
9
9
  import path from "node:path";
10
10
  import ini from "ini";
11
+ import { spawn } from "child_process";
11
12
 
12
13
  /**
13
14
  * Checks if this pip invocation should bypass safe-chain and spawn directly.
@@ -16,7 +17,7 @@ import ini from "ini";
16
17
  * @param {string[]} args - The arguments
17
18
  * @returns {boolean}
18
19
  */
19
- function shouldBypassSafeChain(command, args) {
20
+ export function shouldBypassSafeChain(command, args) {
20
21
  if (command === PYTHON_COMMAND || command === PYTHON3_COMMAND) {
21
22
  // Check if args start with -m pip
22
23
  if (args.length >= 2 && args[0] === "-m" && (args[1] === PIP_COMMAND || args[1] === PIP3_COMMAND)) {
@@ -77,14 +78,16 @@ export async function runPip(command, args) {
77
78
  if (shouldBypassSafeChain(command, args)) {
78
79
  ui.writeVerbose(`Safe-chain: Bypassing safe-chain for non-pip invocation: ${command} ${args.join(" ")}`);
79
80
  // Spawn the ORIGINAL command with ORIGINAL args
80
- const { spawn } = await import("child_process");
81
81
  return new Promise((_resolve) => {
82
82
  const proc = spawn(command, args, { stdio: "inherit" });
83
83
  proc.on("exit", (/** @type {number | null} */ code) => {
84
+ ui.writeVerbose(`${command} ${args.join(" ")} exited with status ${code}`);
85
+ ui.writeBufferedLogsAndStopBuffering();
84
86
  process.exit(code ?? 0);
85
87
  });
86
88
  proc.on("error", (/** @type {Error} */ err) => {
87
89
  ui.writeError(`Error executing command: ${err.message}`);
90
+ ui.writeBufferedLogsAndStopBuffering();
88
91
  process.exit(1);
89
92
  });
90
93
  });
@@ -93,7 +96,7 @@ export async function runPip(command, args) {
93
96
  try {
94
97
  const env = mergeSafeChainProxyEnvironmentVariables(process.env);
95
98
 
96
- // Always provide Python with a complete CA bundle (Safe Chain CA + Mozilla + Node built-in roots)
99
+ // Always provide Python with a complete CA bundle (Safe Chain CA + Mozilla + Node built-in roots + user certs)
97
100
  // so that any network request made by pip, including those outside explicit CLI args,
98
101
  // validates correctly under both MITM'd and tunneled HTTPS.
99
102
  const combinedCaPath = getCombinedCaBundlePath();
@@ -0,0 +1,77 @@
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
+ * @returns {import("../currentPackageManager.js").PackageManager}
8
+ */
9
+ export function createPoetryPackageManager() {
10
+ return {
11
+ runCommand: (args) => runPoetryCommand(args),
12
+
13
+ // MITM only approach for Poetry
14
+ isSupportedCommand: () => false,
15
+ getDependencyUpdatesForCommand: () => [],
16
+ };
17
+ }
18
+
19
+ /**
20
+ * Sets CA bundle environment variables used by Poetry and Python libraries.
21
+ * Poetry uses the Python requests library which respects these environment variables.
22
+ *
23
+ * @param {NodeJS.ProcessEnv} env - Environment object to modify
24
+ * @param {string} combinedCaPath - Path to the combined CA bundle
25
+ */
26
+ function setPoetryCaBundleEnvironmentVariables(env, combinedCaPath) {
27
+ // SSL_CERT_FILE: Used by Python SSL libraries and requests
28
+ if (env.SSL_CERT_FILE) {
29
+ ui.writeWarning("Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten.");
30
+ }
31
+ env.SSL_CERT_FILE = combinedCaPath;
32
+
33
+ // REQUESTS_CA_BUNDLE: Used by the requests library (which Poetry uses)
34
+ if (env.REQUESTS_CA_BUNDLE) {
35
+ ui.writeWarning("Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten.");
36
+ }
37
+ env.REQUESTS_CA_BUNDLE = combinedCaPath;
38
+
39
+ // PIP_CERT: Poetry may use pip internally
40
+ if (env.PIP_CERT) {
41
+ ui.writeWarning("Safe-chain: User defined PIP_CERT found in environment. It will be overwritten.");
42
+ }
43
+ env.PIP_CERT = combinedCaPath;
44
+ }
45
+
46
+ /**
47
+ * Runs a poetry command with safe-chain's certificate bundle and proxy configuration.
48
+ *
49
+ * Poetry respects standard HTTP_PROXY/HTTPS_PROXY environment variables through
50
+ * the Python requests library.
51
+ *
52
+ * @param {string[]} args - Command line arguments to pass to poetry
53
+ * @returns {Promise<{status: number}>} Exit status of the poetry command
54
+ */
55
+ async function runPoetryCommand(args) {
56
+ try {
57
+ const env = mergeSafeChainProxyEnvironmentVariables(process.env);
58
+
59
+ const combinedCaPath = getCombinedCaBundlePath();
60
+ setPoetryCaBundleEnvironmentVariables(env, combinedCaPath);
61
+
62
+ const result = await safeSpawn("poetry", args, {
63
+ stdio: "inherit",
64
+ env,
65
+ });
66
+
67
+ return { status: result.status };
68
+ } catch (/** @type any */ error) {
69
+ if (error.status) {
70
+ return { status: error.status };
71
+ } else {
72
+ ui.writeError("Error executing command:", error.message);
73
+ ui.writeError("Is 'poetry' installed and available on your system?");
74
+ return { status: 1 };
75
+ }
76
+ }
77
+ }
@@ -6,6 +6,7 @@ import certifi from "certifi";
6
6
  import tls from "node:tls";
7
7
  import { X509Certificate } from "node:crypto";
8
8
  import { getCaCertPath } from "./certUtils.js";
9
+ import { ui } from "../environment/userInteraction.js";
9
10
 
10
11
  /**
11
12
  * Check if a PEM string contains only parsable cert blocks.
@@ -14,6 +15,7 @@ import { getCaCertPath } from "./certUtils.js";
14
15
  */
15
16
  function isParsable(pem) {
16
17
  if (!pem || typeof pem !== "string") return false;
18
+ pem = normalizeLineEndings(pem);
17
19
  const begin = "-----BEGIN CERTIFICATE-----";
18
20
  const end = "-----END CERTIFICATE-----";
19
21
  const blocks = [];
@@ -41,20 +43,17 @@ function isParsable(pem) {
41
43
  }
42
44
  }
43
45
 
44
- /** @type {string | null} */
45
- let cachedPath = null;
46
-
47
46
  /**
48
- * Build a combined CA bundle for Python and Node HTTPS flows.
49
- * - Includes Safe Chain CA (for MITM of known registries)
50
- * - Includes Mozilla roots via npm `certifi` (public HTTPS)
51
- * - Includes Node's built-in root certificates as a portable fallback
47
+ * Build a combined CA bundle.
48
+ * Automatically includes:
49
+ * - Safe Chain CA (for MITM of known registries)
50
+ * - Mozilla roots via certifi (for public HTTPS)
51
+ * - Node's built-in root certificates (fallback)
52
+ * - User's custom certificates (if NODE_EXTRA_CA_CERTS environment variable is set)
53
+ *
52
54
  * @returns {string} Path to the combined CA bundle PEM file
53
55
  */
54
56
  export function getCombinedCaBundlePath() {
55
- if (cachedPath && fs.existsSync(cachedPath)) return cachedPath;
56
-
57
- // Concatenate PEM files
58
57
  const parts = [];
59
58
 
60
59
  // 1) Safe Chain CA (for MITM'd registries)
@@ -87,9 +86,96 @@ export function getCombinedCaBundlePath() {
87
86
  // Ignore if unavailable
88
87
  }
89
88
 
89
+ // 4) User's NODE_EXTRA_CA_CERTS (if set)
90
+ const userCertPath = process.env.NODE_EXTRA_CA_CERTS;
91
+ if (userCertPath) {
92
+ const userPem = readUserCertificateFile(userCertPath);
93
+ if (userPem) {
94
+ parts.push(userPem.trim());
95
+ ui.writeVerbose(`Safe-chain: Merging user's NODE_EXTRA_CA_CERTS from ${userCertPath}`);
96
+ } else {
97
+ ui.writeWarning(`Safe-chain: Could not read or parse user's NODE_EXTRA_CA_CERTS from ${userCertPath}`);
98
+ }
99
+ }
100
+
90
101
  const combined = parts.filter(Boolean).join("\n");
91
- const target = path.join(os.tmpdir(), "safe-chain-ca-bundle.pem");
102
+ const target = path.join(os.tmpdir(), `safe-chain-ca-bundle-${Date.now()}.pem`);
92
103
  fs.writeFileSync(target, combined, { encoding: "utf8" });
93
- cachedPath = target;
94
- return cachedPath;
104
+ return target;
105
+ }
106
+
107
+ /**
108
+ * Normalize path
109
+ * @param {string} p - Path to normalize
110
+ * @returns {string}
111
+ */
112
+ function normalizePathF(p) {
113
+ return p.replace(/\\/g, "/");
114
+ }
115
+
116
+ /**
117
+ * Normalize line endings to LF
118
+ * @param {string} text - Text with mixed line endings
119
+ * @returns {string}
120
+ */
121
+ function normalizeLineEndings(text) {
122
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
123
+ }
124
+
125
+ /**
126
+ * Read and validate user certificate file
127
+ * @param {string} certPath - Path to certificate file
128
+ * @returns {string | null} Certificate PEM content or null if invalid/unreadable
129
+ */
130
+ function readUserCertificateFile(certPath) {
131
+ try {
132
+ // 1) Basic validation
133
+ if (typeof certPath !== "string" || certPath.trim().length === 0) {
134
+ return null;
135
+ }
136
+
137
+ // 2) Reject path traversal attempts (normalize backslashes first for Windows paths)
138
+ const normalizedPath = normalizePathF(certPath);
139
+ if (normalizedPath.includes("..")) {
140
+ return null;
141
+ }
142
+
143
+ // 3) Check if file exists and is not a directory or symlink
144
+ let stats;
145
+ try {
146
+ stats = fs.lstatSync(certPath);
147
+ } catch {
148
+ // File doesn't exist or can't be accessed
149
+ return null;
150
+ }
151
+
152
+ if (!stats.isFile()) {
153
+ // Reject directories and symlinks
154
+ return null;
155
+ }
156
+
157
+ // 4) Read file content
158
+ let content;
159
+ try {
160
+ content = fs.readFileSync(certPath, "utf8");
161
+ } catch {
162
+ return null;
163
+ }
164
+
165
+ if (!content || typeof content !== "string") {
166
+ return null;
167
+ }
168
+
169
+ // 5) Validate PEM format
170
+ if (!isParsable(content)) {
171
+ return null;
172
+ }
173
+
174
+ return content;
175
+ } catch {
176
+ // Silently fail on any errors
177
+ return null;
178
+ }
95
179
  }
180
+
181
+