@aikidosec/safe-chain 1.0.24 → 1.1.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.
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { main } from "../src/main.js";
4
+ import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
5
+
6
+ const packageManagerName = "bun";
7
+ initializePackageManager(packageManagerName);
8
+ var exitCode = await main(process.argv.slice(2));
9
+
10
+ process.exit(exitCode);
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { main } from "../src/main.js";
4
+ import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
5
+
6
+ const packageManagerName = "bunx";
7
+ initializePackageManager(packageManagerName);
8
+ var exitCode = await main(process.argv.slice(2));
9
+
10
+ process.exit(exitCode);
package/bin/aikido-npm.js CHANGED
@@ -6,7 +6,9 @@ import { initializePackageManager } from "../src/packagemanager/currentPackageMa
6
6
 
7
7
  const packageManagerName = "npm";
8
8
  initializePackageManager(packageManagerName, getNpmVersion());
9
- await main(process.argv.slice(2));
9
+ var exitCode = await main(process.argv.slice(2));
10
+
11
+ process.exit(exitCode);
10
12
 
11
13
  function getNpmVersion() {
12
14
  try {
package/bin/aikido-npx.js CHANGED
@@ -5,4 +5,6 @@ import { initializePackageManager } from "../src/packagemanager/currentPackageMa
5
5
 
6
6
  const packageManagerName = "npx";
7
7
  initializePackageManager(packageManagerName, process.versions.node);
8
- await main(process.argv.slice(2));
8
+ var exitCode = await main(process.argv.slice(2));
9
+
10
+ process.exit(exitCode);
@@ -5,4 +5,6 @@ import { initializePackageManager } from "../src/packagemanager/currentPackageMa
5
5
 
6
6
  const packageManagerName = "pnpm";
7
7
  initializePackageManager(packageManagerName, process.versions.node);
8
- await main(process.argv.slice(2));
8
+ var exitCode = await main(process.argv.slice(2));
9
+
10
+ process.exit(exitCode);
@@ -5,4 +5,6 @@ import { initializePackageManager } from "../src/packagemanager/currentPackageMa
5
5
 
6
6
  const packageManagerName = "pnpx";
7
7
  initializePackageManager(packageManagerName, process.versions.node);
8
- await main(process.argv.slice(2));
8
+ var exitCode = await main(process.argv.slice(2));
9
+
10
+ process.exit(exitCode);
@@ -5,4 +5,6 @@ import { initializePackageManager } from "../src/packagemanager/currentPackageMa
5
5
 
6
6
  const packageManagerName = "yarn";
7
7
  initializePackageManager(packageManagerName, process.versions.node);
8
- await main(process.argv.slice(2));
8
+ var exitCode = await main(process.argv.slice(2));
9
+
10
+ process.exit(exitCode);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aikidosec/safe-chain",
3
- "version": "1.0.24",
3
+ "version": "1.1.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'",
@@ -12,6 +12,8 @@
12
12
  "aikido-yarn": "bin/aikido-yarn.js",
13
13
  "aikido-pnpm": "bin/aikido-pnpm.js",
14
14
  "aikido-pnpx": "bin/aikido-pnpx.js",
15
+ "aikido-bun": "bin/aikido-bun.js",
16
+ "aikido-bunx": "bin/aikido-bunx.js",
15
17
  "safe-chain": "bin/safe-chain.js"
16
18
  },
17
19
  "type": "module",
@@ -30,7 +32,9 @@
30
32
  "dependencies": {
31
33
  "abbrev": "3.0.1",
32
34
  "chalk": "5.4.1",
35
+ "https-proxy-agent": "7.0.6",
33
36
  "make-fetch-happen": "14.0.3",
37
+ "node-forge": "1.3.1",
34
38
  "npm-registry-fetch": "18.0.2",
35
39
  "ora": "8.2.0",
36
40
  "semver": "7.7.2"
package/src/main.js CHANGED
@@ -4,20 +4,50 @@ import { scanCommand, shouldScanCommand } from "./scanning/index.js";
4
4
  import { ui } from "./environment/userInteraction.js";
5
5
  import { getPackageManager } from "./packagemanager/currentPackageManager.js";
6
6
  import { initializeCliArguments } from "./config/cliArguments.js";
7
+ import { createSafeChainProxy } from "./registryProxy/registryProxy.js";
8
+ import chalk from "chalk";
7
9
 
8
10
  export async function main(args) {
11
+ const proxy = createSafeChainProxy();
12
+ await proxy.startServer();
13
+
9
14
  try {
10
15
  // This parses all the --safe-chain arguments and removes them from the args array
11
16
  args = initializeCliArguments(args);
12
17
 
13
18
  if (shouldScanCommand(args)) {
14
- await scanCommand(args);
19
+ const commandScanResult = await scanCommand(args);
20
+
21
+ // Returning the exit code back to the caller allows the promise
22
+ // to be awaited in the bin files and return the correct exit code
23
+ if (commandScanResult !== 0) {
24
+ return commandScanResult;
25
+ }
26
+ }
27
+
28
+ const packageManagerResult = await getPackageManager().runCommand(args);
29
+
30
+ if (!proxy.verifyNoMaliciousPackages()) {
31
+ return 1;
15
32
  }
33
+
34
+ ui.emptyLine();
35
+ ui.writeInformation(
36
+ `${chalk.green(
37
+ "✔"
38
+ )} Safe-chain: Command completed, no malicious packages found.`
39
+ );
40
+
41
+ // Returning the exit code back to the caller allows the promise
42
+ // to be awaited in the bin files and return the correct exit code
43
+ return packageManagerResult.status;
16
44
  } catch (error) {
17
45
  ui.writeError("Failed to check for malicious packages:", error.message);
18
- process.exit(1);
19
- }
20
46
 
21
- var result = getPackageManager().runCommand(args);
22
- process.exit(result.status);
47
+ // Returning the exit code back to the caller allows the promise
48
+ // to be awaited in the bin files and return the correct exit code
49
+ return 1;
50
+ } finally {
51
+ await proxy.stopServer();
52
+ }
23
53
  }
@@ -0,0 +1,42 @@
1
+ import { ui } from "../../environment/userInteraction.js";
2
+ import { safeSpawn } from "../../utils/safeSpawn.js";
3
+ import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
4
+
5
+ export function createBunPackageManager() {
6
+ return {
7
+ runCommand: (args) => runBunCommand("bun", args),
8
+
9
+ // For bun, we use the proxy-only approach to block package downloads,
10
+ // so we don't need to analyze commands.
11
+ isSupportedCommand: () => false,
12
+ getDependencyUpdatesForCommand: () => [],
13
+ };
14
+ }
15
+
16
+ export function createBunxPackageManager() {
17
+ return {
18
+ runCommand: (args) => runBunCommand("bunx", args),
19
+
20
+ // For bunx, we use the proxy-only approach to block package downloads,
21
+ // so we don't need to analyze commands.
22
+ isSupportedCommand: () => false,
23
+ getDependencyUpdatesForCommand: () => [],
24
+ };
25
+ }
26
+
27
+ async function runBunCommand(command, args) {
28
+ try {
29
+ const result = await safeSpawn(command, args, {
30
+ stdio: "inherit",
31
+ env: mergeSafeChainProxyEnvironmentVariables(process.env),
32
+ });
33
+ return { status: result.status };
34
+ } catch (error) {
35
+ if (error.status) {
36
+ return { status: error.status };
37
+ } else {
38
+ ui.writeError("Error executing command:", error.message);
39
+ return { status: 1 };
40
+ }
41
+ }
42
+ }
@@ -1,3 +1,7 @@
1
+ import {
2
+ createBunPackageManager,
3
+ createBunxPackageManager,
4
+ } from "./bun/createBunPackageManager.js";
1
5
  import { createNpmPackageManager } from "./npm/createPackageManager.js";
2
6
  import { createNpxPackageManager } from "./npx/createPackageManager.js";
3
7
  import {
@@ -21,6 +25,10 @@ export function initializePackageManager(packageManagerName, version) {
21
25
  state.packageManagerName = createPnpmPackageManager();
22
26
  } else if (packageManagerName === "pnpx") {
23
27
  state.packageManagerName = createPnpxPackageManager();
28
+ } else if (packageManagerName === "bun") {
29
+ state.packageManagerName = createBunPackageManager();
30
+ } else if (packageManagerName === "bunx") {
31
+ state.packageManagerName = createBunxPackageManager();
24
32
  } else {
25
33
  throw new Error("Unsupported package manager: " + packageManagerName);
26
34
  }
@@ -8,6 +8,7 @@ export function dryRunScanner(scannerOptions) {
8
8
  shouldScan: (args) => shouldScanDependencies(scannerOptions, args),
9
9
  };
10
10
  }
11
+
11
12
  function scanDependencies(scannerOptions, args) {
12
13
  let dryRunArgs = args;
13
14
 
@@ -31,8 +32,8 @@ function shouldScanDependencies(scannerOptions, args) {
31
32
  return true;
32
33
  }
33
34
 
34
- function checkChangesWithDryRun(args) {
35
- const dryRunOutput = dryRunNpmCommandAndOutput(args);
35
+ async function checkChangesWithDryRun(args) {
36
+ const dryRunOutput = await dryRunNpmCommandAndOutput(args);
36
37
 
37
38
  // Dry-run can return a non-zero status code in some cases
38
39
  // e.g., when running "npm audit fix --dry-run", it returns exit code 1
@@ -1,10 +1,14 @@
1
- import { execSync } from "child_process";
2
1
  import { ui } from "../../environment/userInteraction.js";
2
+ import { safeSpawn } from "../../utils/safeSpawn.js";
3
+ import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
3
4
 
4
- export function runNpm(args) {
5
+ export async function runNpm(args) {
5
6
  try {
6
- const npmCommand = `npm ${args.join(" ")}`;
7
- execSync(npmCommand, { stdio: "inherit" });
7
+ const result = await safeSpawn("npm", args, {
8
+ stdio: "inherit",
9
+ env: mergeSafeChainProxyEnvironmentVariables(process.env),
10
+ });
11
+ return { status: result.status };
8
12
  } catch (error) {
9
13
  if (error.status) {
10
14
  return { status: error.status };
@@ -13,17 +17,29 @@ export function runNpm(args) {
13
17
  return { status: 1 };
14
18
  }
15
19
  }
16
- return { status: 0 };
17
20
  }
18
21
 
19
- export function dryRunNpmCommandAndOutput(args) {
22
+ export async function dryRunNpmCommandAndOutput(args) {
20
23
  try {
21
- const npmCommand = `npm ${args.join(" ")} --ignore-scripts --dry-run`;
22
- const output = execSync(npmCommand, { stdio: "pipe" });
23
- return { status: 0, output: output.toString() };
24
+ const result = await safeSpawn(
25
+ "npm",
26
+ [...args, "--ignore-scripts", "--dry-run"],
27
+ {
28
+ stdio: "pipe",
29
+ env: mergeSafeChainProxyEnvironmentVariables(process.env),
30
+ }
31
+ );
32
+ return {
33
+ status: result.status,
34
+ output: result.status === 0 ? result.stdout : result.stderr,
35
+ };
24
36
  } catch (error) {
25
37
  if (error.status) {
26
- const output = error.stdout ? error.stdout.toString() : "";
38
+ const output =
39
+ error.stdout?.toString() ??
40
+ error.stderr?.toString() ??
41
+ error.message ??
42
+ "";
27
43
  return { status: error.status, output };
28
44
  } else {
29
45
  ui.writeError("Error executing command:", error.message);
@@ -1,10 +1,14 @@
1
- import { execSync } from "child_process";
2
1
  import { ui } from "../../environment/userInteraction.js";
2
+ import { safeSpawn } from "../../utils/safeSpawn.js";
3
+ import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
3
4
 
4
- export function runNpx(args) {
5
+ export async function runNpx(args) {
5
6
  try {
6
- const npxCommand = `npx ${args.join(" ")}`;
7
- execSync(npxCommand, { stdio: "inherit" });
7
+ const result = await safeSpawn("npx", args, {
8
+ stdio: "inherit",
9
+ env: mergeSafeChainProxyEnvironmentVariables(process.env),
10
+ });
11
+ return { status: result.status };
8
12
  } catch (error) {
9
13
  if (error.status) {
10
14
  return { status: error.status };
@@ -13,5 +17,4 @@ export function runNpx(args) {
13
17
  return { status: 1 };
14
18
  }
15
19
  }
16
- return { status: 0 };
17
20
  }
@@ -1,13 +1,20 @@
1
1
  import { ui } from "../../environment/userInteraction.js";
2
- import { safeSpawnSync } from "../../utils/safeSpawn.js";
2
+ import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
3
+ import { safeSpawn } from "../../utils/safeSpawn.js";
3
4
 
4
- export function runPnpmCommand(args, toolName = "pnpm") {
5
+ export async function runPnpmCommand(args, toolName = "pnpm") {
5
6
  try {
6
7
  let result;
7
8
  if (toolName === "pnpm") {
8
- result = safeSpawnSync("pnpm", args, { stdio: "inherit" });
9
+ result = await safeSpawn("pnpm", args, {
10
+ stdio: "inherit",
11
+ env: mergeSafeChainProxyEnvironmentVariables(process.env),
12
+ });
9
13
  } else if (toolName === "pnpx") {
10
- result = safeSpawnSync("pnpx", args, { stdio: "inherit" });
14
+ result = await safeSpawn("pnpx", args, {
15
+ stdio: "inherit",
16
+ env: mergeSafeChainProxyEnvironmentVariables(process.env),
17
+ });
11
18
  } else {
12
19
  throw new Error(`Unsupported tool name for aikido-pnpm: ${toolName}`);
13
20
  }
@@ -1,10 +1,17 @@
1
- import { execSync } from "child_process";
2
1
  import { ui } from "../../environment/userInteraction.js";
2
+ import { safeSpawn } from "../../utils/safeSpawn.js";
3
+ import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
3
4
 
4
- export function runYarnCommand(args) {
5
+ export async function runYarnCommand(args) {
5
6
  try {
6
- const npxCommand = `yarn ${args.join(" ")}`;
7
- execSync(npxCommand, { stdio: "inherit" });
7
+ const env = mergeSafeChainProxyEnvironmentVariables(process.env);
8
+ await fixYarnProxyEnvironmentVariables(env);
9
+
10
+ const result = await safeSpawn("yarn", args, {
11
+ stdio: "inherit",
12
+ env,
13
+ });
14
+ return { status: result.status };
8
15
  } catch (error) {
9
16
  if (error.status) {
10
17
  return { status: error.status };
@@ -13,5 +20,34 @@ export function runYarnCommand(args) {
13
20
  return { status: 1 };
14
21
  }
15
22
  }
16
- return { status: 0 };
23
+ }
24
+
25
+ async function fixYarnProxyEnvironmentVariables(env) {
26
+ // Yarn ignores standard proxy environment variables HTTPS_PROXY and NODE_EXTRA_CA_CERTS
27
+
28
+ // Yarn v2/v3 and v4+ use different environment variables for proxy and CA certs
29
+ // When setting all variables, yarn returns an error about conflicting variables
30
+ // - v2/v3: "Usage Error: Unrecognized or legacy configuration settings found: httpsCaFilePath"
31
+ // - v4+: "Usage Error: Unrecognized or legacy configuration settings found: caFilePath"
32
+
33
+ const version = await yarnVersion();
34
+ const majorVersion = parseInt(version.split(".")[0]);
35
+
36
+ if (majorVersion >= 4) {
37
+ env.YARN_HTTPS_PROXY = env.HTTPS_PROXY;
38
+ env.YARN_HTTPS_CA_FILE_PATH = env.NODE_EXTRA_CA_CERTS;
39
+ } else if (majorVersion === 2 || majorVersion === 3) {
40
+ env.YARN_HTTPS_PROXY = env.HTTPS_PROXY;
41
+ env.YARN_CA_FILE_PATH = env.NODE_EXTRA_CA_CERTS;
42
+ }
43
+ }
44
+
45
+ async function yarnVersion() {
46
+ const result = await safeSpawn("yarn", ["--version"], {
47
+ stdio: "pipe",
48
+ });
49
+ if (result.status !== 0) {
50
+ throw new Error("Failed to get yarn version");
51
+ }
52
+ return result.stdout.trim();
17
53
  }
@@ -0,0 +1,114 @@
1
+ import forge from "node-forge";
2
+ import path from "path";
3
+ import fs from "fs";
4
+ import os from "os";
5
+
6
+ const certFolder = path.join(os.homedir(), ".safe-chain", "certs");
7
+ const ca = loadCa();
8
+
9
+ const certCache = new Map();
10
+
11
+ export function getCaCertPath() {
12
+ return path.join(certFolder, "ca-cert.pem");
13
+ }
14
+
15
+ export function generateCertForHost(hostname) {
16
+ let existingCert = certCache.get(hostname);
17
+ if (existingCert) {
18
+ return existingCert;
19
+ }
20
+
21
+ const keys = forge.pki.rsa.generateKeyPair(2048);
22
+ const cert = forge.pki.createCertificate();
23
+ cert.publicKey = keys.publicKey;
24
+ cert.serialNumber = "01";
25
+ cert.validity.notBefore = new Date();
26
+ cert.validity.notAfter = new Date();
27
+ cert.validity.notAfter.setHours(cert.validity.notBefore.getHours() + 1);
28
+
29
+ const attrs = [{ name: "commonName", value: hostname }];
30
+ cert.setSubject(attrs);
31
+ cert.setIssuer(ca.certificate.subject.attributes);
32
+ cert.setExtensions([
33
+ {
34
+ name: "subjectAltName",
35
+ altNames: [
36
+ {
37
+ type: 2, // DNS
38
+ value: hostname,
39
+ },
40
+ ],
41
+ },
42
+ {
43
+ name: "keyUsage",
44
+ digitalSignature: true,
45
+ keyEncipherment: true,
46
+ },
47
+ ]);
48
+ cert.sign(ca.privateKey, forge.md.sha256.create());
49
+
50
+ const result = {
51
+ privateKey: forge.pki.privateKeyToPem(keys.privateKey),
52
+ certificate: forge.pki.certificateToPem(cert),
53
+ };
54
+
55
+ certCache.set(hostname, result);
56
+
57
+ return result;
58
+ }
59
+
60
+ function loadCa() {
61
+ const keyPath = path.join(certFolder, "ca-key.pem");
62
+ const certPath = path.join(certFolder, "ca-cert.pem");
63
+
64
+ if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
65
+ const privateKeyPem = fs.readFileSync(keyPath, "utf8");
66
+ const certPem = fs.readFileSync(certPath, "utf8");
67
+ const privateKey = forge.pki.privateKeyFromPem(privateKeyPem);
68
+ const certificate = forge.pki.certificateFromPem(certPem);
69
+
70
+ // Don't return a cert that is valid for less than 1 hour
71
+ const oneHourFromNow = new Date(Date.now() + 60 * 60 * 1000);
72
+ if (certificate.validity.notAfter > oneHourFromNow) {
73
+ return { privateKey, certificate };
74
+ }
75
+ }
76
+
77
+ const { privateKey, certificate } = generateCa();
78
+ fs.mkdirSync(certFolder, { recursive: true });
79
+ fs.writeFileSync(keyPath, forge.pki.privateKeyToPem(privateKey));
80
+ fs.writeFileSync(certPath, forge.pki.certificateToPem(certificate));
81
+ return { privateKey, certificate };
82
+ }
83
+
84
+ function generateCa() {
85
+ const keys = forge.pki.rsa.generateKeyPair(2048);
86
+ const cert = forge.pki.createCertificate();
87
+ cert.publicKey = keys.publicKey;
88
+ cert.serialNumber = "01";
89
+ cert.validity.notBefore = new Date();
90
+ cert.validity.notAfter = new Date();
91
+ cert.validity.notAfter.setDate(cert.validity.notBefore.getDate() + 1);
92
+
93
+ const attrs = [{ name: "commonName", value: "safe-chain proxy" }];
94
+ cert.setSubject(attrs);
95
+ cert.setIssuer(attrs);
96
+ cert.setExtensions([
97
+ {
98
+ name: "basicConstraints",
99
+ cA: true,
100
+ },
101
+ {
102
+ name: "keyUsage",
103
+ keyCertSign: true,
104
+ digitalSignature: true,
105
+ keyEncipherment: true,
106
+ },
107
+ ]);
108
+ cert.sign(keys.privateKey, forge.md.sha256.create());
109
+
110
+ return {
111
+ privateKey: keys.privateKey,
112
+ certificate: cert,
113
+ };
114
+ }
@@ -0,0 +1,90 @@
1
+ import https from "https";
2
+ import { generateCertForHost } from "./certUtils.js";
3
+ import { HttpsProxyAgent } from "https-proxy-agent";
4
+
5
+ export function mitmConnect(req, clientSocket, isAllowed) {
6
+ const { hostname } = new URL(`http://${req.url}`);
7
+
8
+ const server = createHttpsServer(hostname, isAllowed);
9
+
10
+ // Establish the connection
11
+ clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
12
+
13
+ // Hand off the socket to the HTTPS server
14
+ server.emit("connection", clientSocket);
15
+ }
16
+
17
+ function createHttpsServer(hostname, isAllowed) {
18
+ const cert = generateCertForHost(hostname);
19
+
20
+ async function handleRequest(req, res) {
21
+ const pathAndQuery = getRequestPathAndQuery(req.url);
22
+ const targetUrl = `https://${hostname}${pathAndQuery}`;
23
+
24
+ if (!(await isAllowed(targetUrl))) {
25
+ res.writeHead(403, "Forbidden - blocked by safe-chain");
26
+ res.end("Blocked by safe-chain");
27
+ return;
28
+ }
29
+
30
+ // Collect request body
31
+ forwardRequest(req, hostname, res);
32
+ }
33
+
34
+ return https.createServer(
35
+ {
36
+ key: cert.privateKey,
37
+ cert: cert.certificate,
38
+ },
39
+ handleRequest
40
+ );
41
+ }
42
+
43
+ function getRequestPathAndQuery(url) {
44
+ if (url.startsWith("http://") || url.startsWith("https://")) {
45
+ const parsedUrl = new URL(url);
46
+ return parsedUrl.pathname + parsedUrl.search + parsedUrl.hash;
47
+ }
48
+ return url;
49
+ }
50
+
51
+ function forwardRequest(req, hostname, res) {
52
+ const proxyReq = createProxyRequest(hostname, req, res);
53
+
54
+ proxyReq.on("error", () => {
55
+ res.writeHead(502);
56
+ res.end("Bad Gateway");
57
+ });
58
+
59
+ req.on("data", (chunk) => {
60
+ proxyReq.write(chunk);
61
+ });
62
+
63
+ req.on("end", () => {
64
+ proxyReq.end();
65
+ });
66
+ }
67
+
68
+ function createProxyRequest(hostname, req, res) {
69
+ const options = {
70
+ hostname: hostname,
71
+ port: 443,
72
+ path: req.url,
73
+ method: req.method,
74
+ headers: { ...req.headers },
75
+ };
76
+
77
+ delete options.headers.host;
78
+
79
+ const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy;
80
+ if (httpsProxy) {
81
+ options.agent = new HttpsProxyAgent(httpsProxy);
82
+ }
83
+
84
+ const proxyReq = https.request(options, (proxyRes) => {
85
+ res.writeHead(proxyRes.statusCode, proxyRes.headers);
86
+ proxyRes.pipe(res);
87
+ });
88
+
89
+ return proxyReq;
90
+ }
@@ -0,0 +1,48 @@
1
+ export const knownRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"];
2
+
3
+ export function parsePackageFromUrl(url) {
4
+ let packageName, version, registry;
5
+
6
+ for (const knownRegistry of knownRegistries) {
7
+ if (url.includes(knownRegistry)) {
8
+ registry = knownRegistry;
9
+ break;
10
+ }
11
+ }
12
+
13
+ if (!registry || !url.endsWith(".tgz")) {
14
+ return { packageName, version };
15
+ }
16
+
17
+ const registryIndex = url.indexOf(registry);
18
+ const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash
19
+
20
+ const separatorIndex = afterRegistry.indexOf("/-/");
21
+ if (separatorIndex === -1) {
22
+ return { packageName, version };
23
+ }
24
+
25
+ packageName = afterRegistry.substring(0, separatorIndex);
26
+ const filename = afterRegistry.substring(
27
+ separatorIndex + 3,
28
+ afterRegistry.length - 4
29
+ ); // Remove /-/ and .tgz
30
+
31
+ // Extract version from filename
32
+ // For scoped packages like @babel/core, the filename is core-7.21.4.tgz
33
+ // For regular packages like lodash, the filename is lodash-4.17.21.tgz
34
+ if (packageName.startsWith("@")) {
35
+ const scopedPackageName = packageName.substring(
36
+ packageName.lastIndexOf("/") + 1
37
+ );
38
+ if (filename.startsWith(scopedPackageName + "-")) {
39
+ version = filename.substring(scopedPackageName.length + 1);
40
+ }
41
+ } else {
42
+ if (filename.startsWith(packageName + "-")) {
43
+ version = filename.substring(packageName.length + 1);
44
+ }
45
+ }
46
+
47
+ return { packageName, version };
48
+ }
@@ -0,0 +1,158 @@
1
+ import * as http from "http";
2
+ import { tunnelRequest } from "./tunnelRequestHandler.js";
3
+ import { mitmConnect } from "./mitmRequestHandler.js";
4
+ import { getCaCertPath } from "./certUtils.js";
5
+ import { auditChanges } from "../scanning/audit/index.js";
6
+ import { knownRegistries, parsePackageFromUrl } from "./parsePackageFromUrl.js";
7
+ import { ui } from "../environment/userInteraction.js";
8
+ import chalk from "chalk";
9
+
10
+ const SERVER_STOP_TIMEOUT_MS = 1000;
11
+ const state = {
12
+ port: null,
13
+ blockedRequests: [],
14
+ };
15
+
16
+ export function createSafeChainProxy() {
17
+ const server = createProxyServer();
18
+ server.on("connect", handleConnect);
19
+
20
+ return {
21
+ startServer: () => startServer(server),
22
+ stopServer: () => stopServer(server),
23
+ verifyNoMaliciousPackages,
24
+ };
25
+ }
26
+
27
+ function getSafeChainProxyEnvironmentVariables() {
28
+ if (!state.port) {
29
+ return {};
30
+ }
31
+
32
+ return {
33
+ HTTPS_PROXY: `http://localhost:${state.port}`,
34
+ GLOBAL_AGENT_HTTP_PROXY: `http://localhost:${state.port}`,
35
+ NODE_EXTRA_CA_CERTS: getCaCertPath(),
36
+ };
37
+ }
38
+
39
+ export function mergeSafeChainProxyEnvironmentVariables(env) {
40
+ const proxyEnv = getSafeChainProxyEnvironmentVariables();
41
+
42
+ for (const key of Object.keys(env)) {
43
+ // If we were to simply copy all env variables, we might overwrite
44
+ // the proxy settings set by safe-chain when casing varies (e.g. http_proxy vs HTTP_PROXY)
45
+ // So we only copy the variable if it's not already set in a different case
46
+ const upperKey = key.toUpperCase();
47
+
48
+ if (!proxyEnv[upperKey]) {
49
+ proxyEnv[key] = env[key];
50
+ }
51
+ }
52
+
53
+ return proxyEnv;
54
+ }
55
+
56
+ function createProxyServer() {
57
+ const server = http.createServer((_, res) => {
58
+ res.writeHead(400, "Bad Request");
59
+ res.write(
60
+ "Safe-chain proxy: Direct http not supported. Only CONNECT requests are allowed."
61
+ );
62
+ res.end();
63
+ });
64
+
65
+ return server;
66
+ }
67
+
68
+ function startServer(server) {
69
+ return new Promise((resolve, reject) => {
70
+ // Passing port 0 makes the OS assign an available port
71
+ server.listen(0, () => {
72
+ const address = server.address();
73
+ if (address && typeof address === "object") {
74
+ state.port = address.port;
75
+ resolve();
76
+ } else {
77
+ reject(new Error("Failed to start proxy server"));
78
+ }
79
+ });
80
+
81
+ server.on("error", (err) => {
82
+ reject(err);
83
+ });
84
+ });
85
+ }
86
+
87
+ function stopServer(server) {
88
+ return new Promise((resolve) => {
89
+ try {
90
+ server.close(() => {
91
+ resolve();
92
+ });
93
+ } catch {
94
+ resolve();
95
+ }
96
+ setTimeout(() => resolve(), SERVER_STOP_TIMEOUT_MS);
97
+ });
98
+ }
99
+
100
+ function handleConnect(req, clientSocket, head) {
101
+ // CONNECT method is used for HTTPS requests
102
+ // It establishes a tunnel to the server identified by the request URL
103
+
104
+ if (knownRegistries.some((reg) => req.url.includes(reg))) {
105
+ // For npm and yarn registries, we want to intercept and inspect the traffic
106
+ // so we can block packages with malware
107
+ mitmConnect(req, clientSocket, isAllowedUrl);
108
+ } else {
109
+ // For other hosts, just tunnel the request to the destination tcp socket
110
+ tunnelRequest(req, clientSocket, head);
111
+ }
112
+ }
113
+
114
+ async function isAllowedUrl(url) {
115
+ const { packageName, version } = parsePackageFromUrl(url);
116
+
117
+ // packageName and version are undefined when the URL is not a package download
118
+ // In that case, we can allow the request to proceed
119
+ if (!packageName || !version) {
120
+ return true;
121
+ }
122
+
123
+ const auditResult = await auditChanges([
124
+ { name: packageName, version, type: "add" },
125
+ ]);
126
+
127
+ if (!auditResult.isAllowed) {
128
+ state.blockedRequests.push({ packageName, version, url });
129
+ return false;
130
+ }
131
+
132
+ return true;
133
+ }
134
+
135
+ function verifyNoMaliciousPackages() {
136
+ if (state.blockedRequests.length === 0) {
137
+ // No malicious packages were blocked, so nothing to block
138
+ return true;
139
+ }
140
+
141
+ ui.emptyLine();
142
+
143
+ ui.writeInformation(
144
+ `Safe-chain: ${chalk.bold(
145
+ `blocked ${state.blockedRequests.length} malicious package downloads`
146
+ )}:`
147
+ );
148
+
149
+ for (const req of state.blockedRequests) {
150
+ ui.writeInformation(` - ${req.packageName}@${req.version} (${req.url})`);
151
+ }
152
+
153
+ ui.emptyLine();
154
+ ui.writeError("Exiting without installing malicious packages.");
155
+ ui.emptyLine();
156
+
157
+ return false;
158
+ }
@@ -0,0 +1,98 @@
1
+ import * as net from "net";
2
+ import { ui } from "../environment/userInteraction.js";
3
+
4
+ export function tunnelRequest(req, clientSocket, head) {
5
+ const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy;
6
+
7
+ if (httpsProxy) {
8
+ // If an HTTPS proxy is set, tunnel the request via the proxy
9
+ // This is the system proxy, not the safe-chain proxy
10
+ // The package manager will run via the safe-chain proxy
11
+ // The safe-chain proxy will then send the request to the system proxy
12
+ // Typical flow: package manager -> safe-chain proxy -> system proxy -> destination
13
+
14
+ // There are 2 processes involved in this:
15
+ // 1. Safe-chain process: has HTTPS_PROXY set to system proxy
16
+ // 2. Package manager process: has HTTPS_PROXY set to safe-chain proxy
17
+
18
+ tunnelRequestViaProxy(req, clientSocket, head, httpsProxy);
19
+ } else {
20
+ tunnelRequestToDestination(req, clientSocket, head);
21
+ }
22
+ }
23
+
24
+ function tunnelRequestToDestination(req, clientSocket, head) {
25
+ const { port, hostname } = new URL(`http://${req.url}`);
26
+
27
+ const serverSocket = net.connect(port || 443, hostname, () => {
28
+ clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
29
+ serverSocket.write(head);
30
+ serverSocket.pipe(clientSocket);
31
+ clientSocket.pipe(serverSocket);
32
+ });
33
+
34
+ serverSocket.on("error", (err) => {
35
+ ui.writeError(
36
+ `Safe-chain: error connecting to ${hostname}:${port} - ${err.message}`
37
+ );
38
+ clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
39
+ });
40
+ }
41
+
42
+ function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) {
43
+ const { port, hostname } = new URL(`http://${req.url}`);
44
+ const proxy = new URL(proxyUrl);
45
+
46
+ // Connect to proxy server
47
+ const proxySocket = net.connect({
48
+ host: proxy.hostname,
49
+ port: proxy.port,
50
+ });
51
+
52
+ proxySocket.on("connect", () => {
53
+ // Send CONNECT request to proxy
54
+ const connectRequest = [
55
+ `CONNECT ${hostname}:${port || 443} HTTP/1.1`,
56
+ `Host: ${hostname}:${port || 443}`,
57
+ "",
58
+ "",
59
+ ].join("\r\n");
60
+
61
+ proxySocket.write(connectRequest);
62
+ });
63
+
64
+ let isConnected = false;
65
+ proxySocket.once("data", (data) => {
66
+ const response = data.toString();
67
+
68
+ // Check if CONNECT succeeded (HTTP/1.1 200)
69
+ if (response.startsWith("HTTP/1.1 200")) {
70
+ isConnected = true;
71
+ clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
72
+ proxySocket.write(head);
73
+ proxySocket.pipe(clientSocket);
74
+ clientSocket.pipe(proxySocket);
75
+ } else {
76
+ ui.writeError(
77
+ `Safe-chain: proxy CONNECT failed: ${response.split("\r\n")[0]}`
78
+ );
79
+ clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
80
+ proxySocket.end();
81
+ }
82
+ });
83
+
84
+ proxySocket.on("error", (err) => {
85
+ if (!isConnected) {
86
+ ui.writeError(
87
+ `Safe-chain: error connecting to proxy ${proxy.hostname}:${
88
+ proxy.port || 8080
89
+ } - ${err.message}`
90
+ );
91
+ clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
92
+ }
93
+ });
94
+
95
+ clientSocket.on("error", () => {
96
+ proxySocket.end();
97
+ });
98
+ }
@@ -61,10 +61,11 @@ export async function scanCommand(args) {
61
61
  }
62
62
 
63
63
  if (!audit || audit.isAllowed) {
64
- spinner.succeed("Safe-chain: No malicious packages detected.");
64
+ spinner.stop();
65
+ return 0;
65
66
  } else {
66
67
  printMaliciousChanges(audit.disallowedChanges, spinner);
67
- await onMalwareFound();
68
+ return await onMalwareFound();
68
69
  }
69
70
  }
70
71
 
@@ -88,11 +89,11 @@ async function onMalwareFound() {
88
89
 
89
90
  if (continueInstall) {
90
91
  ui.writeWarning("Continuing with the installation despite the risks...");
91
- return;
92
+ return 0;
92
93
  }
93
94
  }
94
95
 
95
96
  ui.writeError("Exiting without installing malicious packages.");
96
97
  ui.emptyLine();
97
- process.exit(1);
98
+ return 1;
98
99
  }
@@ -8,7 +8,13 @@ import {
8
8
  } from "../config/configFile.js";
9
9
  import { ui } from "../environment/userInteraction.js";
10
10
 
11
+ let cachedMalwareDatabase = null;
12
+
11
13
  export async function openMalwareDatabase() {
14
+ if (cachedMalwareDatabase) {
15
+ return cachedMalwareDatabase;
16
+ }
17
+
12
18
  const malwareDatabase = await getMalwareDatabase();
13
19
 
14
20
  function getPackageStatus(name, version) {
@@ -25,13 +31,16 @@ export async function openMalwareDatabase() {
25
31
  return packageData.reason;
26
32
  }
27
33
 
28
- return {
34
+ // This implicitely caches the malware database
35
+ // that's closed over by the getPackageStatus function
36
+ cachedMalwareDatabase = {
29
37
  getPackageStatus,
30
38
  isMalware: (name, version) => {
31
39
  const status = getPackageStatus(name, version);
32
40
  return isMalwareStatus(status);
33
41
  },
34
42
  };
43
+ return cachedMalwareDatabase;
35
44
  }
36
45
 
37
46
  async function getMalwareDatabase() {
@@ -9,8 +9,9 @@ export const knownAikidoTools = [
9
9
  { tool: "yarn", aikidoCommand: "aikido-yarn" },
10
10
  { tool: "pnpm", aikidoCommand: "aikido-pnpm" },
11
11
  { tool: "pnpx", aikidoCommand: "aikido-pnpx" },
12
- // When adding a new tool here, also update the expected alias in the tests (setup.spec.js, teardown.spec.js)
13
- // and add the documentation for the new tool in the README.md
12
+ { tool: "bun", aikidoCommand: "aikido-bun" },
13
+ { tool: "bunx", aikidoCommand: "aikido-bunx" },
14
+ // When adding a new tool here, also update the documentation for the new tool in the README.md
14
15
  ];
15
16
 
16
17
  /**
@@ -18,15 +19,15 @@ export const knownAikidoTools = [
18
19
  * Example: "npm, npx, yarn, pnpm, and pnpx commands"
19
20
  */
20
21
  export function getPackageManagerList() {
21
- const tools = knownAikidoTools.map(t => t.tool);
22
+ const tools = knownAikidoTools.map((t) => t.tool);
22
23
  if (tools.length <= 1) {
23
- return `${tools[0] || ''} commands`;
24
+ return `${tools[0] || ""} commands`;
24
25
  }
25
26
  if (tools.length === 2) {
26
27
  return `${tools[0]} and ${tools[1]} commands`;
27
28
  }
28
29
  const lastTool = tools.pop();
29
- return `${tools.join(', ')}, and ${lastTool} commands`;
30
+ return `${tools.join(", ")}, and ${lastTool} commands`;
30
31
  }
31
32
 
32
33
  export function doesExecutableExistOnSystem(executableName) {
@@ -47,7 +48,7 @@ export function removeLinesMatchingPattern(filePath, pattern, eol) {
47
48
  eol = eol || os.EOL;
48
49
 
49
50
  const fileContent = fs.readFileSync(filePath, "utf-8");
50
- const lines = fileContent.split(/[\r\n\u2028\u2029]/);
51
+ const lines = fileContent.split(/\r?\n|\r|\u2028|\u2029/);
51
52
  const updatedLines = lines.filter((line) => !shouldRemoveLine(line, pattern));
52
53
  fs.writeFileSync(filePath, updatedLines.join(eol), "utf-8");
53
54
  }
@@ -46,6 +46,14 @@ function pnpx
46
46
  wrapSafeChainCommand "pnpx" "aikido-pnpx" $argv
47
47
  end
48
48
 
49
+ function bun
50
+ wrapSafeChainCommand "bun" "aikido-bun" $argv
51
+ end
52
+
53
+ function bunx
54
+ wrapSafeChainCommand "bunx" "aikido-bunx" $argv
55
+ end
56
+
49
57
  function npm
50
58
  # If args is just -v or --version and nothing else, just run the `npm -v` command
51
59
  # This is because nvm uses this to check the version of npm
@@ -42,6 +42,14 @@ function pnpx() {
42
42
  wrapSafeChainCommand "pnpx" "aikido-pnpx" "$@"
43
43
  }
44
44
 
45
+ function bun() {
46
+ wrapSafeChainCommand "bun" "aikido-bun" "$@"
47
+ }
48
+
49
+ function bunx() {
50
+ wrapSafeChainCommand "bunx" "aikido-bunx" "$@"
51
+ }
52
+
45
53
  function npm() {
46
54
  if [[ "$1" == "-v" || "$1" == "--version" ]] && [[ $# -eq 1 ]]; then
47
55
  # If args is just -v or --version and nothing else, just run the npm version command
@@ -68,6 +68,14 @@ function pnpx {
68
68
  Invoke-WrappedCommand "pnpx" "aikido-pnpx" $args
69
69
  }
70
70
 
71
+ function bun {
72
+ Invoke-WrappedCommand "bun" "aikido-bun" $args
73
+ }
74
+
75
+ function bunx {
76
+ Invoke-WrappedCommand "bunx" "aikido-bunx" $args
77
+ }
78
+
71
79
  function npm {
72
80
  # If args is just -v or --version and nothing else, just run the npm version command
73
81
  # This is because nvm uses this to check the version of npm
@@ -23,11 +23,23 @@ export async function safeSpawn(command, args, options = {}) {
23
23
  return new Promise((resolve, reject) => {
24
24
  const child = spawn(fullCommand, { ...options, shell: true });
25
25
 
26
+ // When stdio is piped, we need to collect the output
27
+ let stdout = "";
28
+ let stderr = "";
29
+
30
+ child.stdout?.on("data", (data) => {
31
+ stdout += data.toString();
32
+ });
33
+
34
+ child.stderr?.on("data", (data) => {
35
+ stderr += data.toString();
36
+ });
37
+
26
38
  child.on("close", (code) => {
27
39
  resolve({
28
40
  status: code,
29
- stdout: Buffer.from(""),
30
- stderr: Buffer.from(""),
41
+ stdout: stdout,
42
+ stderr: stderr,
31
43
  });
32
44
  });
33
45