@aikidosec/safe-chain 1.2.2 → 1.2.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
@@ -22,6 +22,7 @@ Aikido Safe Chain supports the following package managers:
22
22
  - 📦 **pip** (beta)
23
23
  - 📦 **pip3** (beta)
24
24
  - 📦 **uv** (beta)
25
+ - 📦 **poetry** (beta)
25
26
 
26
27
  # Usage
27
28
 
@@ -81,7 +82,7 @@ iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/inst
81
82
 
82
83
  - The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware.
83
84
 
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.
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.
85
86
 
86
87
  You can check the installed version by running:
87
88
 
@@ -93,13 +94,13 @@ safe-chain --version
93
94
 
94
95
  ### Malware Blocking
95
96
 
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.
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.
97
98
 
98
99
  ### Minimum package age (npm only)
99
100
 
100
101
  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
102
 
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).
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).
103
104
 
104
105
  ### Shell Integration
105
106
 
@@ -115,17 +116,21 @@ More information about the shell integration can be found in the [shell integrat
115
116
 
116
117
  ## Uninstallation
117
118
 
118
- To uninstall the Aikido Safe Chain, you can run the following command:
119
+ To uninstall the Aikido Safe Chain, use our one-line uninstaller:
119
120
 
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.
121
+ ### Unix/Linux/macOS
122
+
123
+ ```shell
124
+ curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/uninstall-safe-chain.sh | sh
125
+ ```
126
+
127
+ ### Windows (PowerShell)
128
+
129
+ ```powershell
130
+ iex (iwr "https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/uninstall-safe-chain.ps1" -UseBasicParsing)
131
+ ```
132
+
133
+ **❗Restart your terminal** after uninstalling to ensure all aliases are removed.
129
134
 
130
135
  # Configuration
131
136
 
@@ -235,7 +240,7 @@ iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/inst
235
240
  run: npm ci
236
241
  ```
237
242
 
238
- > **Note:** Remove `--include-python` if you don't need Python (pip/pip3/uv) support.
243
+ > **Note:** Remove `--include-python` if you don't need Python (pip/pip3/uv/poetry) support.
239
244
 
240
245
  ## Azure DevOps Example
241
246
 
@@ -252,6 +257,6 @@ iex "& { $(iwr 'https://raw.githubusercontent.com/AikidoSec/safe-chain/main/inst
252
257
  displayName: "Install dependencies"
253
258
  ```
254
259
 
255
- > **Note:** Remove `--include-python` if you don't need Python (pip/pip3/uv) support.
260
+ > **Note:** Remove `--include-python` if you don't need Python (pip/pip3/uv/poetry) support.
256
261
 
257
262
  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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aikidosec/safe-chain",
3
- "version": "1.2.2",
3
+ "version": "1.2.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'",
@@ -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",
@@ -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
+
@@ -8,6 +8,17 @@ const ca = loadCa();
8
8
 
9
9
  const certCache = new Map();
10
10
 
11
+ /**
12
+ * @param {forge.pki.PublicKey} publicKey
13
+ * @returns {string}
14
+ */
15
+ function createKeyIdentifier(publicKey) {
16
+ return forge.pki.getPublicKeyFingerprint(publicKey, {
17
+ encoding: "binary",
18
+ md: forge.md.sha1.create(),
19
+ });
20
+ }
21
+
11
22
  export function getCaCertPath() {
12
23
  return path.join(certFolder, "ca-cert.pem");
13
24
  }
@@ -33,6 +44,7 @@ export function generateCertForHost(hostname) {
33
44
  const attrs = [{ name: "commonName", value: hostname }];
34
45
  cert.setSubject(attrs);
35
46
  cert.setIssuer(ca.certificate.subject.attributes);
47
+ const authorityKeyIdentifier = createKeyIdentifier(ca.certificate.publicKey);
36
48
  cert.setExtensions([
37
49
  {
38
50
  name: "subjectAltName",
@@ -50,14 +62,42 @@ export function generateCertForHost(hostname) {
50
62
  },
51
63
  {
52
64
  /*
53
- extKeyUsage serverAuth is required for TLS server authentication.
54
- This is especially important for Python venv environments, which use their own
55
- certificate validation logic and will reject certificates lacking the serverAuth EKU.
56
- Adding serverAuth does not impact other usages
65
+ Extended Key Usage (EKU) serverAuth extension
66
+
67
+ Needed for TLS server authentication. This extension indicates the certificate's
68
+ public key may be used for TLS WWW server authentication.
69
+ Python virtualenv environments (like pipx-installed Poetry) enforce this strictly
70
+ https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.12
57
71
  */
58
72
  name: "extKeyUsage",
59
73
  serverAuth: true,
60
74
  },
75
+ {
76
+ /*
77
+ Subject Key Identifier (SKI)
78
+
79
+ Needed for Python virtualenv SSL validation and certificate chain building.
80
+ This extension provides a means of identifying certificates containing a particular public key.
81
+ Python virtualenv environments require this for proper certificate chain validation.
82
+ System Python installations may be more lenient.
83
+ https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.2
84
+ */
85
+ name: "subjectKeyIdentifier",
86
+ subjectKeyIdentifier: createKeyIdentifier(cert.publicKey),
87
+ },
88
+ {
89
+ /*
90
+ Authority Key Identifier (AKI)
91
+
92
+ Needed for Python virtualenv SSL validation and certificate path validation.
93
+ This extension identifies the public key corresponding to the private key used to sign
94
+ this certificate. It links this certificate to its issuing CA certificate.
95
+ Without this, Python virtualenv certificate validation might fail (for instance for Poetry)
96
+ https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.1
97
+ */
98
+ name: "authorityKeyIdentifier",
99
+ keyIdentifier: authorityKeyIdentifier,
100
+ },
61
101
  ]);
62
102
  cert.sign(ca.privateKey, forge.md.sha256.create());
63
103
 
@@ -106,11 +146,13 @@ function generateCa() {
106
146
 
107
147
  const attrs = [{ name: "commonName", value: "safe-chain proxy" }];
108
148
  cert.setSubject(attrs);
109
- cert.setIssuer(attrs);
149
+ cert.setIssuer(attrs); // Self-signed: issuer === subject
150
+ const keyIdentifier = createKeyIdentifier(cert.publicKey);
110
151
  cert.setExtensions([
111
152
  {
112
153
  name: "basicConstraints",
113
154
  cA: true,
155
+ critical: true, // Marking basicConstraints as critical is required for CA certificates so clients must process it to trust the cert as a CA
114
156
  },
115
157
  {
116
158
  name: "keyUsage",
@@ -118,6 +160,14 @@ function generateCa() {
118
160
  digitalSignature: true,
119
161
  keyEncipherment: true,
120
162
  },
163
+ {
164
+ name: "subjectKeyIdentifier",
165
+ subjectKeyIdentifier: keyIdentifier,
166
+ },
167
+ {
168
+ name: "authorityKeyIdentifier",
169
+ keyIdentifier,
170
+ },
121
171
  ]);
122
172
  cert.sign(keys.privateKey, forge.md.sha256.create());
123
173
 
@@ -0,0 +1,13 @@
1
+ import { isImdsEndpoint } from "./isImdsEndpoint.js";
2
+
3
+ /**
4
+ * Returns appropriate connection timeout for a host.
5
+ * - IMDS endpoints: 3s (fail fast when outside cloud, reduce 5min delay to ~20s)
6
+ * - Other endpoints: 30s (allow for slow networks while preventing indefinite hangs)
7
+ */
8
+ export function getConnectTimeout(/** @type {string} */ host) {
9
+ if (isImdsEndpoint(host)) {
10
+ return 3000;
11
+ }
12
+ return 30000;
13
+ }
@@ -20,6 +20,12 @@ import { EventEmitter } from "events";
20
20
  * @property {(headers: NodeJS.Dict<string | string[]> | undefined) => NodeJS.Dict<string | string[]> | undefined} modifyRequestHeaders
21
21
  * @property {() => boolean} modifiesResponse
22
22
  * @property {(body: Buffer, headers: NodeJS.Dict<string | string[]> | undefined) => Buffer} modifyBody
23
+ *
24
+ * @typedef {Object} MalwareBlockedEvent
25
+ * @property {string} packageName
26
+ * @property {string} version
27
+ * @property {string} targetUrl
28
+ * @property {number} timestamp
23
29
  */
24
30
 
25
31
  /**
@@ -32,7 +32,16 @@ function buildPipInterceptor(registry) {
32
32
  reqContext.targetUrl,
33
33
  registry
34
34
  );
35
- if (await isMalwarePackage(packageName, version)) {
35
+
36
+ // Normalize underscores to hyphens for DB matching, as PyPI allows underscores in distribution names.
37
+ // Per python, packages that differ only by hyphen vs underscore are considered the same.
38
+ const hyphenName = packageName?.includes("_") ? packageName.replace(/_/g, "-") : packageName;
39
+
40
+ const isMalicious =
41
+ await isMalwarePackage(packageName, version)
42
+ || await isMalwarePackage(hyphenName, version);
43
+
44
+ if (isMalicious) {
36
45
  reqContext.blockMalware(packageName, version);
37
46
  }
38
47
  });
@@ -71,16 +80,21 @@ function parsePipPackageFromUrl(url, registry) {
71
80
  // Example wheel: https://files.pythonhosted.org/packages/xx/yy/requests-2.28.1-py3-none-any.whl
72
81
  // Example sdist: https://files.pythonhosted.org/packages/xx/yy/requests-2.28.1.tar.gz
73
82
 
74
- // Wheel (.whl)
75
- if (filename.endsWith(".whl")) {
76
- const base = filename.slice(0, -4); // remove ".whl"
83
+ // Wheel (.whl) and Poetry's preflight metadata (.whl.metadata)
84
+ // Examples:
85
+ // foo_bar-2.0.0-py3-none-any.whl
86
+ // foo_bar-2.0.0-py3-none-any.whl.metadata
87
+ const wheelExtRe = /\.whl(?:\.metadata)?$/;
88
+ const wheelExtMatch = filename.match(wheelExtRe);
89
+ if (wheelExtMatch) {
90
+ const base = filename.replace(wheelExtRe, "");
77
91
  const firstDash = base.indexOf("-");
78
92
  if (firstDash > 0) {
79
93
  const dist = base.slice(0, firstDash); // may contain underscores
80
94
  const rest = base.slice(firstDash + 1); // version + the rest of tags
81
95
  const secondDash = rest.indexOf("-");
82
96
  const rawVersion = secondDash >= 0 ? rest.slice(0, secondDash) : rest;
83
- packageName = dist; // preserve underscores
97
+ packageName = dist;
84
98
  version = rawVersion;
85
99
  // Reject "latest" as it's a placeholder, not a real version
86
100
  // When version is "latest", this signals the URL doesn't contain actual version info
@@ -92,10 +106,11 @@ function parsePipPackageFromUrl(url, registry) {
92
106
  }
93
107
  }
94
108
 
95
- // Source dist (sdist)
96
- const sdistExtMatch = filename.match(/\.(tar\.gz|zip|tar\.bz2|tar\.xz)$/i);
109
+ // Source dist (sdist) and potential metadata sidecars (e.g., .tar.gz.metadata)
110
+ const sdistExtWithMetadataRe = /\.(tar\.gz|zip|tar\.bz2|tar\.xz)(\.metadata)?$/i;
111
+ const sdistExtMatch = filename.match(sdistExtWithMetadataRe);
97
112
  if (sdistExtMatch) {
98
- const base = filename.slice(0, -sdistExtMatch[0].length);
113
+ const base = filename.replace(sdistExtWithMetadataRe, "");
99
114
  const lastDash = base.lastIndexOf("-");
100
115
  if (lastDash > 0 && lastDash < base.length - 1) {
101
116
  packageName = base.slice(0, lastDash);
@@ -109,7 +124,6 @@ function parsePipPackageFromUrl(url, registry) {
109
124
  return { packageName, version };
110
125
  }
111
126
  }
112
-
113
127
  // Unknown file type or invalid
114
128
  return { packageName: undefined, version: undefined };
115
129
  }
@@ -2,7 +2,7 @@ import * as http from "http";
2
2
  import { tunnelRequest } from "./tunnelRequestHandler.js";
3
3
  import { mitmConnect } from "./mitmRequestHandler.js";
4
4
  import { handleHttpProxyRequest } from "./plainHttpProxy.js";
5
- import { getCaCertPath } from "./certUtils.js";
5
+ import { getCombinedCaBundlePath } from "./certBundle.js";
6
6
  import { ui } from "../environment/userInteraction.js";
7
7
  import chalk from "chalk";
8
8
  import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js";
@@ -36,10 +36,13 @@ function getSafeChainProxyEnvironmentVariables() {
36
36
  return {};
37
37
  }
38
38
 
39
+ const proxyUrl = `http://localhost:${state.port}`;
40
+ const caCertPath = getCombinedCaBundlePath();
41
+
39
42
  return {
40
- HTTPS_PROXY: `http://localhost:${state.port}`,
41
- GLOBAL_AGENT_HTTP_PROXY: `http://localhost:${state.port}`,
42
- NODE_EXTRA_CA_CERTS: getCaCertPath(),
43
+ HTTPS_PROXY: proxyUrl,
44
+ GLOBAL_AGENT_HTTP_PROXY: proxyUrl,
45
+ NODE_EXTRA_CA_CERTS: caCertPath,
43
46
  };
44
47
  }
45
48
 
@@ -136,9 +139,14 @@ function handleConnect(req, clientSocket, head) {
136
139
 
137
140
  if (interceptor) {
138
141
  // Subscribe to malware blocked events
139
- interceptor.on("malwareBlocked", (event) => {
140
- onMalwareBlocked(event.packageName, event.version, event.url);
141
- });
142
+ interceptor.on(
143
+ "malwareBlocked",
144
+ (
145
+ /** @type {import("./interceptors/interceptorBuilder.js").MalwareBlockedEvent} */ event
146
+ ) => {
147
+ onMalwareBlocked(event.packageName, event.version, event.targetUrl);
148
+ }
149
+ );
142
150
 
143
151
  mitmConnect(req, clientSocket, interceptor);
144
152
  } else {
@@ -1,9 +1,10 @@
1
1
  import * as net from "net";
2
2
  import { ui } from "../environment/userInteraction.js";
3
3
  import { isImdsEndpoint } from "./isImdsEndpoint.js";
4
+ import { getConnectTimeout } from "./getConnectTimeout.js";
4
5
 
5
6
  /** @type {string[]} */
6
- let timedoutEndpoints = [];
7
+ let timedoutImdsEndpoints = [];
7
8
 
8
9
  /**
9
10
  * @param {import("http").IncomingMessage} req
@@ -43,7 +44,7 @@ function tunnelRequestToDestination(req, clientSocket, head) {
43
44
  const { port, hostname } = new URL(`http://${req.url}`);
44
45
  const isImds = isImdsEndpoint(hostname);
45
46
 
46
- if (timedoutEndpoints.includes(hostname)) {
47
+ if (timedoutImdsEndpoints.includes(hostname)) {
47
48
  clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
48
49
  if (isImds) {
49
50
  ui.writeVerbose(
@@ -74,9 +75,9 @@ function tunnelRequestToDestination(req, clientSocket, head) {
74
75
  serverSocket.setTimeout(connectTimeout);
75
76
 
76
77
  serverSocket.on("timeout", () => {
77
- timedoutEndpoints.push(hostname);
78
78
  // Suppress error logging for IMDS endpoints - timeouts are expected when not in cloud
79
79
  if (isImds) {
80
+ timedoutImdsEndpoints.push(hostname);
80
81
  ui.writeVerbose(
81
82
  `Safe-chain: connect to ${hostname}:${
82
83
  port || 443
@@ -196,14 +197,3 @@ function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) {
196
197
  });
197
198
  }
198
199
 
199
- /**
200
- * Returns appropriate connection timeout for a host.
201
- * - IMDS endpoints: 3s (fail fast when outside cloud, reduce 5min delay to ~20s)
202
- * - Other endpoints: 30s (allow for slow networks while preventing indefinite hangs)
203
- */
204
- function getConnectTimeout(/** @type {string} */ host) {
205
- if (isImdsEndpoint(host)) {
206
- return 3000;
207
- }
208
- return 30000;
209
- }
@@ -76,6 +76,12 @@ export const knownAikidoTools = [
76
76
  ecoSystem: ECOSYSTEM_PY,
77
77
  internalPackageManagerName: "pip",
78
78
  },
79
+ {
80
+ tool: "poetry",
81
+ aikidoCommand: "aikido-poetry",
82
+ ecoSystem: ECOSYSTEM_PY,
83
+ internalPackageManagerName: "poetry",
84
+ },
79
85
  {
80
86
  tool: "python",
81
87
  aikidoCommand: "aikido-python",
@@ -107,6 +113,20 @@ export function getPackageManagerList() {
107
113
  return `${tools.join(", ")}, and ${lastTool} commands`;
108
114
  }
109
115
 
116
+ /**
117
+ * @returns {string}
118
+ */
119
+ export function getShimsDir() {
120
+ return path.join(os.homedir(), ".safe-chain", "shims");
121
+ }
122
+
123
+ /**
124
+ * @returns {string}
125
+ */
126
+ export function getScriptsDir() {
127
+ return path.join(os.homedir(), ".safe-chain", "scripts");
128
+ }
129
+
110
130
  /**
111
131
  * @param {string} executableName
112
132
  *
@@ -1,6 +1,6 @@
1
1
  import chalk from "chalk";
2
2
  import { ui } from "../environment/userInteraction.js";
3
- import { getPackageManagerList, knownAikidoTools } from "./helpers.js";
3
+ import { getPackageManagerList, knownAikidoTools, getShimsDir } from "./helpers.js";
4
4
  import fs from "fs";
5
5
  import os from "os";
6
6
  import path from "path";
@@ -32,7 +32,7 @@ export async function setupCi() {
32
32
  );
33
33
  ui.emptyLine();
34
34
 
35
- const shimsDir = path.join(os.homedir(), ".safe-chain", "shims");
35
+ const shimsDir = getShimsDir();
36
36
  const binDir = path.join(os.homedir(), ".safe-chain", "bin");
37
37
  // Create the shims directory if it doesn't exist
38
38
  if (!fs.existsSync(shimsDir)) {
@@ -1,9 +1,8 @@
1
1
  import chalk from "chalk";
2
2
  import { ui } from "../environment/userInteraction.js";
3
3
  import { detectShells } from "./shellDetection.js";
4
- import { knownAikidoTools, getPackageManagerList } from "./helpers.js";
4
+ import { knownAikidoTools, getPackageManagerList, getScriptsDir } from "./helpers.js";
5
5
  import fs from "fs";
6
- import os from "os";
7
6
  import path from "path";
8
7
  import { includePython } from "../config/cliArguments.js";
9
8
  import { fileURLToPath } from "url";
@@ -107,10 +106,10 @@ function setupShell(shell) {
107
106
 
108
107
  function copyStartupFiles() {
109
108
  const startupFiles = ["init-posix.sh", "init-pwsh.ps1", "init-fish.fish"];
109
+ const targetDir = getScriptsDir();
110
110
 
111
111
  for (const file of startupFiles) {
112
- const targetDir = path.join(os.homedir(), ".safe-chain", "scripts");
113
- const targetPath = path.join(os.homedir(), ".safe-chain", "scripts", file);
112
+ const targetPath = path.join(targetDir, file);
114
113
 
115
114
  if (!fs.existsSync(targetDir)) {
116
115
  fs.mkdirSync(targetDir, { recursive: true });
@@ -52,6 +52,10 @@ function uv
52
52
  wrapSafeChainCommand "uv" $argv
53
53
  end
54
54
 
55
+ function poetry
56
+ wrapSafeChainCommand "poetry" $argv
57
+ end
58
+
55
59
  # `python -m pip`, `python -m pip3`.
56
60
  function python
57
61
  wrapSafeChainCommand "python" $argv
@@ -48,6 +48,10 @@ function uv() {
48
48
  wrapSafeChainCommand "uv" "$@"
49
49
  }
50
50
 
51
+ function poetry() {
52
+ wrapSafeChainCommand "poetry" "$@"
53
+ }
54
+
51
55
  # `python -m pip`, `python -m pip3`.
52
56
  function python() {
53
57
  wrapSafeChainCommand "python" "$@"
@@ -50,6 +50,10 @@ function uv {
50
50
  Invoke-WrappedCommand "uv" $args
51
51
  }
52
52
 
53
+ function poetry {
54
+ Invoke-WrappedCommand "poetry" $args
55
+ }
56
+
53
57
  # `python -m pip`, `python -m pip3`.
54
58
  function python {
55
59
  Invoke-WrappedCommand 'python' $args
@@ -1,7 +1,8 @@
1
1
  import chalk from "chalk";
2
2
  import { ui } from "../environment/userInteraction.js";
3
3
  import { detectShells } from "./shellDetection.js";
4
- import { knownAikidoTools, getPackageManagerList } from "./helpers.js";
4
+ import { knownAikidoTools, getPackageManagerList, getShimsDir, getScriptsDir } from "./helpers.js";
5
+ import fs from "fs";
5
6
 
6
7
  /**
7
8
  * @returns {Promise<void>}
@@ -62,3 +63,44 @@ export async function teardown() {
62
63
  return;
63
64
  }
64
65
  }
66
+
67
+ /**
68
+ * Removes directories created by setup-ci and setup commands
69
+ * @returns {Promise<void>}
70
+ */
71
+ export async function teardownDirectories() {
72
+ const shimsDir = getShimsDir();
73
+ const scriptsDir = getScriptsDir();
74
+
75
+ // Remove CI shims directory
76
+ if (fs.existsSync(shimsDir)) {
77
+ try {
78
+ fs.rmSync(shimsDir, { recursive: true, force: true });
79
+ ui.writeInformation(
80
+ `${chalk.bold("- CI Shims:")} ${chalk.green("Removed successfully")}`
81
+ );
82
+ } catch (/** @type {any} */ error) {
83
+ ui.writeError(
84
+ `${chalk.bold("- CI Shims:")} ${chalk.red(
85
+ "Failed to remove"
86
+ )}. Error: ${error.message}`
87
+ );
88
+ }
89
+ }
90
+
91
+ // Remove scripts directory
92
+ if (fs.existsSync(scriptsDir)) {
93
+ try {
94
+ fs.rmSync(scriptsDir, { recursive: true, force: true });
95
+ ui.writeInformation(
96
+ `${chalk.bold("- Scripts:")} ${chalk.green("Removed successfully")}`
97
+ );
98
+ } catch (/** @type {any} */ error) {
99
+ ui.writeError(
100
+ `${chalk.bold("- Scripts:")} ${chalk.red(
101
+ "Failed to remove"
102
+ )}. Error: ${error.message}`
103
+ );
104
+ }
105
+ }
106
+ }