@aikidosec/safe-chain 1.0.24 → 1.1.1
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 +13 -16
- package/bin/aikido-bun.js +10 -0
- package/bin/aikido-bunx.js +10 -0
- package/bin/aikido-npm.js +3 -1
- package/bin/aikido-npx.js +3 -1
- package/bin/aikido-pnpm.js +3 -1
- package/bin/aikido-pnpx.js +3 -1
- package/bin/aikido-yarn.js +3 -1
- package/docs/shell-integration.md +4 -4
- package/package.json +6 -2
- package/src/main.js +35 -5
- package/src/packagemanager/bun/createBunPackageManager.js +42 -0
- package/src/packagemanager/currentPackageManager.js +8 -0
- package/src/packagemanager/npm/dependencyScanner/dryRunScanner.js +3 -2
- package/src/packagemanager/npm/runNpmCommand.js +26 -10
- package/src/packagemanager/npx/runNpxCommand.js +8 -5
- package/src/packagemanager/pnpm/runPnpmCommand.js +11 -4
- package/src/packagemanager/yarn/runYarnCommand.js +41 -5
- package/src/registryProxy/certUtils.js +114 -0
- package/src/registryProxy/mitmRequestHandler.js +90 -0
- package/src/registryProxy/parsePackageFromUrl.js +48 -0
- package/src/registryProxy/registryProxy.js +158 -0
- package/src/registryProxy/tunnelRequestHandler.js +98 -0
- package/src/scanning/index.js +5 -4
- package/src/scanning/malwareDatabase.js +10 -1
- package/src/shell-integration/helpers.js +7 -6
- package/src/shell-integration/startup-scripts/init-fish.fish +8 -0
- package/src/shell-integration/startup-scripts/init-posix.sh +8 -0
- package/src/shell-integration/startup-scripts/init-pwsh.ps1 +8 -0
- package/src/utils/safeSpawn.js +14 -2
package/README.md
CHANGED
|
@@ -1,23 +1,20 @@
|
|
|
1
1
|
# Aikido Safe Chain
|
|
2
2
|
|
|
3
|
-
The Aikido Safe Chain **prevents developers from installing malware** on their workstations through npm, npx, yarn, pnpm and
|
|
3
|
+
The Aikido Safe Chain **prevents developers from installing malware** on their workstations through npm, npx, yarn, pnpm, pnpx, bun, and bunx. It's **free** to use and does not require any token.
|
|
4
4
|
|
|
5
|
-
The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/),
|
|
5
|
+
The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), and [bunx](https://bun.sh/docs/cli/bunx) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, or bunx from downloading or running the malware.
|
|
6
6
|
|
|
7
7
|

|
|
8
8
|
|
|
9
9
|
Aikido Safe Chain works on Node.js version 18 and above and supports the following package managers:
|
|
10
10
|
|
|
11
|
-
- ✅
|
|
12
|
-
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
- 🚧 **bun**: coming soon
|
|
19
|
-
|
|
20
|
-
Note on the limited support for npm < 10.4.0, npx, yarn, pnpm and pnpx: adding **full support for these package managers is a high priority**. In the meantime, we offer limited support already, which means that the Aikido Safe Chain will scan the package names passed as arguments to the install commands. However, it will not scan the full dependency tree of these packages.
|
|
11
|
+
- ✅ **npm**
|
|
12
|
+
- ✅ **npx**
|
|
13
|
+
- ✅ **yarn**
|
|
14
|
+
- ✅ **pnpm**
|
|
15
|
+
- ✅ **pnpx**
|
|
16
|
+
- ✅ **bun**
|
|
17
|
+
- ✅ **bunx**
|
|
21
18
|
|
|
22
19
|
# Usage
|
|
23
20
|
|
|
@@ -34,20 +31,20 @@ Installing the Aikido Safe Chain is easy. You just need 3 simple steps:
|
|
|
34
31
|
safe-chain setup
|
|
35
32
|
```
|
|
36
33
|
3. **❗Restart your terminal** to start using the Aikido Safe Chain.
|
|
37
|
-
- This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm and
|
|
34
|
+
- This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, and bunx are loaded correctly. If you do not restart your terminal, the aliases will not be available.
|
|
38
35
|
4. **Verify the installation** by running:
|
|
39
36
|
```shell
|
|
40
37
|
npm install safe-chain-test
|
|
41
38
|
```
|
|
42
39
|
- The output should show that Aikido Safe Chain is blocking the installation of this package as it is flagged as malware.
|
|
43
40
|
|
|
44
|
-
When running `npm`, `npx`, `yarn`, `pnpm` or `
|
|
41
|
+
When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, or `bunx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. If any malware is detected, it will prompt you to exit the command.
|
|
45
42
|
|
|
46
43
|
## How it works
|
|
47
44
|
|
|
48
|
-
The Aikido Safe Chain works by
|
|
45
|
+
The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry. When you run npm, npx, yarn, pnpm, pnpx, bun, or bunx 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.
|
|
49
46
|
|
|
50
|
-
The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm and
|
|
47
|
+
The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, and bunx commands. It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support:
|
|
51
48
|
|
|
52
49
|
- ✅ **Bash**
|
|
53
50
|
- ✅ **Zsh**
|
|
@@ -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);
|
package/bin/aikido-pnpm.js
CHANGED
|
@@ -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);
|
package/bin/aikido-pnpx.js
CHANGED
|
@@ -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);
|
package/bin/aikido-yarn.js
CHANGED
|
@@ -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);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Overview
|
|
4
4
|
|
|
5
|
-
The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`) with Aikido's security scanning functionality. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents.
|
|
5
|
+
The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`) with Aikido's security scanning functionality. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents.
|
|
6
6
|
|
|
7
7
|
## Supported Shells
|
|
8
8
|
|
|
@@ -28,7 +28,7 @@ This command:
|
|
|
28
28
|
|
|
29
29
|
- Copies necessary startup scripts to Safe Chain's installation directory (`~/.safe-chain/scripts`)
|
|
30
30
|
- Detects all supported shells on your system
|
|
31
|
-
- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, and `
|
|
31
|
+
- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, and `bunx`
|
|
32
32
|
|
|
33
33
|
❗ After running this command, **you must restart your terminal** for the changes to take effect. This ensures that the startup scripts are sourced correctly.
|
|
34
34
|
|
|
@@ -77,7 +77,7 @@ The system modifies the following files to source Safe Chain startup scripts:
|
|
|
77
77
|
This means the shell functions are working but the Aikido commands aren't installed or available in your PATH:
|
|
78
78
|
|
|
79
79
|
- Make sure Aikido Safe Chain is properly installed on your system
|
|
80
|
-
- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm` and `aikido-
|
|
80
|
+
- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-bun`, and `aikido-bunx` commands exist
|
|
81
81
|
- Check that these commands are in your system's PATH
|
|
82
82
|
|
|
83
83
|
### Manual Verification
|
|
@@ -120,4 +120,4 @@ npm() {
|
|
|
120
120
|
}
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
-
Repeat this pattern for `npx`, `yarn`, `pnpm`, and `
|
|
123
|
+
Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, and `bunx` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aikidosec/safe-chain",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
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",
|
|
@@ -26,11 +28,13 @@
|
|
|
26
28
|
"keywords": [],
|
|
27
29
|
"author": "Aikido Security",
|
|
28
30
|
"license": "AGPL-3.0-or-later",
|
|
29
|
-
"description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/),
|
|
31
|
+
"description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), and [bunx](https://bun.sh/docs/cli/bunx) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, or bunx from downloading or running the malware.",
|
|
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
|
-
|
|
22
|
-
|
|
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
|
|
7
|
-
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
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 =
|
|
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
|
|
7
|
-
|
|
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 {
|
|
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 =
|
|
9
|
+
result = await safeSpawn("pnpm", args, {
|
|
10
|
+
stdio: "inherit",
|
|
11
|
+
env: mergeSafeChainProxyEnvironmentVariables(process.env),
|
|
12
|
+
});
|
|
9
13
|
} else if (toolName === "pnpx") {
|
|
10
|
-
result =
|
|
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
|
|
7
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/scanning/index.js
CHANGED
|
@@ -61,10 +61,11 @@ export async function scanCommand(args) {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
if (!audit || audit.isAllowed) {
|
|
64
|
-
spinner.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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] ||
|
|
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(
|
|
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(
|
|
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
|
package/src/utils/safeSpawn.js
CHANGED
|
@@ -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:
|
|
30
|
-
stderr:
|
|
41
|
+
stdout: stdout,
|
|
42
|
+
stderr: stderr,
|
|
31
43
|
});
|
|
32
44
|
});
|
|
33
45
|
|