mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Merge branch 'main' into escape-special-chars-in-shell
This commit is contained in:
commit
8447d3cac5
62 changed files with 2212 additions and 4032 deletions
5
.github/workflows/build-and-release.yml
vendored
5
.github/workflows/build-and-release.yml
vendored
|
|
@ -21,6 +21,11 @@ jobs:
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
|
||||||
|
|
||||||
|
- name: Setup safe-chain
|
||||||
|
run: |
|
||||||
|
npm i -g @aikidosec/safe-chain
|
||||||
|
safe-chain setup-ci
|
||||||
|
|
||||||
- name: Set version number
|
- name: Set version number
|
||||||
id: get_version
|
id: get_version
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
18
.github/workflows/test-on-pr.yml
vendored
18
.github/workflows/test-on-pr.yml
vendored
|
|
@ -17,13 +17,18 @@ jobs:
|
||||||
with:
|
with:
|
||||||
node-version: "lts/*"
|
node-version: "lts/*"
|
||||||
|
|
||||||
|
- name: Setup safe-chain
|
||||||
|
run: |
|
||||||
|
npm i -g @aikidosec/safe-chain
|
||||||
|
safe-chain setup-ci
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
- name: Run ESLint
|
- name: Run linting
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
|
|
||||||
- name: Create package tarball
|
- name: Create package tarball
|
||||||
|
|
@ -46,7 +51,7 @@ jobs:
|
||||||
include:
|
include:
|
||||||
# Common production setup
|
# Common production setup
|
||||||
- node_version: "20"
|
- node_version: "20"
|
||||||
npm_version: "10.0.0"
|
npm_version: "10.2.0"
|
||||||
yarn_version: "4.0.0"
|
yarn_version: "4.0.0"
|
||||||
pnpm_version: "9.0.0"
|
pnpm_version: "9.0.0"
|
||||||
# Current Active LTS with latest tools
|
# Current Active LTS with latest tools
|
||||||
|
|
@ -66,7 +71,7 @@ jobs:
|
||||||
pnpm_version: "latest"
|
pnpm_version: "latest"
|
||||||
# Version pinning scenario
|
# Version pinning scenario
|
||||||
- node_version: "22"
|
- node_version: "22"
|
||||||
npm_version: "10.0.0"
|
npm_version: "10.2.0"
|
||||||
yarn_version: "4.0.0"
|
yarn_version: "4.0.0"
|
||||||
pnpm_version: "9.0.0"
|
pnpm_version: "9.0.0"
|
||||||
# Backward compatibility testing
|
# Backward compatibility testing
|
||||||
|
|
@ -82,7 +87,7 @@ jobs:
|
||||||
# EOL compatibility testing - Node 16 (EOL Sept 2023)
|
# EOL compatibility testing - Node 16 (EOL Sept 2023)
|
||||||
- node_version: "16"
|
- node_version: "16"
|
||||||
npm_version: "8.0.0"
|
npm_version: "8.0.0"
|
||||||
yarn_version: "3.6.0"
|
yarn_version: "1.22.0"
|
||||||
pnpm_version: "8.0.0"
|
pnpm_version: "8.0.0"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -94,6 +99,11 @@ jobs:
|
||||||
with:
|
with:
|
||||||
node-version: "lts/*"
|
node-version: "lts/*"
|
||||||
|
|
||||||
|
- name: Setup safe-chain
|
||||||
|
run: |
|
||||||
|
npm i -g @aikidosec/safe-chain@1.0.24
|
||||||
|
safe-chain setup-ci
|
||||||
|
|
||||||
- name: Install dependencies (root)
|
- name: Install dependencies (root)
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
|
|
||||||
29
.oxlintrc.json
Normal file
29
.oxlintrc.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": [
|
||||||
|
"node",
|
||||||
|
"promise",
|
||||||
|
"eslint",
|
||||||
|
"unicorn",
|
||||||
|
"oxc",
|
||||||
|
"import"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"browser": false,
|
||||||
|
"node": true
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"eslint/no-console": "error",
|
||||||
|
"eslint/no-empty": "error"
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"*.spec.js"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"eslint/no-console": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
34
README.md
34
README.md
|
|
@ -1,23 +1,20 @@
|
||||||
# Aikido Safe Chain
|
# Aikido Safe Chain
|
||||||
|
|
||||||
The Aikido Safe Chain **prevents developers from installing malware** on their workstations through npm, npx, yarn, pnpm and pnpx. It's **free** to use and does not require any token.
|
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.
|
||||||
|
|
||||||
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/), and [pnpx](https://pnpm.io/cli/dlx) 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 or pnpx from downloading or running the malware.
|
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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Aikido Safe Chain works on Node.js version 18 and above and supports the following package managers:
|
Aikido Safe Chain works on Node.js version 18 and above and supports the following package managers:
|
||||||
|
|
||||||
- ✅ full coverage: **npm >= 10.4.0**:
|
- ✅ **npm**
|
||||||
- ⚠️ limited to scanning the install command arguments (broader scanning coming soon):
|
- ✅ **npx**
|
||||||
- **npm < 10.4.0**
|
- ✅ **yarn**
|
||||||
- **npx**
|
- ✅ **pnpm**
|
||||||
- **yarn**
|
- ✅ **pnpx**
|
||||||
- **pnpm**
|
- ✅ **bun**
|
||||||
- **pnpx**
|
- ✅ **bunx**
|
||||||
- 🚧 **bun**: coming soon
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
# Usage
|
# Usage
|
||||||
|
|
||||||
|
|
@ -34,20 +31,25 @@ Installing the Aikido Safe Chain is easy. You just need 3 simple steps:
|
||||||
safe-chain setup
|
safe-chain setup
|
||||||
```
|
```
|
||||||
3. **❗Restart your terminal** to start using the Aikido Safe Chain.
|
3. **❗Restart your terminal** to start using the Aikido Safe Chain.
|
||||||
- This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm and pnpx are loaded correctly. If you do not restart your terminal, the aliases will not be available.
|
- 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.
|
||||||
4. **Verify the installation** by running:
|
4. **Verify the installation** by running:
|
||||||
```shell
|
```shell
|
||||||
npm install safe-chain-test
|
npm install safe-chain-test
|
||||||
```
|
```
|
||||||
- The output should show that Aikido Safe Chain is blocking the installation of this package as it is flagged as malware.
|
- The output should show that Aikido Safe Chain is blocking the installation of this package as it is flagged as malware.
|
||||||
|
|
||||||
When running `npm`, `npx`, `yarn`, `pnpm` or `pnpx` 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.
|
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.
|
||||||
|
|
||||||
|
You can check the installed version by running:
|
||||||
|
```shell
|
||||||
|
safe-chain --version
|
||||||
|
```
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
The Aikido Safe Chain works by intercepting the npm, npx, yarn, pnpm and pnpx commands and verifying the packages against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**.
|
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.
|
||||||
|
|
||||||
The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm and pnpx commands. It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which perform malware checks before executing the original commands. We currently support:
|
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:
|
||||||
|
|
||||||
- ✅ **Bash**
|
- ✅ **Bash**
|
||||||
- ✅ **Zsh**
|
- ✅ **Zsh**
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Supported Shells
|
## Supported Shells
|
||||||
|
|
||||||
|
|
@ -28,7 +28,7 @@ This command:
|
||||||
|
|
||||||
- Copies necessary startup scripts to Safe Chain's installation directory (`~/.safe-chain/scripts`)
|
- Copies necessary startup scripts to Safe Chain's installation directory (`~/.safe-chain/scripts`)
|
||||||
- Detects all supported shells on your system
|
- Detects all supported shells on your system
|
||||||
- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, and `pnpx`
|
- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, and `bunx`
|
||||||
|
|
||||||
❗ After running this command, **you must restart your terminal** for the changes to take effect. This ensures that the startup scripts are sourced correctly.
|
❗ After running this command, **you must restart your terminal** for the changes to take effect. This ensures that the startup scripts are sourced correctly.
|
||||||
|
|
||||||
|
|
@ -77,7 +77,7 @@ The system modifies the following files to source Safe Chain startup scripts:
|
||||||
This means the shell functions are working but the Aikido commands aren't installed or available in your PATH:
|
This means the shell functions are working but the Aikido commands aren't installed or available in your PATH:
|
||||||
|
|
||||||
- Make sure Aikido Safe Chain is properly installed on your system
|
- Make sure Aikido Safe Chain is properly installed on your system
|
||||||
- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm` and `aikido-pnpx` commands exist
|
- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-bun`, and `aikido-bunx` commands exist
|
||||||
- Check that these commands are in your system's PATH
|
- Check that these commands are in your system's PATH
|
||||||
|
|
||||||
### Manual Verification
|
### Manual Verification
|
||||||
|
|
@ -120,4 +120,4 @@ npm() {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Repeat this pattern for `npx`, `yarn`, `pnpm`, and `pnpx` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import js from "@eslint/js";
|
|
||||||
import { defineConfig, globalIgnores } from "@eslint/config-helpers";
|
|
||||||
import globals from "globals";
|
|
||||||
import importPlugin from "eslint-plugin-import";
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
{
|
|
||||||
files: ["**/*.{js,mjs,cjs,ts}"],
|
|
||||||
plugins: { js },
|
|
||||||
extends: ["js/recommended"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ["**/*.{js,mjs,cjs,ts}"],
|
|
||||||
languageOptions: { globals: globals.node },
|
|
||||||
},
|
|
||||||
importPlugin.flatConfigs.recommended,
|
|
||||||
{
|
|
||||||
files: ["**/*.{js,mjs,cjs}"],
|
|
||||||
languageOptions: {
|
|
||||||
ecmaVersion: "latest",
|
|
||||||
sourceType: "module",
|
|
||||||
},
|
|
||||||
rules: {},
|
|
||||||
},
|
|
||||||
globalIgnores(['test/e2e', 'node_modules']),
|
|
||||||
]);
|
|
||||||
3434
package-lock.json
generated
3434
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -18,13 +18,6 @@
|
||||||
"author": "Aikido Security",
|
"author": "Aikido Security",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.35.0",
|
"oxlint": "^1.22.0"
|
||||||
"eslint": "^9.35.0",
|
|
||||||
"eslint-plugin-import": "^2.32.0",
|
|
||||||
"globals": "^16.1.0",
|
|
||||||
"typescript-eslint": "^8.32.0"
|
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"brace-expansion@<=2.0.2": "2.0.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// oxlint-disable no-console
|
||||||
import { auditChanges } from "@aikidosec/safe-chain/scanning";
|
import { auditChanges } from "@aikidosec/safe-chain/scanning";
|
||||||
|
|
||||||
// Bun Security Scanner for Safe-Chain
|
// Bun Security Scanner for Safe-Chain
|
||||||
|
|
|
||||||
10
packages/safe-chain/bin/aikido-bun.js
Executable file
10
packages/safe-chain/bin/aikido-bun.js
Executable file
|
|
@ -0,0 +1,10 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { main } from "../src/main.js";
|
||||||
|
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||||
|
|
||||||
|
const packageManagerName = "bun";
|
||||||
|
initializePackageManager(packageManagerName);
|
||||||
|
var exitCode = await main(process.argv.slice(2));
|
||||||
|
|
||||||
|
process.exit(exitCode);
|
||||||
10
packages/safe-chain/bin/aikido-bunx.js
Executable file
10
packages/safe-chain/bin/aikido-bunx.js
Executable file
|
|
@ -0,0 +1,10 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { main } from "../src/main.js";
|
||||||
|
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||||
|
|
||||||
|
const packageManagerName = "bunx";
|
||||||
|
initializePackageManager(packageManagerName);
|
||||||
|
var exitCode = await main(process.argv.slice(2));
|
||||||
|
|
||||||
|
process.exit(exitCode);
|
||||||
|
|
@ -1,19 +1,10 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { execSync } from "child_process";
|
|
||||||
import { main } from "../src/main.js";
|
import { main } from "../src/main.js";
|
||||||
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||||
|
|
||||||
const packageManagerName = "npm";
|
const packageManagerName = "npm";
|
||||||
initializePackageManager(packageManagerName, getNpmVersion());
|
initializePackageManager(packageManagerName);
|
||||||
await main(process.argv.slice(2));
|
var exitCode = await main(process.argv.slice(2));
|
||||||
|
|
||||||
function getNpmVersion() {
|
process.exit(exitCode);
|
||||||
try {
|
|
||||||
return execSync("npm --version").toString().trim();
|
|
||||||
} catch {
|
|
||||||
// Default to 0.0.0 if npm is not found
|
|
||||||
// That way we don't use any unsupported features
|
|
||||||
return "0.0.0";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -4,5 +4,7 @@ import { main } from "../src/main.js";
|
||||||
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||||
|
|
||||||
const packageManagerName = "npx";
|
const packageManagerName = "npx";
|
||||||
initializePackageManager(packageManagerName, process.versions.node);
|
initializePackageManager(packageManagerName);
|
||||||
await main(process.argv.slice(2));
|
var exitCode = await main(process.argv.slice(2));
|
||||||
|
|
||||||
|
process.exit(exitCode);
|
||||||
|
|
|
||||||
|
|
@ -4,5 +4,7 @@ import { main } from "../src/main.js";
|
||||||
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||||
|
|
||||||
const packageManagerName = "pnpm";
|
const packageManagerName = "pnpm";
|
||||||
initializePackageManager(packageManagerName, process.versions.node);
|
initializePackageManager(packageManagerName);
|
||||||
await main(process.argv.slice(2));
|
var exitCode = await main(process.argv.slice(2));
|
||||||
|
|
||||||
|
process.exit(exitCode);
|
||||||
|
|
|
||||||
|
|
@ -4,5 +4,7 @@ import { main } from "../src/main.js";
|
||||||
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||||
|
|
||||||
const packageManagerName = "pnpx";
|
const packageManagerName = "pnpx";
|
||||||
initializePackageManager(packageManagerName, process.versions.node);
|
initializePackageManager(packageManagerName);
|
||||||
await main(process.argv.slice(2));
|
var exitCode = await main(process.argv.slice(2));
|
||||||
|
|
||||||
|
process.exit(exitCode);
|
||||||
|
|
|
||||||
|
|
@ -4,5 +4,7 @@ import { main } from "../src/main.js";
|
||||||
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||||
|
|
||||||
const packageManagerName = "yarn";
|
const packageManagerName = "yarn";
|
||||||
initializePackageManager(packageManagerName, process.versions.node);
|
initializePackageManager(packageManagerName);
|
||||||
await main(process.argv.slice(2));
|
var exitCode = await main(process.argv.slice(2));
|
||||||
|
|
||||||
|
process.exit(exitCode);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
|
import { createRequire } from "module";
|
||||||
import { ui } from "../src/environment/userInteraction.js";
|
import { ui } from "../src/environment/userInteraction.js";
|
||||||
import { setup } from "../src/shell-integration/setup.js";
|
import { setup } from "../src/shell-integration/setup.js";
|
||||||
import { teardown } from "../src/shell-integration/teardown.js";
|
import { teardown } from "../src/shell-integration/teardown.js";
|
||||||
|
|
@ -26,6 +27,8 @@ if (command === "setup") {
|
||||||
teardown();
|
teardown();
|
||||||
} else if (command === "setup-ci") {
|
} else if (command === "setup-ci") {
|
||||||
setupCi();
|
setupCi();
|
||||||
|
} else if (command === "--version" || command === "-v" || command === "-v") {
|
||||||
|
ui.writeInformation(`Current safe-chain version: ${getVersion()}`);
|
||||||
} else {
|
} else {
|
||||||
ui.writeError(`Unknown command: ${command}.`);
|
ui.writeError(`Unknown command: ${command}.`);
|
||||||
ui.emptyLine();
|
ui.emptyLine();
|
||||||
|
|
@ -43,13 +46,15 @@ function writeHelp() {
|
||||||
ui.writeInformation(
|
ui.writeInformation(
|
||||||
`Available commands: ${chalk.cyan("setup")}, ${chalk.cyan(
|
`Available commands: ${chalk.cyan("setup")}, ${chalk.cyan(
|
||||||
"teardown"
|
"teardown"
|
||||||
)}, ${chalk.cyan("help")}`
|
)}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("help")}, ${chalk.cyan(
|
||||||
|
"--version"
|
||||||
|
)}`
|
||||||
);
|
);
|
||||||
ui.emptyLine();
|
ui.emptyLine();
|
||||||
ui.writeInformation(
|
ui.writeInformation(
|
||||||
`- ${chalk.cyan(
|
`- ${chalk.cyan(
|
||||||
"safe-chain setup"
|
"safe-chain setup"
|
||||||
)}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm and pnpx.`
|
)}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, bun and bunx.`
|
||||||
);
|
);
|
||||||
ui.writeInformation(
|
ui.writeInformation(
|
||||||
`- ${chalk.cyan(
|
`- ${chalk.cyan(
|
||||||
|
|
@ -61,5 +66,16 @@ function writeHelp() {
|
||||||
"safe-chain setup-ci"
|
"safe-chain setup-ci"
|
||||||
)}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.`
|
)}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.`
|
||||||
);
|
);
|
||||||
|
ui.writeInformation(
|
||||||
|
`- ${chalk.cyan(
|
||||||
|
"safe-chain --version"
|
||||||
|
)} (or ${chalk.cyan("-v")}): Display the current version of safe-chain.`
|
||||||
|
);
|
||||||
ui.emptyLine();
|
ui.emptyLine();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getVersion() {
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const packageJson = require("../package.json");
|
||||||
|
return packageJson.version;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test --experimental-test-module-mocks 'src/**/*.spec.js'",
|
"test": "node --test --experimental-test-module-mocks 'src/**/*.spec.js'",
|
||||||
"test:watch": "node --test --watch --experimental-test-module-mocks 'src/**/*.spec.js'",
|
"test:watch": "node --test --watch --experimental-test-module-mocks 'src/**/*.spec.js'",
|
||||||
"lint": "eslint ."
|
"lint": "oxlint --deny-warnings"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"aikido-npm": "bin/aikido-npm.js",
|
"aikido-npm": "bin/aikido-npm.js",
|
||||||
|
|
@ -12,6 +12,8 @@
|
||||||
"aikido-yarn": "bin/aikido-yarn.js",
|
"aikido-yarn": "bin/aikido-yarn.js",
|
||||||
"aikido-pnpm": "bin/aikido-pnpm.js",
|
"aikido-pnpm": "bin/aikido-pnpm.js",
|
||||||
"aikido-pnpx": "bin/aikido-pnpx.js",
|
"aikido-pnpx": "bin/aikido-pnpx.js",
|
||||||
|
"aikido-bun": "bin/aikido-bun.js",
|
||||||
|
"aikido-bunx": "bin/aikido-bunx.js",
|
||||||
"safe-chain": "bin/safe-chain.js"
|
"safe-chain": "bin/safe-chain.js"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
@ -26,11 +28,12 @@
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "Aikido Security",
|
"author": "Aikido Security",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"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/), and [pnpx](https://pnpm.io/cli/dlx) 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, or pnpx from downloading or running the malware.",
|
"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.",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"abbrev": "3.0.1",
|
|
||||||
"chalk": "5.4.1",
|
"chalk": "5.4.1",
|
||||||
|
"https-proxy-agent": "7.0.6",
|
||||||
"make-fetch-happen": "14.0.3",
|
"make-fetch-happen": "14.0.3",
|
||||||
|
"node-forge": "1.3.1",
|
||||||
"npm-registry-fetch": "18.0.2",
|
"npm-registry-fetch": "18.0.2",
|
||||||
"ora": "8.2.0",
|
"ora": "8.2.0",
|
||||||
"semver": "7.7.2"
|
"semver": "7.7.2"
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// oxlint-disable no-console
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import ora from "ora";
|
import ora from "ora";
|
||||||
import { createInterface } from "readline";
|
import { createInterface } from "readline";
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,50 @@ import { scanCommand, shouldScanCommand } from "./scanning/index.js";
|
||||||
import { ui } from "./environment/userInteraction.js";
|
import { ui } from "./environment/userInteraction.js";
|
||||||
import { getPackageManager } from "./packagemanager/currentPackageManager.js";
|
import { getPackageManager } from "./packagemanager/currentPackageManager.js";
|
||||||
import { initializeCliArguments } from "./config/cliArguments.js";
|
import { initializeCliArguments } from "./config/cliArguments.js";
|
||||||
|
import { createSafeChainProxy } from "./registryProxy/registryProxy.js";
|
||||||
|
import chalk from "chalk";
|
||||||
|
|
||||||
export async function main(args) {
|
export async function main(args) {
|
||||||
|
const proxy = createSafeChainProxy();
|
||||||
|
await proxy.startServer();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// This parses all the --safe-chain arguments and removes them from the args array
|
// This parses all the --safe-chain arguments and removes them from the args array
|
||||||
args = initializeCliArguments(args);
|
args = initializeCliArguments(args);
|
||||||
|
|
||||||
if (shouldScanCommand(args)) {
|
if (shouldScanCommand(args)) {
|
||||||
await scanCommand(args);
|
const commandScanResult = await scanCommand(args);
|
||||||
|
|
||||||
|
// Returning the exit code back to the caller allows the promise
|
||||||
|
// to be awaited in the bin files and return the correct exit code
|
||||||
|
if (commandScanResult !== 0) {
|
||||||
|
return commandScanResult;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const packageManagerResult = await getPackageManager().runCommand(args);
|
||||||
|
|
||||||
|
if (!proxy.verifyNoMaliciousPackages()) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.emptyLine();
|
||||||
|
ui.writeInformation(
|
||||||
|
`${chalk.green(
|
||||||
|
"✔"
|
||||||
|
)} Safe-chain: Command completed, no malicious packages found.`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Returning the exit code back to the caller allows the promise
|
||||||
|
// to be awaited in the bin files and return the correct exit code
|
||||||
|
return packageManagerResult.status;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ui.writeError("Failed to check for malicious packages:", error.message);
|
ui.writeError("Failed to check for malicious packages:", error.message);
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = getPackageManager().runCommand(args);
|
// Returning the exit code back to the caller allows the promise
|
||||||
process.exit(result.status);
|
// to be awaited in the bin files and return the correct exit code
|
||||||
|
return 1;
|
||||||
|
} finally {
|
||||||
|
await proxy.stopServer();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { ui } from "../../environment/userInteraction.js";
|
||||||
|
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||||
|
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
||||||
|
|
||||||
|
export function createBunPackageManager() {
|
||||||
|
return {
|
||||||
|
runCommand: (args) => runBunCommand("bun", args),
|
||||||
|
|
||||||
|
// For bun, we use the proxy-only approach to block package downloads,
|
||||||
|
// so we don't need to analyze commands.
|
||||||
|
isSupportedCommand: () => false,
|
||||||
|
getDependencyUpdatesForCommand: () => [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBunxPackageManager() {
|
||||||
|
return {
|
||||||
|
runCommand: (args) => runBunCommand("bunx", args),
|
||||||
|
|
||||||
|
// For bunx, we use the proxy-only approach to block package downloads,
|
||||||
|
// so we don't need to analyze commands.
|
||||||
|
isSupportedCommand: () => false,
|
||||||
|
getDependencyUpdatesForCommand: () => [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBunCommand(command, args) {
|
||||||
|
try {
|
||||||
|
const result = await safeSpawn(command, args, {
|
||||||
|
stdio: "inherit",
|
||||||
|
env: mergeSafeChainProxyEnvironmentVariables(process.env),
|
||||||
|
});
|
||||||
|
return { status: result.status };
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status) {
|
||||||
|
return { status: error.status };
|
||||||
|
} else {
|
||||||
|
ui.writeError("Error executing command:", error.message);
|
||||||
|
return { status: 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
import {
|
||||||
|
createBunPackageManager,
|
||||||
|
createBunxPackageManager,
|
||||||
|
} from "./bun/createBunPackageManager.js";
|
||||||
import { createNpmPackageManager } from "./npm/createPackageManager.js";
|
import { createNpmPackageManager } from "./npm/createPackageManager.js";
|
||||||
import { createNpxPackageManager } from "./npx/createPackageManager.js";
|
import { createNpxPackageManager } from "./npx/createPackageManager.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -10,9 +14,9 @@ const state = {
|
||||||
packageManagerName: null,
|
packageManagerName: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function initializePackageManager(packageManagerName, version) {
|
export function initializePackageManager(packageManagerName) {
|
||||||
if (packageManagerName === "npm") {
|
if (packageManagerName === "npm") {
|
||||||
state.packageManagerName = createNpmPackageManager(version);
|
state.packageManagerName = createNpmPackageManager();
|
||||||
} else if (packageManagerName === "npx") {
|
} else if (packageManagerName === "npx") {
|
||||||
state.packageManagerName = createNpxPackageManager();
|
state.packageManagerName = createNpxPackageManager();
|
||||||
} else if (packageManagerName === "yarn") {
|
} else if (packageManagerName === "yarn") {
|
||||||
|
|
@ -21,6 +25,10 @@ export function initializePackageManager(packageManagerName, version) {
|
||||||
state.packageManagerName = createPnpmPackageManager();
|
state.packageManagerName = createPnpmPackageManager();
|
||||||
} else if (packageManagerName === "pnpx") {
|
} else if (packageManagerName === "pnpx") {
|
||||||
state.packageManagerName = createPnpxPackageManager();
|
state.packageManagerName = createPnpxPackageManager();
|
||||||
|
} else if (packageManagerName === "bun") {
|
||||||
|
state.packageManagerName = createBunPackageManager();
|
||||||
|
} else if (packageManagerName === "bunx") {
|
||||||
|
state.packageManagerName = createBunxPackageManager();
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Unsupported package manager: " + packageManagerName);
|
throw new Error("Unsupported package manager: " + packageManagerName);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,27 @@
|
||||||
import { commandArgumentScanner } from "./dependencyScanner/commandArgumentScanner.js";
|
import { commandArgumentScanner } from "./dependencyScanner/commandArgumentScanner.js";
|
||||||
import { dryRunScanner } from "./dependencyScanner/dryRunScanner.js";
|
|
||||||
import { nullScanner } from "./dependencyScanner/nullScanner.js";
|
import { nullScanner } from "./dependencyScanner/nullScanner.js";
|
||||||
import { runNpm } from "./runNpmCommand.js";
|
import { runNpm } from "./runNpmCommand.js";
|
||||||
import {
|
import {
|
||||||
getNpmCommandForArgs,
|
getNpmCommandForArgs,
|
||||||
npmInstallCommand,
|
npmInstallCommand,
|
||||||
npmCiCommand,
|
|
||||||
npmInstallTestCommand,
|
|
||||||
npmInstallCiTestCommand,
|
|
||||||
npmUpdateCommand,
|
npmUpdateCommand,
|
||||||
npmAuditCommand,
|
|
||||||
npmExecCommand,
|
npmExecCommand,
|
||||||
} from "./utils/npmCommands.js";
|
} from "./utils/npmCommands.js";
|
||||||
|
|
||||||
export function createNpmPackageManager(version) {
|
export function createNpmPackageManager() {
|
||||||
// From npm v10.4.0 onwards, the npm commands output detailed information
|
|
||||||
// when using the --dry-run flag.
|
|
||||||
// We use that information to scan for dependency changes.
|
|
||||||
// For older versions of npm we have to rely on parsing the command arguments.
|
|
||||||
const supportedScanners = isPriorToNpm10_4(version)
|
|
||||||
? npm10_3AndBelowSupportedScanners
|
|
||||||
: npm10_4AndAboveSupportedScanners;
|
|
||||||
|
|
||||||
function isSupportedCommand(args) {
|
function isSupportedCommand(args) {
|
||||||
const scanner = findDependencyScannerForCommand(supportedScanners, args);
|
const scanner = findDependencyScannerForCommand(
|
||||||
|
commandScannerMapping,
|
||||||
|
args
|
||||||
|
);
|
||||||
return scanner.shouldScan(args);
|
return scanner.shouldScan(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDependencyUpdatesForCommand(args) {
|
function getDependencyUpdatesForCommand(args) {
|
||||||
const scanner = findDependencyScannerForCommand(supportedScanners, args);
|
const scanner = findDependencyScannerForCommand(
|
||||||
|
commandScannerMapping,
|
||||||
|
args
|
||||||
|
);
|
||||||
return scanner.scan(args);
|
return scanner.scan(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,40 +32,12 @@ export function createNpmPackageManager(version) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const npm10_4AndAboveSupportedScanners = {
|
const commandScannerMapping = {
|
||||||
[npmInstallCommand]: dryRunScanner(),
|
|
||||||
[npmUpdateCommand]: dryRunScanner(),
|
|
||||||
[npmCiCommand]: dryRunScanner(),
|
|
||||||
[npmAuditCommand]: dryRunScanner({
|
|
||||||
skipScanWhen: (args) => !args.includes("fix"),
|
|
||||||
}),
|
|
||||||
[npmExecCommand]: commandArgumentScanner({ ignoreDryRun: true }), // exec command doesn't support dry-run
|
|
||||||
|
|
||||||
// Running dry-run on install-test and install-ci-test will install & run tests.
|
|
||||||
// We only want to know if there are changes in the dependencies.
|
|
||||||
// So we run change the dry-run command to only check the install.
|
|
||||||
[npmInstallTestCommand]: dryRunScanner({ dryRunCommand: npmInstallCommand }),
|
|
||||||
[npmInstallCiTestCommand]: dryRunScanner({ dryRunCommand: npmCiCommand }),
|
|
||||||
};
|
|
||||||
|
|
||||||
const npm10_3AndBelowSupportedScanners = {
|
|
||||||
[npmInstallCommand]: commandArgumentScanner(),
|
[npmInstallCommand]: commandArgumentScanner(),
|
||||||
[npmUpdateCommand]: commandArgumentScanner(),
|
[npmUpdateCommand]: commandArgumentScanner(),
|
||||||
[npmExecCommand]: commandArgumentScanner({ ignoreDryRun: true }), // exec command doesn't support dry-run
|
[npmExecCommand]: commandArgumentScanner({ ignoreDryRun: true }), // exec command doesn't support dry-run
|
||||||
};
|
};
|
||||||
|
|
||||||
function isPriorToNpm10_4(version) {
|
|
||||||
try {
|
|
||||||
const [major, minor] = version.split(".").map(Number);
|
|
||||||
if (major < 10) return true;
|
|
||||||
if (major === 10 && minor < 4) return true;
|
|
||||||
return false;
|
|
||||||
} catch {
|
|
||||||
// Default to true: if version parsing fails, assume it's an older version
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function findDependencyScannerForCommand(scanners, args) {
|
function findDependencyScannerForCommand(scanners, args) {
|
||||||
const command = getNpmCommandForArgs(args);
|
const command = getNpmCommandForArgs(args);
|
||||||
if (!command) {
|
if (!command) {
|
||||||
|
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
import { parseDryRunOutput } from "../parsing/parseNpmInstallDryRunOutput.js";
|
|
||||||
import { dryRunNpmCommandAndOutput } from "../runNpmCommand.js";
|
|
||||||
import { hasDryRunArg } from "../utils/npmCommands.js";
|
|
||||||
|
|
||||||
export function dryRunScanner(scannerOptions) {
|
|
||||||
return {
|
|
||||||
scan: (args) => scanDependencies(scannerOptions, args),
|
|
||||||
shouldScan: (args) => shouldScanDependencies(scannerOptions, args),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function scanDependencies(scannerOptions, args) {
|
|
||||||
let dryRunArgs = args;
|
|
||||||
|
|
||||||
if (scannerOptions?.dryRunCommand) {
|
|
||||||
// Replace the first argument with the dryRunCommand (eg: "install" instead of "install-test")
|
|
||||||
dryRunArgs = [scannerOptions.dryRunCommand, ...args.slice(1)];
|
|
||||||
}
|
|
||||||
|
|
||||||
return checkChangesWithDryRun(dryRunArgs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldScanDependencies(scannerOptions, args) {
|
|
||||||
if (hasDryRunArg(args)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scannerOptions?.skipScanWhen && scannerOptions.skipScanWhen(args)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkChangesWithDryRun(args) {
|
|
||||||
const dryRunOutput = dryRunNpmCommandAndOutput(args);
|
|
||||||
|
|
||||||
// Dry-run can return a non-zero status code in some cases
|
|
||||||
// e.g., when running "npm audit fix --dry-run", it returns exit code 1
|
|
||||||
// when there are vulnerabilities that can be fixed.
|
|
||||||
if (dryRunOutput.status !== 0 && !canCommandReturnNonZeroOnSuccess(args)) {
|
|
||||||
throw new Error(
|
|
||||||
`Dry-run command failed with exit code ${dryRunOutput.status} and output:\n${dryRunOutput.output}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dryRunOutput.status !== 0 && !dryRunOutput.output) {
|
|
||||||
throw new Error(
|
|
||||||
`Dry-run command failed with exit code ${dryRunOutput.status} and produced no output.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedOutput = parseDryRunOutput(dryRunOutput.output);
|
|
||||||
|
|
||||||
// reverse the array to have the top-level packages first
|
|
||||||
return parsedOutput.reverse();
|
|
||||||
}
|
|
||||||
|
|
||||||
function canCommandReturnNonZeroOnSuccess(args) {
|
|
||||||
if (args.length < 2) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// `npm audit fix --dry-run` can return exit code 1 when it succesfully ran and
|
|
||||||
// there were vulnerabilities that could be fixed
|
|
||||||
return args[0] === "audit" && args[1] === "fix";
|
|
||||||
}
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
import { describe, it, mock } from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
|
|
||||||
describe("dryRunScanner", async () => {
|
|
||||||
const mockWriteError = mock.fn();
|
|
||||||
const mockDryRunNpmCommandAndOutput = mock.fn();
|
|
||||||
|
|
||||||
// Mock ui module
|
|
||||||
mock.module("../../../environment/userInteraction.js", {
|
|
||||||
namedExports: {
|
|
||||||
ui: {
|
|
||||||
writeError: mockWriteError,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock dryRunNpmCommandAndOutput function
|
|
||||||
mock.module("../runNpmCommand.js", {
|
|
||||||
namedExports: {
|
|
||||||
dryRunNpmCommandAndOutput: mockDryRunNpmCommandAndOutput,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { dryRunScanner } = await import("./dryRunScanner.js");
|
|
||||||
|
|
||||||
describe("doesCommandReturnNonZero", () => {
|
|
||||||
// We need to access the internal function for testing
|
|
||||||
// Since it's not exported, we'll test it indirectly through the main functionality
|
|
||||||
|
|
||||||
it("should handle npm audit fix commands that return non-zero", async () => {
|
|
||||||
mockDryRunNpmCommandAndOutput.mock.resetCalls();
|
|
||||||
mockWriteError.mock.resetCalls();
|
|
||||||
mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({
|
|
||||||
status: 1,
|
|
||||||
output: "found 5 vulnerabilities that can be fixed",
|
|
||||||
}));
|
|
||||||
|
|
||||||
const scanner = dryRunScanner();
|
|
||||||
const result = scanner.scan(["audit", "fix"]);
|
|
||||||
|
|
||||||
// Should not throw an error for audit fix commands
|
|
||||||
assert.ok(Array.isArray(result));
|
|
||||||
assert.equal(mockWriteError.mock.callCount(), 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for unexpected non-zero exit codes", async () => {
|
|
||||||
mockDryRunNpmCommandAndOutput.mock.resetCalls();
|
|
||||||
mockWriteError.mock.resetCalls();
|
|
||||||
mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({
|
|
||||||
status: 1,
|
|
||||||
output: "some error output",
|
|
||||||
}));
|
|
||||||
|
|
||||||
const scanner = dryRunScanner();
|
|
||||||
|
|
||||||
assert.throws(() => {
|
|
||||||
scanner.scan(["install", "lodash"]);
|
|
||||||
}, /Dry-run command failed with exit code 1/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle zero exit codes normally", async () => {
|
|
||||||
mockDryRunNpmCommandAndOutput.mock.resetCalls();
|
|
||||||
mockWriteError.mock.resetCalls();
|
|
||||||
mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({
|
|
||||||
status: 0,
|
|
||||||
output: "added 1 package",
|
|
||||||
}));
|
|
||||||
|
|
||||||
const scanner = dryRunScanner();
|
|
||||||
const result = scanner.scan(["install", "lodash"]);
|
|
||||||
|
|
||||||
assert.ok(Array.isArray(result));
|
|
||||||
assert.equal(mockWriteError.mock.callCount(), 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for non-zero exit with no output for audit fix", async () => {
|
|
||||||
mockDryRunNpmCommandAndOutput.mock.resetCalls();
|
|
||||||
mockWriteError.mock.resetCalls();
|
|
||||||
mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({
|
|
||||||
status: 1,
|
|
||||||
output: "",
|
|
||||||
}));
|
|
||||||
|
|
||||||
const scanner = dryRunScanner();
|
|
||||||
|
|
||||||
assert.throws(() => {
|
|
||||||
scanner.scan(["audit", "fix"]);
|
|
||||||
}, /Dry-run command failed with exit code 1/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("scanner functionality", () => {
|
|
||||||
it("should use dryRunCommand option when provided", async () => {
|
|
||||||
mockDryRunNpmCommandAndOutput.mock.resetCalls();
|
|
||||||
mockWriteError.mock.resetCalls();
|
|
||||||
mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({
|
|
||||||
status: 0,
|
|
||||||
output: "no changes",
|
|
||||||
}));
|
|
||||||
|
|
||||||
const scanner = dryRunScanner({ dryRunCommand: "install" });
|
|
||||||
scanner.scan(["install-test", "lodash"]);
|
|
||||||
|
|
||||||
// Should call with "install" instead of "install-test"
|
|
||||||
assert.equal(mockDryRunNpmCommandAndOutput.mock.callCount(), 1);
|
|
||||||
const calledArgs =
|
|
||||||
mockDryRunNpmCommandAndOutput.mock.calls[0].arguments[0];
|
|
||||||
assert.deepEqual(calledArgs, ["install", "lodash"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should skip scanning when hasDryRunArg returns true", async () => {
|
|
||||||
mockDryRunNpmCommandAndOutput.mock.resetCalls();
|
|
||||||
mockWriteError.mock.resetCalls();
|
|
||||||
|
|
||||||
const scanner = dryRunScanner();
|
|
||||||
const shouldScan = scanner.shouldScan(["install", "--dry-run"]);
|
|
||||||
|
|
||||||
assert.equal(shouldScan, false);
|
|
||||||
// Should not call dryRunNpmCommandAndOutput since scanning is skipped
|
|
||||||
assert.equal(mockDryRunNpmCommandAndOutput.mock.callCount(), 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should skip scanning when skipScanWhen returns true", async () => {
|
|
||||||
const scanner = dryRunScanner({
|
|
||||||
skipScanWhen: (args) => args.includes("--skip"),
|
|
||||||
});
|
|
||||||
const shouldScan = scanner.shouldScan(["install", "--skip"]);
|
|
||||||
|
|
||||||
assert.equal(shouldScan, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should scan when conditions are met", async () => {
|
|
||||||
const scanner = dryRunScanner();
|
|
||||||
const shouldScan = scanner.shouldScan(["install", "lodash"]);
|
|
||||||
|
|
||||||
assert.equal(shouldScan, true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
export function parseDryRunOutput(output) {
|
|
||||||
const lines = output.split(/\r?\n/);
|
|
||||||
const packageChanges = [];
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith("add ")) {
|
|
||||||
packageChanges.push(parseAdd(line));
|
|
||||||
} else if (line.startsWith("remove ")) {
|
|
||||||
packageChanges.push(parseRemove(line));
|
|
||||||
} else if (line.startsWith("change ")) {
|
|
||||||
packageChanges.push(parseChange(line));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return packageChanges;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAdd(line) {
|
|
||||||
const splitLine = getLineParts(line);
|
|
||||||
const packageName = splitLine[1];
|
|
||||||
const packageVersion = splitLine[splitLine.length - 1];
|
|
||||||
return addedPackage(packageName, packageVersion);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addedPackage(name, version) {
|
|
||||||
return { type: "add", name, version };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseRemove(line) {
|
|
||||||
const splitLine = getLineParts(line);
|
|
||||||
const packageName = splitLine[1];
|
|
||||||
const packageVersion = splitLine[splitLine.length - 1];
|
|
||||||
return removedPackage(packageName, packageVersion);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removedPackage(name, version) {
|
|
||||||
return { type: "remove", name, version };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseChange(line) {
|
|
||||||
const splitLine = getLineParts(line);
|
|
||||||
const packageName = splitLine[1];
|
|
||||||
const packageVersion = splitLine[splitLine.length - 1];
|
|
||||||
const oldVersion = splitLine[2];
|
|
||||||
return changedPackage(packageName, packageVersion, oldVersion);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLineParts(line) {
|
|
||||||
return line
|
|
||||||
.split(" ")
|
|
||||||
.map((part) => part.trim())
|
|
||||||
.filter((part) => part !== "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function changedPackage(name, version, oldVersion) {
|
|
||||||
return { type: "change", name, version, oldVersion };
|
|
||||||
}
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import assert from "node:assert";
|
|
||||||
import { parseDryRunOutput } from "./parseNpmInstallDryRunOutput.js";
|
|
||||||
|
|
||||||
describe("parseNpmInstallDryRunOutput", () => {
|
|
||||||
it("should parse added packages", () => {
|
|
||||||
const output = `
|
|
||||||
add @jest/transform 29.7.0
|
|
||||||
add @jest/test-result 29.7.0
|
|
||||||
add @jest/reporters 29.7.0
|
|
||||||
add @jest/console 29.7.0
|
|
||||||
add jest-cli 29.7.0
|
|
||||||
add import-local 3.2.0
|
|
||||||
add @jest/types 29.6.3
|
|
||||||
add @jest/core 29.7.0
|
|
||||||
add jest 29.7.0
|
|
||||||
|
|
||||||
added 267 packages in 831ms
|
|
||||||
|
|
||||||
32 packages are looking for funding
|
|
||||||
run \`npm fund\` for details`;
|
|
||||||
|
|
||||||
const expected = [
|
|
||||||
{ name: "@jest/transform", version: "29.7.0", type: "add" },
|
|
||||||
{ name: "@jest/test-result", version: "29.7.0", type: "add" },
|
|
||||||
{ name: "@jest/reporters", version: "29.7.0", type: "add" },
|
|
||||||
{ name: "@jest/console", version: "29.7.0", type: "add" },
|
|
||||||
{ name: "jest-cli", version: "29.7.0", type: "add" },
|
|
||||||
{ name: "import-local", version: "3.2.0", type: "add" },
|
|
||||||
{ name: "@jest/types", version: "29.6.3", type: "add" },
|
|
||||||
{ name: "@jest/core", version: "29.7.0", type: "add" },
|
|
||||||
{ name: "jest", version: "29.7.0", type: "add" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = parseDryRunOutput(output);
|
|
||||||
|
|
||||||
assert.deepEqual(result, expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should parse removed packages", () => {
|
|
||||||
const output = `
|
|
||||||
remove react 19.1.0
|
|
||||||
|
|
||||||
removed 1 package in 115ms`;
|
|
||||||
|
|
||||||
const expected = [{ name: "react", version: "19.1.0", type: "remove" }];
|
|
||||||
|
|
||||||
const result = parseDryRunOutput(output);
|
|
||||||
|
|
||||||
assert.deepEqual(result, expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should parse changed packages", () => {
|
|
||||||
const output = `
|
|
||||||
change react 19.0.0 => 19.1.0
|
|
||||||
|
|
||||||
changed 1 package in 204ms`;
|
|
||||||
|
|
||||||
const expected = [
|
|
||||||
{
|
|
||||||
name: "react",
|
|
||||||
version: "19.1.0",
|
|
||||||
oldVersion: "19.0.0",
|
|
||||||
type: "change",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = parseDryRunOutput(output);
|
|
||||||
|
|
||||||
assert.deepEqual(result, expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should parse mixed package changes", () => {
|
|
||||||
const output = `
|
|
||||||
add @jest/transform 29.7.0
|
|
||||||
add @jest/test-result 29.7.0
|
|
||||||
add @jest/reporters 29.7.0
|
|
||||||
add @jest/console 29.7.0
|
|
||||||
add jest-cli 29.7.0
|
|
||||||
add import-local 3.2.0
|
|
||||||
add @jest/types 29.6.3
|
|
||||||
add @jest/core 29.7.0
|
|
||||||
add jest 29.7.0
|
|
||||||
remove react 19.1.0
|
|
||||||
change lodash 4.17.0 => 4.18.0
|
|
||||||
|
|
||||||
removed 1 package in 115ms`;
|
|
||||||
|
|
||||||
const expected = [
|
|
||||||
{ name: "@jest/transform", version: "29.7.0", type: "add" },
|
|
||||||
{ name: "@jest/test-result", version: "29.7.0", type: "add" },
|
|
||||||
{ name: "@jest/reporters", version: "29.7.0", type: "add" },
|
|
||||||
{ name: "@jest/console", version: "29.7.0", type: "add" },
|
|
||||||
{ name: "jest-cli", version: "29.7.0", type: "add" },
|
|
||||||
{ name: "import-local", version: "3.2.0", type: "add" },
|
|
||||||
{ name: "@jest/types", version: "29.6.3", type: "add" },
|
|
||||||
{ name: "@jest/core", version: "29.7.0", type: "add" },
|
|
||||||
{ name: "jest", version: "29.7.0", type: "add" },
|
|
||||||
{ name: "react", version: "19.1.0", type: "remove" },
|
|
||||||
{
|
|
||||||
name: "lodash",
|
|
||||||
version: "4.18.0",
|
|
||||||
oldVersion: "4.17.0",
|
|
||||||
type: "change",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = parseDryRunOutput(output);
|
|
||||||
|
|
||||||
assert.deepEqual(result, expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should work with npm v22.0.0", () => {
|
|
||||||
const output = `
|
|
||||||
add @jest/types 29.6.3
|
|
||||||
add @jest/core 29.7.0
|
|
||||||
add jest 29.7.0
|
|
||||||
|
|
||||||
added 257 packages in 791ms
|
|
||||||
|
|
||||||
44 packages are looking for funding
|
|
||||||
run \`npm fund\` for details`;
|
|
||||||
|
|
||||||
const expected = [
|
|
||||||
{ name: "@jest/types", version: "29.6.3", type: "add" },
|
|
||||||
{ name: "@jest/core", version: "29.7.0", type: "add" },
|
|
||||||
{ name: "jest", version: "29.7.0", type: "add" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = parseDryRunOutput(output);
|
|
||||||
|
|
||||||
assert.deepEqual(result, expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import { execSync } from "child_process";
|
|
||||||
import { ui } from "../../environment/userInteraction.js";
|
import { ui } from "../../environment/userInteraction.js";
|
||||||
|
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||||
|
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
||||||
|
|
||||||
export function runNpm(args) {
|
export async function runNpm(args) {
|
||||||
try {
|
try {
|
||||||
const npmCommand = `npm ${args.join(" ")}`;
|
const result = await safeSpawn("npm", args, {
|
||||||
execSync(npmCommand, { stdio: "inherit" });
|
stdio: "inherit",
|
||||||
|
env: mergeSafeChainProxyEnvironmentVariables(process.env),
|
||||||
|
});
|
||||||
|
return { status: result.status };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.status) {
|
if (error.status) {
|
||||||
return { status: error.status };
|
return { status: error.status };
|
||||||
|
|
@ -13,17 +17,29 @@ export function runNpm(args) {
|
||||||
return { status: 1 };
|
return { status: 1 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { status: 0 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dryRunNpmCommandAndOutput(args) {
|
export async function dryRunNpmCommandAndOutput(args) {
|
||||||
try {
|
try {
|
||||||
const npmCommand = `npm ${args.join(" ")} --ignore-scripts --dry-run`;
|
const result = await safeSpawn(
|
||||||
const output = execSync(npmCommand, { stdio: "pipe" });
|
"npm",
|
||||||
return { status: 0, output: output.toString() };
|
[...args, "--ignore-scripts", "--dry-run"],
|
||||||
|
{
|
||||||
|
stdio: "pipe",
|
||||||
|
env: mergeSafeChainProxyEnvironmentVariables(process.env),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: result.status,
|
||||||
|
output: result.status === 0 ? result.stdout : result.stderr,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.status) {
|
if (error.status) {
|
||||||
const output = error.stdout ? error.stdout.toString() : "";
|
const output =
|
||||||
|
error.stdout?.toString() ??
|
||||||
|
error.stderr?.toString() ??
|
||||||
|
error.message ??
|
||||||
|
"";
|
||||||
return { status: error.status, output };
|
return { status: error.status, output };
|
||||||
} else {
|
} else {
|
||||||
ui.writeError("Error executing command:", error.message);
|
ui.writeError("Error executing command:", error.message);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,358 @@
|
||||||
|
// This was ran with the abbrev package to generate the abbrevs object below
|
||||||
|
// console.log(abbrev(commands.concat(Object.keys(aliases))));
|
||||||
|
export const abbrevs = {
|
||||||
|
ac: "access",
|
||||||
|
acc: "access",
|
||||||
|
acce: "access",
|
||||||
|
acces: "access",
|
||||||
|
access: "access",
|
||||||
|
add: "add",
|
||||||
|
"add-": "add-user",
|
||||||
|
"add-u": "add-user",
|
||||||
|
"add-us": "add-user",
|
||||||
|
"add-use": "add-user",
|
||||||
|
"add-user": "add-user",
|
||||||
|
addu: "adduser",
|
||||||
|
addus: "adduser",
|
||||||
|
adduse: "adduser",
|
||||||
|
adduser: "adduser",
|
||||||
|
aud: "audit",
|
||||||
|
audi: "audit",
|
||||||
|
audit: "audit",
|
||||||
|
aut: "author",
|
||||||
|
auth: "author",
|
||||||
|
autho: "author",
|
||||||
|
author: "author",
|
||||||
|
b: "bugs",
|
||||||
|
bu: "bugs",
|
||||||
|
bug: "bugs",
|
||||||
|
bugs: "bugs",
|
||||||
|
c: "c",
|
||||||
|
ca: "cache",
|
||||||
|
cac: "cache",
|
||||||
|
cach: "cache",
|
||||||
|
cache: "cache",
|
||||||
|
ci: "ci",
|
||||||
|
cit: "cit",
|
||||||
|
"clean-install": "clean-install",
|
||||||
|
"clean-install-": "clean-install-test",
|
||||||
|
"clean-install-t": "clean-install-test",
|
||||||
|
"clean-install-te": "clean-install-test",
|
||||||
|
"clean-install-tes": "clean-install-test",
|
||||||
|
"clean-install-test": "clean-install-test",
|
||||||
|
com: "completion",
|
||||||
|
comp: "completion",
|
||||||
|
compl: "completion",
|
||||||
|
comple: "completion",
|
||||||
|
complet: "completion",
|
||||||
|
completi: "completion",
|
||||||
|
completio: "completion",
|
||||||
|
completion: "completion",
|
||||||
|
con: "config",
|
||||||
|
conf: "config",
|
||||||
|
confi: "config",
|
||||||
|
config: "config",
|
||||||
|
cr: "create",
|
||||||
|
cre: "create",
|
||||||
|
crea: "create",
|
||||||
|
creat: "create",
|
||||||
|
create: "create",
|
||||||
|
dd: "ddp",
|
||||||
|
ddp: "ddp",
|
||||||
|
ded: "dedupe",
|
||||||
|
dedu: "dedupe",
|
||||||
|
dedup: "dedupe",
|
||||||
|
dedupe: "dedupe",
|
||||||
|
dep: "deprecate",
|
||||||
|
depr: "deprecate",
|
||||||
|
depre: "deprecate",
|
||||||
|
deprec: "deprecate",
|
||||||
|
depreca: "deprecate",
|
||||||
|
deprecat: "deprecate",
|
||||||
|
deprecate: "deprecate",
|
||||||
|
dif: "diff",
|
||||||
|
diff: "diff",
|
||||||
|
"dist-tag": "dist-tag",
|
||||||
|
"dist-tags": "dist-tags",
|
||||||
|
docs: "docs",
|
||||||
|
doct: "doctor",
|
||||||
|
docto: "doctor",
|
||||||
|
doctor: "doctor",
|
||||||
|
ed: "edit",
|
||||||
|
edi: "edit",
|
||||||
|
edit: "edit",
|
||||||
|
exe: "exec",
|
||||||
|
exec: "exec",
|
||||||
|
expla: "explain",
|
||||||
|
explai: "explain",
|
||||||
|
explain: "explain",
|
||||||
|
explo: "explore",
|
||||||
|
explor: "explore",
|
||||||
|
explore: "explore",
|
||||||
|
find: "find",
|
||||||
|
"find-": "find-dupes",
|
||||||
|
"find-d": "find-dupes",
|
||||||
|
"find-du": "find-dupes",
|
||||||
|
"find-dup": "find-dupes",
|
||||||
|
"find-dupe": "find-dupes",
|
||||||
|
"find-dupes": "find-dupes",
|
||||||
|
fu: "fund",
|
||||||
|
fun: "fund",
|
||||||
|
fund: "fund",
|
||||||
|
g: "get",
|
||||||
|
ge: "get",
|
||||||
|
get: "get",
|
||||||
|
help: "help",
|
||||||
|
"help-": "help-search",
|
||||||
|
"help-s": "help-search",
|
||||||
|
"help-se": "help-search",
|
||||||
|
"help-sea": "help-search",
|
||||||
|
"help-sear": "help-search",
|
||||||
|
"help-searc": "help-search",
|
||||||
|
"help-search": "help-search",
|
||||||
|
hl: "hlep",
|
||||||
|
hle: "hlep",
|
||||||
|
hlep: "hlep",
|
||||||
|
ho: "home",
|
||||||
|
hom: "home",
|
||||||
|
home: "home",
|
||||||
|
i: "i",
|
||||||
|
ic: "ic",
|
||||||
|
in: "in",
|
||||||
|
inf: "info",
|
||||||
|
info: "info",
|
||||||
|
ini: "init",
|
||||||
|
init: "init",
|
||||||
|
inn: "innit",
|
||||||
|
inni: "innit",
|
||||||
|
innit: "innit",
|
||||||
|
ins: "ins",
|
||||||
|
inst: "inst",
|
||||||
|
insta: "insta",
|
||||||
|
instal: "instal",
|
||||||
|
install: "install",
|
||||||
|
"install-ci": "install-ci-test",
|
||||||
|
"install-ci-": "install-ci-test",
|
||||||
|
"install-ci-t": "install-ci-test",
|
||||||
|
"install-ci-te": "install-ci-test",
|
||||||
|
"install-ci-tes": "install-ci-test",
|
||||||
|
"install-ci-test": "install-ci-test",
|
||||||
|
"install-cl": "install-clean",
|
||||||
|
"install-cle": "install-clean",
|
||||||
|
"install-clea": "install-clean",
|
||||||
|
"install-clean": "install-clean",
|
||||||
|
"install-t": "install-test",
|
||||||
|
"install-te": "install-test",
|
||||||
|
"install-tes": "install-test",
|
||||||
|
"install-test": "install-test",
|
||||||
|
isnt: "isnt",
|
||||||
|
isnta: "isnta",
|
||||||
|
isntal: "isntal",
|
||||||
|
isntall: "isntall",
|
||||||
|
"isntall-": "isntall-clean",
|
||||||
|
"isntall-c": "isntall-clean",
|
||||||
|
"isntall-cl": "isntall-clean",
|
||||||
|
"isntall-cle": "isntall-clean",
|
||||||
|
"isntall-clea": "isntall-clean",
|
||||||
|
"isntall-clean": "isntall-clean",
|
||||||
|
iss: "issues",
|
||||||
|
issu: "issues",
|
||||||
|
issue: "issues",
|
||||||
|
issues: "issues",
|
||||||
|
it: "it",
|
||||||
|
la: "la",
|
||||||
|
lin: "link",
|
||||||
|
link: "link",
|
||||||
|
lis: "list",
|
||||||
|
list: "list",
|
||||||
|
ll: "ll",
|
||||||
|
ln: "ln",
|
||||||
|
logi: "login",
|
||||||
|
login: "login",
|
||||||
|
logo: "logout",
|
||||||
|
logou: "logout",
|
||||||
|
logout: "logout",
|
||||||
|
ls: "ls",
|
||||||
|
og: "ogr",
|
||||||
|
ogr: "ogr",
|
||||||
|
or: "org",
|
||||||
|
org: "org",
|
||||||
|
ou: "outdated",
|
||||||
|
out: "outdated",
|
||||||
|
outd: "outdated",
|
||||||
|
outda: "outdated",
|
||||||
|
outdat: "outdated",
|
||||||
|
outdate: "outdated",
|
||||||
|
outdated: "outdated",
|
||||||
|
ow: "owner",
|
||||||
|
own: "owner",
|
||||||
|
owne: "owner",
|
||||||
|
owner: "owner",
|
||||||
|
pa: "pack",
|
||||||
|
pac: "pack",
|
||||||
|
pack: "pack",
|
||||||
|
pi: "ping",
|
||||||
|
pin: "ping",
|
||||||
|
ping: "ping",
|
||||||
|
pk: "pkg",
|
||||||
|
pkg: "pkg",
|
||||||
|
pre: "prefix",
|
||||||
|
pref: "prefix",
|
||||||
|
prefi: "prefix",
|
||||||
|
prefix: "prefix",
|
||||||
|
pro: "profile",
|
||||||
|
prof: "profile",
|
||||||
|
profi: "profile",
|
||||||
|
profil: "profile",
|
||||||
|
profile: "profile",
|
||||||
|
pru: "prune",
|
||||||
|
prun: "prune",
|
||||||
|
prune: "prune",
|
||||||
|
pu: "publish",
|
||||||
|
pub: "publish",
|
||||||
|
publ: "publish",
|
||||||
|
publi: "publish",
|
||||||
|
publis: "publish",
|
||||||
|
publish: "publish",
|
||||||
|
q: "query",
|
||||||
|
qu: "query",
|
||||||
|
que: "query",
|
||||||
|
quer: "query",
|
||||||
|
query: "query",
|
||||||
|
r: "r",
|
||||||
|
rb: "rb",
|
||||||
|
reb: "rebuild",
|
||||||
|
rebu: "rebuild",
|
||||||
|
rebui: "rebuild",
|
||||||
|
rebuil: "rebuild",
|
||||||
|
rebuild: "rebuild",
|
||||||
|
rem: "remove",
|
||||||
|
remo: "remove",
|
||||||
|
remov: "remove",
|
||||||
|
remove: "remove",
|
||||||
|
rep: "repo",
|
||||||
|
repo: "repo",
|
||||||
|
res: "restart",
|
||||||
|
rest: "restart",
|
||||||
|
resta: "restart",
|
||||||
|
restar: "restart",
|
||||||
|
restart: "restart",
|
||||||
|
rm: "rm",
|
||||||
|
ro: "root",
|
||||||
|
roo: "root",
|
||||||
|
root: "root",
|
||||||
|
rum: "rum",
|
||||||
|
run: "run",
|
||||||
|
"run-": "run-script",
|
||||||
|
"run-s": "run-script",
|
||||||
|
"run-sc": "run-script",
|
||||||
|
"run-scr": "run-script",
|
||||||
|
"run-scri": "run-script",
|
||||||
|
"run-scrip": "run-script",
|
||||||
|
"run-script": "run-script",
|
||||||
|
s: "s",
|
||||||
|
sb: "sbom",
|
||||||
|
sbo: "sbom",
|
||||||
|
sbom: "sbom",
|
||||||
|
se: "se",
|
||||||
|
sea: "search",
|
||||||
|
sear: "search",
|
||||||
|
searc: "search",
|
||||||
|
search: "search",
|
||||||
|
set: "set",
|
||||||
|
sho: "show",
|
||||||
|
show: "show",
|
||||||
|
shr: "shrinkwrap",
|
||||||
|
shri: "shrinkwrap",
|
||||||
|
shrin: "shrinkwrap",
|
||||||
|
shrink: "shrinkwrap",
|
||||||
|
shrinkw: "shrinkwrap",
|
||||||
|
shrinkwr: "shrinkwrap",
|
||||||
|
shrinkwra: "shrinkwrap",
|
||||||
|
shrinkwrap: "shrinkwrap",
|
||||||
|
si: "sit",
|
||||||
|
sit: "sit",
|
||||||
|
star: "star",
|
||||||
|
stars: "stars",
|
||||||
|
start: "start",
|
||||||
|
sto: "stop",
|
||||||
|
stop: "stop",
|
||||||
|
t: "t",
|
||||||
|
tea: "team",
|
||||||
|
team: "team",
|
||||||
|
tes: "test",
|
||||||
|
test: "test",
|
||||||
|
to: "token",
|
||||||
|
tok: "token",
|
||||||
|
toke: "token",
|
||||||
|
token: "token",
|
||||||
|
ts: "tst",
|
||||||
|
tst: "tst",
|
||||||
|
ud: "udpate",
|
||||||
|
udp: "udpate",
|
||||||
|
udpa: "udpate",
|
||||||
|
udpat: "udpate",
|
||||||
|
udpate: "udpate",
|
||||||
|
un: "un",
|
||||||
|
und: "undeprecate",
|
||||||
|
unde: "undeprecate",
|
||||||
|
undep: "undeprecate",
|
||||||
|
undepr: "undeprecate",
|
||||||
|
undepre: "undeprecate",
|
||||||
|
undeprec: "undeprecate",
|
||||||
|
undepreca: "undeprecate",
|
||||||
|
undeprecat: "undeprecate",
|
||||||
|
undeprecate: "undeprecate",
|
||||||
|
uni: "uninstall",
|
||||||
|
unin: "uninstall",
|
||||||
|
unins: "uninstall",
|
||||||
|
uninst: "uninstall",
|
||||||
|
uninsta: "uninstall",
|
||||||
|
uninstal: "uninstall",
|
||||||
|
uninstall: "uninstall",
|
||||||
|
unl: "unlink",
|
||||||
|
unli: "unlink",
|
||||||
|
unlin: "unlink",
|
||||||
|
unlink: "unlink",
|
||||||
|
unp: "unpublish",
|
||||||
|
unpu: "unpublish",
|
||||||
|
unpub: "unpublish",
|
||||||
|
unpubl: "unpublish",
|
||||||
|
unpubli: "unpublish",
|
||||||
|
unpublis: "unpublish",
|
||||||
|
unpublish: "unpublish",
|
||||||
|
uns: "unstar",
|
||||||
|
unst: "unstar",
|
||||||
|
unsta: "unstar",
|
||||||
|
unstar: "unstar",
|
||||||
|
up: "up",
|
||||||
|
upd: "update",
|
||||||
|
upda: "update",
|
||||||
|
updat: "update",
|
||||||
|
update: "update",
|
||||||
|
upg: "upgrade",
|
||||||
|
upgr: "upgrade",
|
||||||
|
upgra: "upgrade",
|
||||||
|
upgrad: "upgrade",
|
||||||
|
upgrade: "upgrade",
|
||||||
|
ur: "urn",
|
||||||
|
urn: "urn",
|
||||||
|
v: "v",
|
||||||
|
veri: "verison",
|
||||||
|
veris: "verison",
|
||||||
|
veriso: "verison",
|
||||||
|
verison: "verison",
|
||||||
|
vers: "version",
|
||||||
|
versi: "version",
|
||||||
|
versio: "version",
|
||||||
|
version: "version",
|
||||||
|
vi: "view",
|
||||||
|
vie: "view",
|
||||||
|
view: "view",
|
||||||
|
who: "whoami",
|
||||||
|
whoa: "whoami",
|
||||||
|
whoam: "whoami",
|
||||||
|
whoami: "whoami",
|
||||||
|
why: "why",
|
||||||
|
x: "x",
|
||||||
|
};
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Based on https://github.com/npm/cli/blob/latest/lib/utils/cmd-list.js
|
// Based on https://github.com/npm/cli/blob/latest/lib/utils/cmd-list.js
|
||||||
|
|
||||||
import abbrev from "abbrev";
|
import { abbrevs } from "./abbrevs-generated.js";
|
||||||
|
|
||||||
const commands = [
|
const commands = [
|
||||||
"access",
|
"access",
|
||||||
|
|
@ -158,8 +158,6 @@ export function deref(c) {
|
||||||
return aliases[c];
|
return aliases[c];
|
||||||
}
|
}
|
||||||
|
|
||||||
const abbrevs = abbrev(commands.concat(Object.keys(aliases)));
|
|
||||||
|
|
||||||
// first deref the abbrev, if there is one
|
// first deref the abbrev, if there is one
|
||||||
// then resolve any aliases
|
// then resolve any aliases
|
||||||
// so `npm install-cl` will resolve to `install-clean` then to `ci`
|
// so `npm install-cl` will resolve to `install-clean` then to `ci`
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import { execSync } from "child_process";
|
|
||||||
import { ui } from "../../environment/userInteraction.js";
|
import { ui } from "../../environment/userInteraction.js";
|
||||||
|
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||||
|
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
||||||
|
|
||||||
export function runNpx(args) {
|
export async function runNpx(args) {
|
||||||
try {
|
try {
|
||||||
const npxCommand = `npx ${args.join(" ")}`;
|
const result = await safeSpawn("npx", args, {
|
||||||
execSync(npxCommand, { stdio: "inherit" });
|
stdio: "inherit",
|
||||||
|
env: mergeSafeChainProxyEnvironmentVariables(process.env),
|
||||||
|
});
|
||||||
|
return { status: result.status };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.status) {
|
if (error.status) {
|
||||||
return { status: error.status };
|
return { status: error.status };
|
||||||
|
|
@ -13,5 +17,4 @@ export function runNpx(args) {
|
||||||
return { status: 1 };
|
return { status: 1 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { status: 0 };
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,20 @@
|
||||||
import { ui } from "../../environment/userInteraction.js";
|
import { ui } from "../../environment/userInteraction.js";
|
||||||
import { safeSpawnSync } from "../../utils/safeSpawn.js";
|
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
||||||
|
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||||
|
|
||||||
export function runPnpmCommand(args, toolName = "pnpm") {
|
export async function runPnpmCommand(args, toolName = "pnpm") {
|
||||||
try {
|
try {
|
||||||
let result;
|
let result;
|
||||||
if (toolName === "pnpm") {
|
if (toolName === "pnpm") {
|
||||||
result = safeSpawnSync("pnpm", args, { stdio: "inherit" });
|
result = await safeSpawn("pnpm", args, {
|
||||||
|
stdio: "inherit",
|
||||||
|
env: mergeSafeChainProxyEnvironmentVariables(process.env),
|
||||||
|
});
|
||||||
} else if (toolName === "pnpx") {
|
} else if (toolName === "pnpx") {
|
||||||
result = safeSpawnSync("pnpx", args, { stdio: "inherit" });
|
result = await safeSpawn("pnpx", args, {
|
||||||
|
stdio: "inherit",
|
||||||
|
env: mergeSafeChainProxyEnvironmentVariables(process.env),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unsupported tool name for aikido-pnpm: ${toolName}`);
|
throw new Error(`Unsupported tool name for aikido-pnpm: ${toolName}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
import { execSync } from "child_process";
|
|
||||||
import { ui } from "../../environment/userInteraction.js";
|
import { ui } from "../../environment/userInteraction.js";
|
||||||
|
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||||
|
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
||||||
|
|
||||||
export function runYarnCommand(args) {
|
export async function runYarnCommand(args) {
|
||||||
try {
|
try {
|
||||||
const npxCommand = `yarn ${args.join(" ")}`;
|
const env = mergeSafeChainProxyEnvironmentVariables(process.env);
|
||||||
execSync(npxCommand, { stdio: "inherit" });
|
await fixYarnProxyEnvironmentVariables(env);
|
||||||
|
|
||||||
|
const result = await safeSpawn("yarn", args, {
|
||||||
|
stdio: "inherit",
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
return { status: result.status };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.status) {
|
if (error.status) {
|
||||||
return { status: error.status };
|
return { status: error.status };
|
||||||
|
|
@ -13,5 +20,34 @@ export function runYarnCommand(args) {
|
||||||
return { status: 1 };
|
return { status: 1 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { status: 0 };
|
}
|
||||||
|
|
||||||
|
async function fixYarnProxyEnvironmentVariables(env) {
|
||||||
|
// Yarn ignores standard proxy environment variable HTTPS_PROXY
|
||||||
|
// It does respect NODE_EXTRA_CA_CERTS for custom CA certificates though.
|
||||||
|
// Don't use YARN_HTTPS_CA_FILE_PATH though, as it causes to ignore all system CAs
|
||||||
|
|
||||||
|
// Yarn v2/v3 and v4+ use different environment variables for proxy and CA certs
|
||||||
|
// When setting all variables, yarn returns an error about conflicting variables
|
||||||
|
// - v2/v3: "Usage Error: Unrecognized or legacy configuration settings found: httpsCaFilePath"
|
||||||
|
// - v4+: "Usage Error: Unrecognized or legacy configuration settings found: caFilePath"
|
||||||
|
|
||||||
|
const version = await yarnVersion();
|
||||||
|
const majorVersion = parseInt(version.split(".")[0]);
|
||||||
|
|
||||||
|
if (majorVersion >= 4) {
|
||||||
|
env.YARN_HTTPS_PROXY = env.HTTPS_PROXY;
|
||||||
|
} else if (majorVersion === 2 || majorVersion === 3) {
|
||||||
|
env.YARN_HTTPS_PROXY = env.HTTPS_PROXY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function yarnVersion() {
|
||||||
|
const result = await safeSpawn("yarn", ["--version"], {
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error("Failed to get yarn version");
|
||||||
|
}
|
||||||
|
return result.stdout.trim();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
import { describe, it, beforeEach, afterEach, mock } from "node:test";
|
||||||
|
import assert from "node:assert";
|
||||||
|
|
||||||
|
describe("runYarnCommand", () => {
|
||||||
|
let runYarnCommand;
|
||||||
|
let capturedEnv;
|
||||||
|
let yarnVersion;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
capturedEnv = null;
|
||||||
|
yarnVersion = "4.1.0"; // Default to v4
|
||||||
|
|
||||||
|
// Mock safeSpawn to capture env and control yarn version
|
||||||
|
mock.module("../../utils/safeSpawn.js", {
|
||||||
|
namedExports: {
|
||||||
|
safeSpawn: async (command, args, options) => {
|
||||||
|
if (args.includes("--version")) {
|
||||||
|
// Mock yarn version check
|
||||||
|
return { status: 0, stdout: yarnVersion };
|
||||||
|
}
|
||||||
|
// Capture the env for assertions
|
||||||
|
capturedEnv = options.env;
|
||||||
|
return { status: 0 };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock mergeSafeChainProxyEnvironmentVariables to return test env
|
||||||
|
mock.module("../../registryProxy/registryProxy.js", {
|
||||||
|
namedExports: {
|
||||||
|
mergeSafeChainProxyEnvironmentVariables: (env) => {
|
||||||
|
return {
|
||||||
|
...env,
|
||||||
|
HTTPS_PROXY: "http://localhost:8080",
|
||||||
|
NODE_EXTRA_CA_CERTS: "/path/to/ca-cert.pem",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock ui to prevent console output
|
||||||
|
mock.module("../../environment/userInteraction.js", {
|
||||||
|
namedExports: {
|
||||||
|
ui: {
|
||||||
|
writeError: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const module = await import("./runYarnCommand.js");
|
||||||
|
runYarnCommand = module.runYarnCommand;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set YARN_HTTPS_PROXY for Yarn v4+", async () => {
|
||||||
|
yarnVersion = "4.1.0";
|
||||||
|
await runYarnCommand(["add", "lodash"]);
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
capturedEnv.YARN_HTTPS_PROXY,
|
||||||
|
"http://localhost:8080",
|
||||||
|
"YARN_HTTPS_PROXY should be set to the HTTPS_PROXY value"
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
capturedEnv.YARN_HTTPS_CA_FILE_PATH,
|
||||||
|
undefined,
|
||||||
|
"YARN_HTTPS_CA_FILE_PATH should NOT be set to avoid overriding system CAs"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set YARN_HTTPS_PROXY for Yarn v3", async () => {
|
||||||
|
yarnVersion = "3.6.4";
|
||||||
|
await runYarnCommand(["add", "lodash"]);
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
capturedEnv.YARN_HTTPS_PROXY,
|
||||||
|
"http://localhost:8080",
|
||||||
|
"YARN_HTTPS_PROXY should be set to the HTTPS_PROXY value"
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
capturedEnv.YARN_CA_FILE_PATH,
|
||||||
|
undefined,
|
||||||
|
"YARN_CA_FILE_PATH should NOT be set to avoid overriding system CAs"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set YARN_HTTPS_PROXY for Yarn v2", async () => {
|
||||||
|
yarnVersion = "2.4.3";
|
||||||
|
await runYarnCommand(["add", "lodash"]);
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
capturedEnv.YARN_HTTPS_PROXY,
|
||||||
|
"http://localhost:8080",
|
||||||
|
"YARN_HTTPS_PROXY should be set to the HTTPS_PROXY value"
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
capturedEnv.YARN_CA_FILE_PATH,
|
||||||
|
undefined,
|
||||||
|
"YARN_CA_FILE_PATH should NOT be set to avoid overriding system CAs"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not set Yarn-specific proxy vars for Yarn v1", async () => {
|
||||||
|
yarnVersion = "1.22.19";
|
||||||
|
await runYarnCommand(["add", "lodash"]);
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
capturedEnv.YARN_HTTPS_PROXY,
|
||||||
|
undefined,
|
||||||
|
"YARN_HTTPS_PROXY should not be set for Yarn v1"
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
capturedEnv.YARN_HTTPS_CA_FILE_PATH,
|
||||||
|
undefined,
|
||||||
|
"YARN_HTTPS_CA_FILE_PATH should not be set for Yarn v1"
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
capturedEnv.YARN_CA_FILE_PATH,
|
||||||
|
undefined,
|
||||||
|
"YARN_CA_FILE_PATH should not be set for Yarn v1"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve NODE_EXTRA_CA_CERTS for all Yarn versions", async () => {
|
||||||
|
for (const version of ["4.1.0", "3.6.4", "2.4.3", "1.22.19"]) {
|
||||||
|
yarnVersion = version;
|
||||||
|
await runYarnCommand(["add", "lodash"]);
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
capturedEnv.NODE_EXTRA_CA_CERTS,
|
||||||
|
"/path/to/ca-cert.pem",
|
||||||
|
`NODE_EXTRA_CA_CERTS should be preserved for Yarn ${version}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve HTTPS_PROXY for all Yarn versions", async () => {
|
||||||
|
for (const version of ["4.1.0", "3.6.4", "2.4.3", "1.22.19"]) {
|
||||||
|
yarnVersion = version;
|
||||||
|
await runYarnCommand(["add", "lodash"]);
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
capturedEnv.HTTPS_PROXY,
|
||||||
|
"http://localhost:8080",
|
||||||
|
`HTTPS_PROXY should be preserved for Yarn ${version}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
114
packages/safe-chain/src/registryProxy/certUtils.js
Normal file
114
packages/safe-chain/src/registryProxy/certUtils.js
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import forge from "node-forge";
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
import os from "os";
|
||||||
|
|
||||||
|
const certFolder = path.join(os.homedir(), ".safe-chain", "certs");
|
||||||
|
const ca = loadCa();
|
||||||
|
|
||||||
|
const certCache = new Map();
|
||||||
|
|
||||||
|
export function getCaCertPath() {
|
||||||
|
return path.join(certFolder, "ca-cert.pem");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateCertForHost(hostname) {
|
||||||
|
let existingCert = certCache.get(hostname);
|
||||||
|
if (existingCert) {
|
||||||
|
return existingCert;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = forge.pki.rsa.generateKeyPair(2048);
|
||||||
|
const cert = forge.pki.createCertificate();
|
||||||
|
cert.publicKey = keys.publicKey;
|
||||||
|
cert.serialNumber = "01";
|
||||||
|
cert.validity.notBefore = new Date();
|
||||||
|
cert.validity.notAfter = new Date();
|
||||||
|
cert.validity.notAfter.setHours(cert.validity.notBefore.getHours() + 1);
|
||||||
|
|
||||||
|
const attrs = [{ name: "commonName", value: hostname }];
|
||||||
|
cert.setSubject(attrs);
|
||||||
|
cert.setIssuer(ca.certificate.subject.attributes);
|
||||||
|
cert.setExtensions([
|
||||||
|
{
|
||||||
|
name: "subjectAltName",
|
||||||
|
altNames: [
|
||||||
|
{
|
||||||
|
type: 2, // DNS
|
||||||
|
value: hostname,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "keyUsage",
|
||||||
|
digitalSignature: true,
|
||||||
|
keyEncipherment: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
cert.sign(ca.privateKey, forge.md.sha256.create());
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
privateKey: forge.pki.privateKeyToPem(keys.privateKey),
|
||||||
|
certificate: forge.pki.certificateToPem(cert),
|
||||||
|
};
|
||||||
|
|
||||||
|
certCache.set(hostname, result);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCa() {
|
||||||
|
const keyPath = path.join(certFolder, "ca-key.pem");
|
||||||
|
const certPath = path.join(certFolder, "ca-cert.pem");
|
||||||
|
|
||||||
|
if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
|
||||||
|
const privateKeyPem = fs.readFileSync(keyPath, "utf8");
|
||||||
|
const certPem = fs.readFileSync(certPath, "utf8");
|
||||||
|
const privateKey = forge.pki.privateKeyFromPem(privateKeyPem);
|
||||||
|
const certificate = forge.pki.certificateFromPem(certPem);
|
||||||
|
|
||||||
|
// Don't return a cert that is valid for less than 1 hour
|
||||||
|
const oneHourFromNow = new Date(Date.now() + 60 * 60 * 1000);
|
||||||
|
if (certificate.validity.notAfter > oneHourFromNow) {
|
||||||
|
return { privateKey, certificate };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { privateKey, certificate } = generateCa();
|
||||||
|
fs.mkdirSync(certFolder, { recursive: true });
|
||||||
|
fs.writeFileSync(keyPath, forge.pki.privateKeyToPem(privateKey));
|
||||||
|
fs.writeFileSync(certPath, forge.pki.certificateToPem(certificate));
|
||||||
|
return { privateKey, certificate };
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateCa() {
|
||||||
|
const keys = forge.pki.rsa.generateKeyPair(2048);
|
||||||
|
const cert = forge.pki.createCertificate();
|
||||||
|
cert.publicKey = keys.publicKey;
|
||||||
|
cert.serialNumber = "01";
|
||||||
|
cert.validity.notBefore = new Date();
|
||||||
|
cert.validity.notAfter = new Date();
|
||||||
|
cert.validity.notAfter.setDate(cert.validity.notBefore.getDate() + 1);
|
||||||
|
|
||||||
|
const attrs = [{ name: "commonName", value: "safe-chain proxy" }];
|
||||||
|
cert.setSubject(attrs);
|
||||||
|
cert.setIssuer(attrs);
|
||||||
|
cert.setExtensions([
|
||||||
|
{
|
||||||
|
name: "basicConstraints",
|
||||||
|
cA: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "keyUsage",
|
||||||
|
keyCertSign: true,
|
||||||
|
digitalSignature: true,
|
||||||
|
keyEncipherment: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
cert.sign(keys.privateKey, forge.md.sha256.create());
|
||||||
|
|
||||||
|
return {
|
||||||
|
privateKey: keys.privateKey,
|
||||||
|
certificate: cert,
|
||||||
|
};
|
||||||
|
}
|
||||||
96
packages/safe-chain/src/registryProxy/mitmRequestHandler.js
Normal file
96
packages/safe-chain/src/registryProxy/mitmRequestHandler.js
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import https from "https";
|
||||||
|
import { generateCertForHost } from "./certUtils.js";
|
||||||
|
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||||
|
|
||||||
|
export function mitmConnect(req, clientSocket, isAllowed) {
|
||||||
|
const { hostname } = new URL(`http://${req.url}`);
|
||||||
|
|
||||||
|
clientSocket.on("error", () => {
|
||||||
|
// NO-OP
|
||||||
|
// This can happen if the client TCP socket sends RST instead of FIN.
|
||||||
|
// Not subscribing to 'close' event will cause node to throw and crash.
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = createHttpsServer(hostname, isAllowed);
|
||||||
|
|
||||||
|
// Establish the connection
|
||||||
|
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||||
|
|
||||||
|
// Hand off the socket to the HTTPS server
|
||||||
|
server.emit("connection", clientSocket);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHttpsServer(hostname, isAllowed) {
|
||||||
|
const cert = generateCertForHost(hostname);
|
||||||
|
|
||||||
|
async function handleRequest(req, res) {
|
||||||
|
const pathAndQuery = getRequestPathAndQuery(req.url);
|
||||||
|
const targetUrl = `https://${hostname}${pathAndQuery}`;
|
||||||
|
|
||||||
|
if (!(await isAllowed(targetUrl))) {
|
||||||
|
res.writeHead(403, "Forbidden - blocked by safe-chain");
|
||||||
|
res.end("Blocked by safe-chain");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect request body
|
||||||
|
forwardRequest(req, hostname, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
return https.createServer(
|
||||||
|
{
|
||||||
|
key: cert.privateKey,
|
||||||
|
cert: cert.certificate,
|
||||||
|
},
|
||||||
|
handleRequest
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestPathAndQuery(url) {
|
||||||
|
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
return parsedUrl.pathname + parsedUrl.search + parsedUrl.hash;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function forwardRequest(req, hostname, res) {
|
||||||
|
const proxyReq = createProxyRequest(hostname, req, res);
|
||||||
|
|
||||||
|
proxyReq.on("error", () => {
|
||||||
|
res.writeHead(502);
|
||||||
|
res.end("Bad Gateway");
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on("data", (chunk) => {
|
||||||
|
proxyReq.write(chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on("end", () => {
|
||||||
|
proxyReq.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProxyRequest(hostname, req, res) {
|
||||||
|
const options = {
|
||||||
|
hostname: hostname,
|
||||||
|
port: 443,
|
||||||
|
path: req.url,
|
||||||
|
method: req.method,
|
||||||
|
headers: { ...req.headers },
|
||||||
|
};
|
||||||
|
|
||||||
|
delete options.headers.host;
|
||||||
|
|
||||||
|
const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy;
|
||||||
|
if (httpsProxy) {
|
||||||
|
options.agent = new HttpsProxyAgent(httpsProxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyReq = https.request(options, (proxyRes) => {
|
||||||
|
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||||
|
proxyRes.pipe(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
return proxyReq;
|
||||||
|
}
|
||||||
48
packages/safe-chain/src/registryProxy/parsePackageFromUrl.js
Normal file
48
packages/safe-chain/src/registryProxy/parsePackageFromUrl.js
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
export const knownRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"];
|
||||||
|
|
||||||
|
export function parsePackageFromUrl(url) {
|
||||||
|
let packageName, version, registry;
|
||||||
|
|
||||||
|
for (const knownRegistry of knownRegistries) {
|
||||||
|
if (url.includes(knownRegistry)) {
|
||||||
|
registry = knownRegistry;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!registry || !url.endsWith(".tgz")) {
|
||||||
|
return { packageName, version };
|
||||||
|
}
|
||||||
|
|
||||||
|
const registryIndex = url.indexOf(registry);
|
||||||
|
const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash
|
||||||
|
|
||||||
|
const separatorIndex = afterRegistry.indexOf("/-/");
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
return { packageName, version };
|
||||||
|
}
|
||||||
|
|
||||||
|
packageName = afterRegistry.substring(0, separatorIndex);
|
||||||
|
const filename = afterRegistry.substring(
|
||||||
|
separatorIndex + 3,
|
||||||
|
afterRegistry.length - 4
|
||||||
|
); // Remove /-/ and .tgz
|
||||||
|
|
||||||
|
// Extract version from filename
|
||||||
|
// For scoped packages like @babel/core, the filename is core-7.21.4.tgz
|
||||||
|
// For regular packages like lodash, the filename is lodash-4.17.21.tgz
|
||||||
|
if (packageName.startsWith("@")) {
|
||||||
|
const scopedPackageName = packageName.substring(
|
||||||
|
packageName.lastIndexOf("/") + 1
|
||||||
|
);
|
||||||
|
if (filename.startsWith(scopedPackageName + "-")) {
|
||||||
|
version = filename.substring(scopedPackageName.length + 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (filename.startsWith(packageName + "-")) {
|
||||||
|
version = filename.substring(packageName.length + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { packageName, version };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import assert from "node:assert";
|
||||||
|
import { parsePackageFromUrl } from "./parsePackageFromUrl.js";
|
||||||
|
|
||||||
|
describe("parsePackageFromUrl", () => {
|
||||||
|
const testCases = [
|
||||||
|
// Regular packages
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
expected: { packageName: "lodash", version: "4.17.21" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
|
||||||
|
expected: { packageName: "express", version: "4.18.2" },
|
||||||
|
},
|
||||||
|
// Packages with hyphens in name
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/safe-chain-test/-/safe-chain-test-1.0.0.tgz",
|
||||||
|
expected: { packageName: "safe-chain-test", version: "1.0.0" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/web-vitals/-/web-vitals-3.5.0.tgz",
|
||||||
|
expected: { packageName: "web-vitals", version: "3.5.0" },
|
||||||
|
},
|
||||||
|
// Preview/prerelease versions
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/safe-chain-test/-/safe-chain-test-0.0.1-security.tgz",
|
||||||
|
expected: { packageName: "safe-chain-test", version: "0.0.1-security" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/lodash/-/lodash-5.0.0-beta.1.tgz",
|
||||||
|
expected: { packageName: "lodash", version: "5.0.0-beta.1" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/react/-/react-18.3.0-canary-abc123.tgz",
|
||||||
|
expected: { packageName: "react", version: "18.3.0-canary-abc123" },
|
||||||
|
},
|
||||||
|
// Scoped packages
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/@babel/core/-/core-7.21.4.tgz",
|
||||||
|
expected: { packageName: "@babel/core", version: "7.21.4" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/@types/node/-/node-20.10.5.tgz",
|
||||||
|
expected: { packageName: "@types/node", version: "20.10.5" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/@angular/common/-/common-17.0.8.tgz",
|
||||||
|
expected: { packageName: "@angular/common", version: "17.0.8" },
|
||||||
|
},
|
||||||
|
// Scoped packages with hyphens
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/@safe-chain/test-package/-/test-package-2.1.0.tgz",
|
||||||
|
expected: { packageName: "@safe-chain/test-package", version: "2.1.0" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.465.0.tgz",
|
||||||
|
expected: { packageName: "@aws-sdk/client-s3", version: "3.465.0" },
|
||||||
|
},
|
||||||
|
// Scoped packages with preview versions
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/@babel/core/-/core-8.0.0-alpha.1.tgz",
|
||||||
|
expected: { packageName: "@babel/core", version: "8.0.0-alpha.1" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/@safe-chain/security-test/-/security-test-1.0.0-security.tgz",
|
||||||
|
expected: {
|
||||||
|
packageName: "@safe-chain/security-test",
|
||||||
|
version: "1.0.0-security",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Yarn registry
|
||||||
|
{
|
||||||
|
url: "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
expected: { packageName: "lodash", version: "4.17.21" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://registry.yarnpkg.com/@babel/core/-/core-7.21.4.tgz",
|
||||||
|
expected: { packageName: "@babel/core", version: "7.21.4" },
|
||||||
|
},
|
||||||
|
// Invalid URLs should return undefined values
|
||||||
|
{
|
||||||
|
url: "https://example.com/package.tgz",
|
||||||
|
expected: { packageName: undefined, version: undefined },
|
||||||
|
},
|
||||||
|
// URL to get package info, not tarball
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/lodash",
|
||||||
|
expected: { packageName: undefined, version: undefined },
|
||||||
|
},
|
||||||
|
// Complex version patterns
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/package-with-many-hyphens/-/package-with-many-hyphens-1.0.0-rc.1+build.123.tgz",
|
||||||
|
expected: {
|
||||||
|
packageName: "package-with-many-hyphens",
|
||||||
|
version: "1.0.0-rc.1+build.123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/@scope/package-name-with-hyphens/-/package-name-with-hyphens-2.0.0-beta.2.tgz",
|
||||||
|
expected: {
|
||||||
|
packageName: "@scope/package-name-with-hyphens",
|
||||||
|
version: "2.0.0-beta.2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
testCases.forEach(({ url, expected }, index) => {
|
||||||
|
it(`should parse URL ${index + 1}: ${url}`, () => {
|
||||||
|
const result = parsePackageFromUrl(url);
|
||||||
|
assert.deepEqual(result, expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
69
packages/safe-chain/src/registryProxy/plainHttpProxy.js
Normal file
69
packages/safe-chain/src/registryProxy/plainHttpProxy.js
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import * as http from "http";
|
||||||
|
import * as https from "https";
|
||||||
|
|
||||||
|
export function handleHttpProxyRequest(req, res) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
|
||||||
|
// The protocol for the plainHttpProxy should usually only be http:
|
||||||
|
// but when the client for some reason sends an https: request directly
|
||||||
|
// instead of using the CONNECT method, we should handle it gracefully.
|
||||||
|
let protocol;
|
||||||
|
if (url.protocol === "http:") {
|
||||||
|
protocol = http;
|
||||||
|
} else if (url.protocol === "https:") {
|
||||||
|
protocol = https;
|
||||||
|
} else {
|
||||||
|
res.writeHead(502);
|
||||||
|
res.end(`Bad Gateway: Unsupported protocol ${url.protocol}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyRequest = protocol
|
||||||
|
.request(
|
||||||
|
req.url,
|
||||||
|
{ method: req.method, headers: req.headers },
|
||||||
|
(proxyRes) => {
|
||||||
|
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||||
|
proxyRes.pipe(res);
|
||||||
|
|
||||||
|
proxyRes.on("error", () => {
|
||||||
|
// Proxy response stream error
|
||||||
|
// Clean up client response stream
|
||||||
|
if (res.writable) {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proxyRes.on("close", () => {
|
||||||
|
// Clean up if the proxy response stream closes
|
||||||
|
if (res.writable) {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.on("error", (err) => {
|
||||||
|
res.writeHead(502);
|
||||||
|
res.end(`Bad Gateway: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on("error", () => {
|
||||||
|
// Client request stream error
|
||||||
|
// Abort the proxy request
|
||||||
|
proxyRequest.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on("error", () => {
|
||||||
|
// Client response stream error (client disconnected)
|
||||||
|
// Clean up proxy streams
|
||||||
|
proxyRequest.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on("close", () => {
|
||||||
|
// Client disconnected
|
||||||
|
// Abort the proxy request to avoid unnecessary work
|
||||||
|
proxyRequest.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
req.pipe(proxyRequest);
|
||||||
|
}
|
||||||
160
packages/safe-chain/src/registryProxy/registryProxy.js
Normal file
160
packages/safe-chain/src/registryProxy/registryProxy.js
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
import * as http from "http";
|
||||||
|
import { tunnelRequest } from "./tunnelRequestHandler.js";
|
||||||
|
import { mitmConnect } from "./mitmRequestHandler.js";
|
||||||
|
import { handleHttpProxyRequest } from "./plainHttpProxy.js";
|
||||||
|
import { getCaCertPath } from "./certUtils.js";
|
||||||
|
import { auditChanges } from "../scanning/audit/index.js";
|
||||||
|
import { knownRegistries, parsePackageFromUrl } from "./parsePackageFromUrl.js";
|
||||||
|
import { ui } from "../environment/userInteraction.js";
|
||||||
|
import chalk from "chalk";
|
||||||
|
|
||||||
|
const SERVER_STOP_TIMEOUT_MS = 1000;
|
||||||
|
const state = {
|
||||||
|
port: null,
|
||||||
|
blockedRequests: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createSafeChainProxy() {
|
||||||
|
const server = createProxyServer();
|
||||||
|
|
||||||
|
return {
|
||||||
|
startServer: () => startServer(server),
|
||||||
|
stopServer: () => stopServer(server),
|
||||||
|
verifyNoMaliciousPackages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSafeChainProxyEnvironmentVariables() {
|
||||||
|
if (!state.port) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
HTTPS_PROXY: `http://localhost:${state.port}`,
|
||||||
|
GLOBAL_AGENT_HTTP_PROXY: `http://localhost:${state.port}`,
|
||||||
|
NODE_EXTRA_CA_CERTS: getCaCertPath(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeSafeChainProxyEnvironmentVariables(env) {
|
||||||
|
const proxyEnv = getSafeChainProxyEnvironmentVariables();
|
||||||
|
|
||||||
|
for (const key of Object.keys(env)) {
|
||||||
|
// If we were to simply copy all env variables, we might overwrite
|
||||||
|
// the proxy settings set by safe-chain when casing varies (e.g. http_proxy vs HTTP_PROXY)
|
||||||
|
// So we only copy the variable if it's not already set in a different case
|
||||||
|
const upperKey = key.toUpperCase();
|
||||||
|
|
||||||
|
if (!proxyEnv[upperKey]) {
|
||||||
|
proxyEnv[key] = env[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProxyServer() {
|
||||||
|
const server = http.createServer(
|
||||||
|
// This handles direct HTTP requests (non-CONNECT requests)
|
||||||
|
// This is normally http-only traffic, but we also handle
|
||||||
|
// https for clients that don't properly use CONNECT
|
||||||
|
handleHttpProxyRequest
|
||||||
|
);
|
||||||
|
|
||||||
|
// This handles HTTPS requests via the CONNECT method
|
||||||
|
server.on("connect", handleConnect);
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startServer(server) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Passing port 0 makes the OS assign an available port
|
||||||
|
server.listen(0, () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (address && typeof address === "object") {
|
||||||
|
state.port = address.port;
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error("Failed to start proxy server"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on("error", (err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopServer(server) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
server.close(() => {
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
setTimeout(() => resolve(), SERVER_STOP_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConnect(req, clientSocket, head) {
|
||||||
|
// CONNECT method is used for HTTPS requests
|
||||||
|
// It establishes a tunnel to the server identified by the request URL
|
||||||
|
|
||||||
|
if (knownRegistries.some((reg) => req.url.includes(reg))) {
|
||||||
|
// For npm and yarn registries, we want to intercept and inspect the traffic
|
||||||
|
// so we can block packages with malware
|
||||||
|
mitmConnect(req, clientSocket, isAllowedUrl);
|
||||||
|
} else {
|
||||||
|
// For other hosts, just tunnel the request to the destination tcp socket
|
||||||
|
tunnelRequest(req, clientSocket, head);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isAllowedUrl(url) {
|
||||||
|
const { packageName, version } = parsePackageFromUrl(url);
|
||||||
|
|
||||||
|
// packageName and version are undefined when the URL is not a package download
|
||||||
|
// In that case, we can allow the request to proceed
|
||||||
|
if (!packageName || !version) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auditResult = await auditChanges([
|
||||||
|
{ name: packageName, version, type: "add" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!auditResult.isAllowed) {
|
||||||
|
state.blockedRequests.push({ packageName, version, url });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyNoMaliciousPackages() {
|
||||||
|
if (state.blockedRequests.length === 0) {
|
||||||
|
// No malicious packages were blocked, so nothing to block
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.emptyLine();
|
||||||
|
|
||||||
|
ui.writeInformation(
|
||||||
|
`Safe-chain: ${chalk.bold(
|
||||||
|
`blocked ${state.blockedRequests.length} malicious package downloads`
|
||||||
|
)}:`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const req of state.blockedRequests) {
|
||||||
|
ui.writeInformation(` - ${req.packageName}@${req.version} (${req.url})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.emptyLine();
|
||||||
|
ui.writeError("Exiting without installing malicious packages.");
|
||||||
|
ui.emptyLine();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
114
packages/safe-chain/src/registryProxy/tunnelRequestHandler.js
Normal file
114
packages/safe-chain/src/registryProxy/tunnelRequestHandler.js
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import * as net from "net";
|
||||||
|
import { ui } from "../environment/userInteraction.js";
|
||||||
|
|
||||||
|
export function tunnelRequest(req, clientSocket, head) {
|
||||||
|
const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy;
|
||||||
|
|
||||||
|
if (httpsProxy) {
|
||||||
|
// If an HTTPS proxy is set, tunnel the request via the proxy
|
||||||
|
// This is the system proxy, not the safe-chain proxy
|
||||||
|
// The package manager will run via the safe-chain proxy
|
||||||
|
// The safe-chain proxy will then send the request to the system proxy
|
||||||
|
// Typical flow: package manager -> safe-chain proxy -> system proxy -> destination
|
||||||
|
|
||||||
|
// There are 2 processes involved in this:
|
||||||
|
// 1. Safe-chain process: has HTTPS_PROXY set to system proxy
|
||||||
|
// 2. Package manager process: has HTTPS_PROXY set to safe-chain proxy
|
||||||
|
|
||||||
|
tunnelRequestViaProxy(req, clientSocket, head, httpsProxy);
|
||||||
|
} else {
|
||||||
|
tunnelRequestToDestination(req, clientSocket, head);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tunnelRequestToDestination(req, clientSocket, head) {
|
||||||
|
const { port, hostname } = new URL(`http://${req.url}`);
|
||||||
|
|
||||||
|
clientSocket.on("error", () => {
|
||||||
|
// NO-OP
|
||||||
|
// This can happen if the client TCP socket sends RST instead of FIN.
|
||||||
|
// Not subscribing to 'close' event will cause node to throw and crash.
|
||||||
|
});
|
||||||
|
|
||||||
|
const serverSocket = net.connect(port || 443, hostname, () => {
|
||||||
|
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||||
|
serverSocket.write(head);
|
||||||
|
serverSocket.pipe(clientSocket);
|
||||||
|
clientSocket.pipe(serverSocket);
|
||||||
|
});
|
||||||
|
|
||||||
|
serverSocket.on("error", (err) => {
|
||||||
|
ui.writeError(
|
||||||
|
`Safe-chain: error connecting to ${hostname}:${port} - ${err.message}`
|
||||||
|
);
|
||||||
|
if (clientSocket.writable) {
|
||||||
|
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) {
|
||||||
|
const { port, hostname } = new URL(`http://${req.url}`);
|
||||||
|
const proxy = new URL(proxyUrl);
|
||||||
|
|
||||||
|
// Connect to proxy server
|
||||||
|
const proxySocket = net.connect({
|
||||||
|
host: proxy.hostname,
|
||||||
|
port: proxy.port,
|
||||||
|
});
|
||||||
|
|
||||||
|
proxySocket.on("connect", () => {
|
||||||
|
// Send CONNECT request to proxy
|
||||||
|
const connectRequest = [
|
||||||
|
`CONNECT ${hostname}:${port || 443} HTTP/1.1`,
|
||||||
|
`Host: ${hostname}:${port || 443}`,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
].join("\r\n");
|
||||||
|
|
||||||
|
proxySocket.write(connectRequest);
|
||||||
|
});
|
||||||
|
|
||||||
|
let isConnected = false;
|
||||||
|
proxySocket.once("data", (data) => {
|
||||||
|
const response = data.toString();
|
||||||
|
|
||||||
|
// Check if CONNECT succeeded (HTTP/1.1 200)
|
||||||
|
if (response.startsWith("HTTP/1.1 200")) {
|
||||||
|
isConnected = true;
|
||||||
|
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||||
|
proxySocket.write(head);
|
||||||
|
proxySocket.pipe(clientSocket);
|
||||||
|
clientSocket.pipe(proxySocket);
|
||||||
|
} else {
|
||||||
|
ui.writeError(
|
||||||
|
`Safe-chain: proxy CONNECT failed: ${response.split("\r\n")[0]}`
|
||||||
|
);
|
||||||
|
if (clientSocket.writable) {
|
||||||
|
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||||
|
}
|
||||||
|
if (proxySocket.writable) {
|
||||||
|
proxySocket.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proxySocket.on("error", (err) => {
|
||||||
|
if (!isConnected) {
|
||||||
|
ui.writeError(
|
||||||
|
`Safe-chain: error connecting to proxy ${proxy.hostname}:${
|
||||||
|
proxy.port || 8080
|
||||||
|
} - ${err.message}`
|
||||||
|
);
|
||||||
|
if (clientSocket.writable) {
|
||||||
|
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clientSocket.on("error", () => {
|
||||||
|
if (proxySocket.writable) {
|
||||||
|
proxySocket.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -61,10 +61,11 @@ export async function scanCommand(args) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!audit || audit.isAllowed) {
|
if (!audit || audit.isAllowed) {
|
||||||
spinner.succeed("Safe-chain: No malicious packages detected.");
|
spinner.stop();
|
||||||
|
return 0;
|
||||||
} else {
|
} else {
|
||||||
printMaliciousChanges(audit.disallowedChanges, spinner);
|
printMaliciousChanges(audit.disallowedChanges, spinner);
|
||||||
await onMalwareFound();
|
return await onMalwareFound();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,11 +89,11 @@ async function onMalwareFound() {
|
||||||
|
|
||||||
if (continueInstall) {
|
if (continueInstall) {
|
||||||
ui.writeWarning("Continuing with the installation despite the risks...");
|
ui.writeWarning("Continuing with the installation despite the risks...");
|
||||||
return;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.writeError("Exiting without installing malicious packages.");
|
ui.writeError("Exiting without installing malicious packages.");
|
||||||
ui.emptyLine();
|
ui.emptyLine();
|
||||||
process.exit(1);
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it, mock } from "node:test";
|
import { beforeEach, describe, it, mock } from "node:test";
|
||||||
import { setTimeout } from "node:timers/promises";
|
import { setTimeout } from "node:timers/promises";
|
||||||
import {
|
import {
|
||||||
MALWARE_ACTION_PROMPT,
|
MALWARE_ACTION_PROMPT,
|
||||||
|
|
@ -13,6 +13,7 @@ describe("scanCommand", async () => {
|
||||||
setText: () => {},
|
setText: () => {},
|
||||||
succeed: () => {},
|
succeed: () => {},
|
||||||
fail: () => {},
|
fail: () => {},
|
||||||
|
stop: () => {},
|
||||||
}));
|
}));
|
||||||
const mockConfirm = mock.fn(() => true);
|
const mockConfirm = mock.fn(() => true);
|
||||||
let malwareAction = MALWARE_ACTION_PROMPT;
|
let malwareAction = MALWARE_ACTION_PROMPT;
|
||||||
|
|
@ -87,30 +88,37 @@ describe("scanCommand", async () => {
|
||||||
|
|
||||||
const { scanCommand } = await import("./index.js");
|
const { scanCommand } = await import("./index.js");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset malware action back to prompt mode for other tests
|
||||||
|
malwareAction = MALWARE_ACTION_PROMPT;
|
||||||
|
});
|
||||||
|
|
||||||
it("should succeed when there are no changes", async () => {
|
it("should succeed when there are no changes", async () => {
|
||||||
let successMessageWasSet = false;
|
let progressWasStopped = false;
|
||||||
mockStartProcess.mock.mockImplementationOnce(() => ({
|
mockStartProcess.mock.mockImplementationOnce(() => ({
|
||||||
setText: () => {},
|
setText: () => {},
|
||||||
succeed: () => {
|
succeed: () => {},
|
||||||
successMessageWasSet = true;
|
|
||||||
},
|
|
||||||
fail: () => {},
|
fail: () => {},
|
||||||
|
stop: () => {
|
||||||
|
progressWasStopped = true;
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => []);
|
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => []);
|
||||||
|
|
||||||
await scanCommand(["install", "lodash"]);
|
await scanCommand(["install", "lodash"]);
|
||||||
|
|
||||||
assert.equal(successMessageWasSet, true);
|
assert.equal(progressWasStopped, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should succeed when changes are not malicious", async () => {
|
it("should succeed when changes are not malicious", async () => {
|
||||||
let successMessageWasSet = false;
|
let progressWasStopped = false;
|
||||||
mockStartProcess.mock.mockImplementationOnce(() => ({
|
mockStartProcess.mock.mockImplementationOnce(() => ({
|
||||||
setText: () => {},
|
setText: () => {},
|
||||||
succeed: () => {
|
succeed: () => {},
|
||||||
successMessageWasSet = true;
|
|
||||||
},
|
|
||||||
fail: () => {},
|
fail: () => {},
|
||||||
|
stop: () => {
|
||||||
|
progressWasStopped = true;
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [
|
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [
|
||||||
{ name: "lodash", version: "4.17.21" },
|
{ name: "lodash", version: "4.17.21" },
|
||||||
|
|
@ -118,7 +126,7 @@ describe("scanCommand", async () => {
|
||||||
|
|
||||||
await scanCommand(["install", "lodash"]);
|
await scanCommand(["install", "lodash"]);
|
||||||
|
|
||||||
assert.equal(successMessageWasSet, true);
|
assert.equal(progressWasStopped, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw an error when timing out", async () => {
|
it("should throw an error when timing out", async () => {
|
||||||
|
|
@ -129,6 +137,7 @@ describe("scanCommand", async () => {
|
||||||
fail: () => {
|
fail: () => {
|
||||||
failureMessageWasSet = true;
|
failureMessageWasSet = true;
|
||||||
},
|
},
|
||||||
|
stop: () => {},
|
||||||
}));
|
}));
|
||||||
getScanTimeoutMock.mock.mockImplementationOnce(() => 100);
|
getScanTimeoutMock.mock.mockImplementationOnce(() => 100);
|
||||||
mockGetDependencyUpdatesForCommand.mock.mockImplementation(async () => {
|
mockGetDependencyUpdatesForCommand.mock.mockImplementation(async () => {
|
||||||
|
|
@ -149,6 +158,7 @@ describe("scanCommand", async () => {
|
||||||
fail: () => {
|
fail: () => {
|
||||||
failureMessageWasSet = true;
|
failureMessageWasSet = true;
|
||||||
},
|
},
|
||||||
|
stop: () => {},
|
||||||
}));
|
}));
|
||||||
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [
|
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [
|
||||||
{ name: "malicious", version: "1.0.0" },
|
{ name: "malicious", version: "1.0.0" },
|
||||||
|
|
@ -173,6 +183,7 @@ describe("scanCommand", async () => {
|
||||||
fail: (message) => {
|
fail: (message) => {
|
||||||
failureMessages.push(message);
|
failureMessages.push(message);
|
||||||
},
|
},
|
||||||
|
stop: () => {},
|
||||||
}));
|
}));
|
||||||
getScanTimeoutMock.mock.mockImplementationOnce(() => 100);
|
getScanTimeoutMock.mock.mockImplementationOnce(() => 100);
|
||||||
mockGetDependencyUpdatesForCommand.mock.mockImplementation(async () => {
|
mockGetDependencyUpdatesForCommand.mock.mockImplementation(async () => {
|
||||||
|
|
@ -194,46 +205,29 @@ describe("scanCommand", async () => {
|
||||||
it("should exit immediately when malicious changes are detected in block mode", async () => {
|
it("should exit immediately when malicious changes are detected in block mode", async () => {
|
||||||
// Set malware action to block mode for this test
|
// Set malware action to block mode for this test
|
||||||
malwareAction = MALWARE_ACTION_BLOCK;
|
malwareAction = MALWARE_ACTION_BLOCK;
|
||||||
|
|
||||||
// Reset mock call count
|
// Reset mock call count
|
||||||
mockConfirm.mock.resetCalls();
|
mockConfirm.mock.resetCalls();
|
||||||
|
|
||||||
let failureMessageWasSet = false;
|
let failureMessageWasSet = false;
|
||||||
let exitCode = null;
|
|
||||||
|
|
||||||
mockStartProcess.mock.mockImplementationOnce(() => ({
|
mockStartProcess.mock.mockImplementationOnce(() => ({
|
||||||
setText: () => {},
|
setText: () => {},
|
||||||
succeed: () => {},
|
succeed: () => {},
|
||||||
fail: () => {
|
fail: () => {
|
||||||
failureMessageWasSet = true;
|
failureMessageWasSet = true;
|
||||||
},
|
},
|
||||||
|
stop: () => {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [
|
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [
|
||||||
{ name: "malicious", version: "1.0.0" },
|
{ name: "malicious", version: "1.0.0" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Mock process.exit
|
const result = await scanCommand(["install", "malicious"]);
|
||||||
const originalExit = process.exit;
|
|
||||||
process.exit = mock.fn((code) => {
|
|
||||||
exitCode = code;
|
|
||||||
throw new Error("Process exit called"); // Prevent actual exit
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await assert.rejects(
|
|
||||||
scanCommand(["install", "malicious"]),
|
|
||||||
/Process exit called/
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
// Restore original process.exit
|
|
||||||
process.exit = originalExit;
|
|
||||||
// Reset malware action back to prompt mode for other tests
|
|
||||||
malwareAction = MALWARE_ACTION_PROMPT;
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.equal(failureMessageWasSet, true);
|
assert.equal(failureMessageWasSet, true);
|
||||||
assert.equal(exitCode, 1);
|
assert.equal(result, 1);
|
||||||
// Confirm should not have been called in block mode
|
// Confirm should not have been called in block mode
|
||||||
assert.equal(mockConfirm.mock.callCount(), 0);
|
assert.equal(mockConfirm.mock.callCount(), 0);
|
||||||
});
|
});
|
||||||
|
|
@ -241,19 +235,19 @@ describe("scanCommand", async () => {
|
||||||
it("should exit immediately when malicious changes are detected in block mode without prompting", async () => {
|
it("should exit immediately when malicious changes are detected in block mode without prompting", async () => {
|
||||||
// Set malware action to block mode for this test
|
// Set malware action to block mode for this test
|
||||||
malwareAction = MALWARE_ACTION_BLOCK;
|
malwareAction = MALWARE_ACTION_BLOCK;
|
||||||
|
|
||||||
// Reset mock call count
|
// Reset mock call count
|
||||||
mockConfirm.mock.resetCalls();
|
mockConfirm.mock.resetCalls();
|
||||||
|
|
||||||
let processExited = false;
|
|
||||||
let userWasPrompted = false;
|
let userWasPrompted = false;
|
||||||
|
|
||||||
mockStartProcess.mock.mockImplementationOnce(() => ({
|
mockStartProcess.mock.mockImplementationOnce(() => ({
|
||||||
setText: () => {},
|
setText: () => {},
|
||||||
succeed: () => {},
|
succeed: () => {},
|
||||||
fail: () => {},
|
fail: () => {},
|
||||||
|
stop: () => {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [
|
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [
|
||||||
{ name: "malicious", version: "1.0.0" },
|
{ name: "malicious", version: "1.0.0" },
|
||||||
]);
|
]);
|
||||||
|
|
@ -263,26 +257,9 @@ describe("scanCommand", async () => {
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock process.exit
|
const result = await scanCommand(["install", "malicious"]);
|
||||||
const originalExit = process.exit;
|
|
||||||
process.exit = mock.fn(() => {
|
|
||||||
processExited = true;
|
|
||||||
throw new Error("Process exit called"); // Prevent actual exit
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
assert.equal(result, 1);
|
||||||
await assert.rejects(
|
|
||||||
scanCommand(["install", "malicious"]),
|
|
||||||
/Process exit called/
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
// Restore original process.exit
|
|
||||||
process.exit = originalExit;
|
|
||||||
// Reset malware action back to prompt mode for other tests
|
|
||||||
malwareAction = MALWARE_ACTION_PROMPT;
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.equal(processExited, true);
|
|
||||||
assert.equal(userWasPrompted, false);
|
assert.equal(userWasPrompted, false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,13 @@ import {
|
||||||
} from "../config/configFile.js";
|
} from "../config/configFile.js";
|
||||||
import { ui } from "../environment/userInteraction.js";
|
import { ui } from "../environment/userInteraction.js";
|
||||||
|
|
||||||
|
let cachedMalwareDatabase = null;
|
||||||
|
|
||||||
export async function openMalwareDatabase() {
|
export async function openMalwareDatabase() {
|
||||||
|
if (cachedMalwareDatabase) {
|
||||||
|
return cachedMalwareDatabase;
|
||||||
|
}
|
||||||
|
|
||||||
const malwareDatabase = await getMalwareDatabase();
|
const malwareDatabase = await getMalwareDatabase();
|
||||||
|
|
||||||
function getPackageStatus(name, version) {
|
function getPackageStatus(name, version) {
|
||||||
|
|
@ -25,13 +31,16 @@ export async function openMalwareDatabase() {
|
||||||
return packageData.reason;
|
return packageData.reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
// This implicitely caches the malware database
|
||||||
|
// that's closed over by the getPackageStatus function
|
||||||
|
cachedMalwareDatabase = {
|
||||||
getPackageStatus,
|
getPackageStatus,
|
||||||
isMalware: (name, version) => {
|
isMalware: (name, version) => {
|
||||||
const status = getPackageStatus(name, version);
|
const status = getPackageStatus(name, version);
|
||||||
return isMalwareStatus(status);
|
return isMalwareStatus(status);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
return cachedMalwareDatabase;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMalwareDatabase() {
|
async function getMalwareDatabase() {
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,9 @@ export const knownAikidoTools = [
|
||||||
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
|
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
|
||||||
{ tool: "pnpm", aikidoCommand: "aikido-pnpm" },
|
{ tool: "pnpm", aikidoCommand: "aikido-pnpm" },
|
||||||
{ tool: "pnpx", aikidoCommand: "aikido-pnpx" },
|
{ tool: "pnpx", aikidoCommand: "aikido-pnpx" },
|
||||||
// When adding a new tool here, also update the expected alias in the tests (setup.spec.js, teardown.spec.js)
|
{ tool: "bun", aikidoCommand: "aikido-bun" },
|
||||||
// and add the documentation for the new tool in the README.md
|
{ tool: "bunx", aikidoCommand: "aikido-bunx" },
|
||||||
|
// When adding a new tool here, also update the documentation for the new tool in the README.md
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -18,15 +19,15 @@ export const knownAikidoTools = [
|
||||||
* Example: "npm, npx, yarn, pnpm, and pnpx commands"
|
* Example: "npm, npx, yarn, pnpm, and pnpx commands"
|
||||||
*/
|
*/
|
||||||
export function getPackageManagerList() {
|
export function getPackageManagerList() {
|
||||||
const tools = knownAikidoTools.map(t => t.tool);
|
const tools = knownAikidoTools.map((t) => t.tool);
|
||||||
if (tools.length <= 1) {
|
if (tools.length <= 1) {
|
||||||
return `${tools[0] || ''} commands`;
|
return `${tools[0] || ""} commands`;
|
||||||
}
|
}
|
||||||
if (tools.length === 2) {
|
if (tools.length === 2) {
|
||||||
return `${tools[0]} and ${tools[1]} commands`;
|
return `${tools[0]} and ${tools[1]} commands`;
|
||||||
}
|
}
|
||||||
const lastTool = tools.pop();
|
const lastTool = tools.pop();
|
||||||
return `${tools.join(', ')}, and ${lastTool} commands`;
|
return `${tools.join(", ")}, and ${lastTool} commands`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function doesExecutableExistOnSystem(executableName) {
|
export function doesExecutableExistOnSystem(executableName) {
|
||||||
|
|
@ -47,7 +48,7 @@ export function removeLinesMatchingPattern(filePath, pattern, eol) {
|
||||||
eol = eol || os.EOL;
|
eol = eol || os.EOL;
|
||||||
|
|
||||||
const fileContent = fs.readFileSync(filePath, "utf-8");
|
const fileContent = fs.readFileSync(filePath, "utf-8");
|
||||||
const lines = fileContent.split(/[\r\n\u2028\u2029]/);
|
const lines = fileContent.split(/\r?\n|\r|\u2028|\u2029/);
|
||||||
const updatedLines = lines.filter((line) => !shouldRemoveLine(line, pattern));
|
const updatedLines = lines.filter((line) => !shouldRemoveLine(line, pattern));
|
||||||
fs.writeFileSync(filePath, updatedLines.join(eol), "utf-8");
|
fs.writeFileSync(filePath, updatedLines.join(eol), "utf-8");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ describe("removeLinesMatchingPatternTests", () => {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
EOL: "\r\n", // Simulate Windows line endings
|
EOL: "\r\n", // Simulate Windows line endings
|
||||||
tmpdir: tmpdir,
|
tmpdir: tmpdir,
|
||||||
platform: () => "linux"
|
platform: () => "linux",
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -31,54 +31,59 @@ describe("removeLinesMatchingPatternTests", () => {
|
||||||
mock.reset();
|
mock.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
it("should handle mixed line endings without wiping entire file", async () => {
|
it("should handle mixed line endings without wiping entire file", async () => {
|
||||||
// Import helpers after setting up the mock
|
// Import helpers after setting up the mock
|
||||||
const { removeLinesMatchingPattern } = await import("./helpers.js");
|
const { removeLinesMatchingPattern } = await import("./helpers.js");
|
||||||
|
|
||||||
// Create a file with Unix line endings but os.EOL expects Windows
|
// Create a file with Unix line endings but os.EOL expects Windows
|
||||||
const fileContent = [
|
const fileContent = [
|
||||||
"# keep this line",
|
"# keep this line",
|
||||||
"alias npm='remove-this'",
|
"alias npm='remove-this'",
|
||||||
"# keep this line too",
|
"# keep this line too",
|
||||||
"alias yarn='remove-this-too'",
|
"alias yarn='remove-this-too'",
|
||||||
"# final line to keep"
|
"# final line to keep",
|
||||||
].join("\n"); // File has Unix line endings
|
].join("\n"); // File has Unix line endings
|
||||||
|
|
||||||
fs.writeFileSync(testFile, fileContent, "utf-8");
|
fs.writeFileSync(testFile, fileContent, "utf-8");
|
||||||
|
|
||||||
// Try to remove lines containing 'alias'
|
// Try to remove lines containing 'alias'
|
||||||
const pattern = /alias.*=/;
|
const pattern = /alias.*=/;
|
||||||
removeLinesMatchingPattern(testFile, pattern);
|
removeLinesMatchingPattern(testFile, pattern);
|
||||||
|
|
||||||
const result = fs.readFileSync(testFile, "utf-8");
|
const result = fs.readFileSync(testFile, "utf-8");
|
||||||
|
|
||||||
// This test will fail because the function splits on '\r\n' but file uses '\n'
|
// This test will fail because the function splits on '\r\n' but file uses '\n'
|
||||||
// So it treats the entire content as one line and if any part matches, removes everything
|
// So it treats the entire content as one line and if any part matches, removes everything
|
||||||
assert.ok(result.includes("keep this line"), "Should preserve non-matching lines");
|
assert.ok(
|
||||||
assert.ok(result.includes("final line to keep"), "Should preserve final line");
|
result.includes("keep this line"),
|
||||||
|
"Should preserve non-matching lines"
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.includes("final line to keep"),
|
||||||
|
"Should preserve final line"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle mixed line endings with short matching content", async () => {
|
it("should handle mixed line endings with short matching content", async () => {
|
||||||
// Import helpers after setting up the mock
|
// Import helpers after setting up the mock
|
||||||
const { removeLinesMatchingPattern } = await import("./helpers.js");
|
const { removeLinesMatchingPattern } = await import("./helpers.js");
|
||||||
|
|
||||||
// Create a file with Unix line endings, but make the entire content short
|
// Create a file with Unix line endings, but make the entire content short
|
||||||
// to bypass the maxLineLength protection
|
// to bypass the maxLineLength protection
|
||||||
const fileContent = [
|
const fileContent = [
|
||||||
"# keep1",
|
"# keep1",
|
||||||
"alias x=y", // Short alias line that should be removed
|
"alias x=y", // Short alias line that should be removed
|
||||||
"# keep2"
|
"# keep2",
|
||||||
].join("\n"); // File has Unix line endings, total length < 100 chars
|
].join("\n"); // File has Unix line endings, total length < 100 chars
|
||||||
|
|
||||||
fs.writeFileSync(testFile, fileContent, "utf-8");
|
fs.writeFileSync(testFile, fileContent, "utf-8");
|
||||||
|
|
||||||
// Try to remove lines containing 'alias'
|
// Try to remove lines containing 'alias'
|
||||||
const pattern = /alias/;
|
const pattern = /alias/;
|
||||||
removeLinesMatchingPattern(testFile, pattern);
|
removeLinesMatchingPattern(testFile, pattern);
|
||||||
|
|
||||||
const result = fs.readFileSync(testFile, "utf-8");
|
const result = fs.readFileSync(testFile, "utf-8");
|
||||||
|
|
||||||
// This should now be protected by the newline detection
|
// This should now be protected by the newline detection
|
||||||
assert.ok(result.includes("keep1"), "Should preserve first line");
|
assert.ok(result.includes("keep1"), "Should preserve first line");
|
||||||
assert.ok(result.includes("keep2"), "Should preserve third line");
|
assert.ok(result.includes("keep2"), "Should preserve third line");
|
||||||
|
|
@ -87,27 +92,93 @@ describe("removeLinesMatchingPatternTests", () => {
|
||||||
it("should handle Unicode line separators that bypass newline detection", async () => {
|
it("should handle Unicode line separators that bypass newline detection", async () => {
|
||||||
// Import helpers after setting up the mock
|
// Import helpers after setting up the mock
|
||||||
const { removeLinesMatchingPattern } = await import("./helpers.js");
|
const { removeLinesMatchingPattern } = await import("./helpers.js");
|
||||||
|
|
||||||
// Use Unicode line separator (U+2028) and paragraph separator (U+2029)
|
// Use Unicode line separator (U+2028) and paragraph separator (U+2029)
|
||||||
// These are considered line breaks but aren't \n or \r
|
// These are considered line breaks but aren't \n or \r
|
||||||
const fileContent = [
|
const fileContent = ["keep this", "alias test=value", "keep that"].join(
|
||||||
"keep this",
|
"\u2028"
|
||||||
"alias test=value",
|
); // Unicode line separator
|
||||||
"keep that"
|
|
||||||
].join("\u2028"); // Unicode line separator
|
|
||||||
|
|
||||||
fs.writeFileSync(testFile, fileContent, "utf-8");
|
fs.writeFileSync(testFile, fileContent, "utf-8");
|
||||||
|
|
||||||
// Try to remove lines containing 'alias'
|
// Try to remove lines containing 'alias'
|
||||||
const pattern = /alias/;
|
const pattern = /alias/;
|
||||||
removeLinesMatchingPattern(testFile, pattern);
|
removeLinesMatchingPattern(testFile, pattern);
|
||||||
|
|
||||||
const result = fs.readFileSync(testFile, "utf-8");
|
const result = fs.readFileSync(testFile, "utf-8");
|
||||||
|
|
||||||
// This could still wipe everything if split() treats it as one line
|
// This could still wipe everything if split() treats it as one line
|
||||||
// but the content doesn't contain \n or \r so passes the newline check
|
// but the content doesn't contain \n or \r so passes the newline check
|
||||||
assert.ok(result.includes("keep this"), "Should preserve first part");
|
assert.ok(result.includes("keep this"), "Should preserve first part");
|
||||||
assert.ok(result.includes("keep that"), "Should preserve last part");
|
assert.ok(result.includes("keep that"), "Should preserve last part");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should handle Windows CRLF line endings without creating empty lines", async () => {
|
||||||
|
// Import helpers after setting up the mock
|
||||||
|
const { removeLinesMatchingPattern } = await import("./helpers.js");
|
||||||
|
|
||||||
|
// Create a file with Windows CRLF line endings
|
||||||
|
const fileContent = [
|
||||||
|
"# comment 1",
|
||||||
|
"alias npm='aikido-npm'",
|
||||||
|
"# comment 2",
|
||||||
|
"export PATH=$PATH:/usr/local/bin",
|
||||||
|
"",
|
||||||
|
"# comment 3",
|
||||||
|
].join("\r\n"); // Windows line endings
|
||||||
|
|
||||||
|
fs.writeFileSync(testFile, fileContent, "utf-8");
|
||||||
|
|
||||||
|
// Try to remove lines containing 'alias'
|
||||||
|
const pattern = /alias/;
|
||||||
|
removeLinesMatchingPattern(testFile, pattern, "\r\n");
|
||||||
|
|
||||||
|
const result = fs.readFileSync(testFile, "utf-8");
|
||||||
|
|
||||||
|
// Should preserve non-matching lines without adding empty lines
|
||||||
|
assert.ok(result.includes("# comment 1"), "Should preserve first comment");
|
||||||
|
assert.ok(result.includes("# comment 2"), "Should preserve second comment");
|
||||||
|
assert.ok(result.includes("# comment 3"), "Should preserve third comment");
|
||||||
|
assert.ok(result.includes("export PATH"), "Should preserve export line");
|
||||||
|
assert.ok(!result.includes("alias npm"), "Should remove alias line");
|
||||||
|
|
||||||
|
// The key test: when we split on \r\n, we should get exactly 4 lines
|
||||||
|
// Bug: if split(/[\r\n]/) was used, it creates empty lines between each real line
|
||||||
|
// because \r\n becomes two separators, resulting in: ["# comment 1", "", "# comment 2", "", "export...", "", "# comment 3", ""]
|
||||||
|
const resultLines = result.split("\r\n");
|
||||||
|
assert.strictEqual(resultLines.length, 5, "Should have exactly 5 lines");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not remove empty lines on unix line endings", async () => {
|
||||||
|
// Import helpers after setting up the mock
|
||||||
|
const { removeLinesMatchingPattern } = await import("./helpers.js");
|
||||||
|
|
||||||
|
// Create a file with Unix line endings and empty lines
|
||||||
|
const fileContent = [
|
||||||
|
"# comment 1",
|
||||||
|
"alias npm='aikido-npm'",
|
||||||
|
"# comment 2",
|
||||||
|
"export PATH=$PATH:/usr/local/bin",
|
||||||
|
"",
|
||||||
|
"# comment 3",
|
||||||
|
].join("\n"); // Unix line endings
|
||||||
|
|
||||||
|
fs.writeFileSync(testFile, fileContent, "utf-8");
|
||||||
|
|
||||||
|
// Try to remove lines containing 'alias'
|
||||||
|
const pattern = /alias/;
|
||||||
|
removeLinesMatchingPattern(testFile, pattern, "\n");
|
||||||
|
|
||||||
|
const result = fs.readFileSync(testFile, "utf-8");
|
||||||
|
|
||||||
|
// Should preserve non-matching lines including empty lines
|
||||||
|
assert.ok(result.includes("# comment 1"), "Should preserve first comment");
|
||||||
|
assert.ok(result.includes("# comment 2"), "Should preserve second comment");
|
||||||
|
assert.ok(result.includes("# comment 3"), "Should preserve third comment");
|
||||||
|
assert.ok(result.includes("export PATH"), "Should preserve export line");
|
||||||
|
assert.ok(!result.includes("alias npm"), "Should remove alias line");
|
||||||
|
|
||||||
|
const resultLines = result.split("\n");
|
||||||
|
assert.strictEqual(resultLines.length, 5, "Should have exactly 5 lines");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,14 @@ function pnpx
|
||||||
wrapSafeChainCommand "pnpx" "aikido-pnpx" $argv
|
wrapSafeChainCommand "pnpx" "aikido-pnpx" $argv
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function bun
|
||||||
|
wrapSafeChainCommand "bun" "aikido-bun" $argv
|
||||||
|
end
|
||||||
|
|
||||||
|
function bunx
|
||||||
|
wrapSafeChainCommand "bunx" "aikido-bunx" $argv
|
||||||
|
end
|
||||||
|
|
||||||
function npm
|
function npm
|
||||||
# If args is just -v or --version and nothing else, just run the `npm -v` command
|
# If args is just -v or --version and nothing else, just run the `npm -v` command
|
||||||
# This is because nvm uses this to check the version of npm
|
# This is because nvm uses this to check the version of npm
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,14 @@ function pnpx() {
|
||||||
wrapSafeChainCommand "pnpx" "aikido-pnpx" "$@"
|
wrapSafeChainCommand "pnpx" "aikido-pnpx" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bun() {
|
||||||
|
wrapSafeChainCommand "bun" "aikido-bun" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
function bunx() {
|
||||||
|
wrapSafeChainCommand "bunx" "aikido-bunx" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
function npm() {
|
function npm() {
|
||||||
if [[ "$1" == "-v" || "$1" == "--version" ]] && [[ $# -eq 1 ]]; then
|
if [[ "$1" == "-v" || "$1" == "--version" ]] && [[ $# -eq 1 ]]; then
|
||||||
# If args is just -v or --version and nothing else, just run the npm version command
|
# If args is just -v or --version and nothing else, just run the npm version command
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,14 @@ function pnpx {
|
||||||
Invoke-WrappedCommand "pnpx" "aikido-pnpx" $args
|
Invoke-WrappedCommand "pnpx" "aikido-pnpx" $args
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bun {
|
||||||
|
Invoke-WrappedCommand "bun" "aikido-bun" $args
|
||||||
|
}
|
||||||
|
|
||||||
|
function bunx {
|
||||||
|
Invoke-WrappedCommand "bunx" "aikido-bunx" $args
|
||||||
|
}
|
||||||
|
|
||||||
function npm {
|
function npm {
|
||||||
# If args is just -v or --version and nothing else, just run the npm version command
|
# If args is just -v or --version and nothing else, just run the npm version command
|
||||||
# This is because nvm uses this to check the version of npm
|
# This is because nvm uses this to check the version of npm
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ function escapeArg(arg) {
|
||||||
// and escape characters that are special even inside double quotes
|
// and escape characters that are special even inside double quotes
|
||||||
if (shellMetaChars.test(arg)) {
|
if (shellMetaChars.test(arg)) {
|
||||||
// Inside double quotes, we need to escape: " $ ` \
|
// Inside double quotes, we need to escape: " $ ` \
|
||||||
return '"' + arg.replace(/(["`$\\])/g, '\\$1') + '"';
|
return '"' + arg.replace(/(["`$\\])/g, "\\$1") + '"';
|
||||||
}
|
}
|
||||||
return arg;
|
return arg;
|
||||||
}
|
}
|
||||||
|
|
@ -50,11 +50,23 @@ export async function safeSpawn(command, args, options = {}) {
|
||||||
child = spawn(fullPath, args, options);
|
child = spawn(fullPath, args, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When stdio is piped, we need to collect the output
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
|
||||||
|
child.stdout?.on("data", (data) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr?.on("data", (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
child.on("close", (code) => {
|
child.on("close", (code) => {
|
||||||
resolve({
|
resolve({
|
||||||
status: code,
|
status: code,
|
||||||
stdout: Buffer.from(""),
|
stdout: stdout,
|
||||||
stderr: Buffer.from(""),
|
stderr: stderr,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { describe, it, beforeEach, afterEach, mock } from "node:test";
|
||||||
import assert from "node:assert";
|
import assert from "node:assert";
|
||||||
|
|
||||||
describe("safeSpawn", () => {
|
describe("safeSpawn", () => {
|
||||||
let safeSpawnSync, safeSpawn;
|
let safeSpawn;
|
||||||
let spawnCalls = [];
|
let spawnCalls = [];
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|
@ -11,31 +11,30 @@ describe("safeSpawn", () => {
|
||||||
// Mock child_process module to capture what command string gets built
|
// Mock child_process module to capture what command string gets built
|
||||||
mock.module("child_process", {
|
mock.module("child_process", {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
spawnSync: (command, options) => {
|
|
||||||
spawnCalls.push({ command, options });
|
|
||||||
return {
|
|
||||||
status: 0,
|
|
||||||
stdout: Buffer.from(""),
|
|
||||||
stderr: Buffer.from(""),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
spawn: (command, options) => {
|
spawn: (command, options) => {
|
||||||
spawnCalls.push({ command, options });
|
spawnCalls.push({ command, options });
|
||||||
return {
|
return {
|
||||||
on: (event, callback) => {
|
on: (event, callback) => {
|
||||||
if (event === 'close') {
|
if (event === "close") {
|
||||||
// Simulate immediate success
|
// Simulate immediate success
|
||||||
setTimeout(() => callback(0), 0);
|
setTimeout(() => callback(0), 0);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
execSync: (cmd, opts) => {
|
||||||
|
// Simulate 'command -v' returning full path
|
||||||
|
const match = cmd.match(/command -v (.+)/);
|
||||||
|
if (match) {
|
||||||
|
return `/usr/bin/${match[1]}\n`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Import after mocking
|
// Import after mocking
|
||||||
const safeSpawnModule = await import("./safeSpawn.js");
|
const safeSpawnModule = await import("./safeSpawn.js");
|
||||||
safeSpawnSync = safeSpawnModule.safeSpawnSync;
|
|
||||||
safeSpawn = safeSpawnModule.safeSpawn;
|
safeSpawn = safeSpawnModule.safeSpawn;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -43,76 +42,68 @@ describe("safeSpawn", () => {
|
||||||
mock.reset();
|
mock.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper to run either sync or async variant
|
it("should pass basic command and arguments correctly", async () => {
|
||||||
async function runSafeSpawn(variant, command, args, options) {
|
await safeSpawn("echo", ["hello"]);
|
||||||
if (variant === "sync") {
|
|
||||||
return safeSpawnSync(command, args, options);
|
|
||||||
} else {
|
|
||||||
return await safeSpawn(command, args, options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let variant of ["sync", "async"]) {
|
assert.strictEqual(spawnCalls.length, 1);
|
||||||
it(`should pass basic command and arguments correctly (${variant})`, async () => {
|
assert.strictEqual(spawnCalls[0].command, "echo hello");
|
||||||
await runSafeSpawn(variant, "echo", ["hello"]);
|
assert.strictEqual(spawnCalls[0].options.shell, true);
|
||||||
|
});
|
||||||
|
|
||||||
assert.strictEqual(spawnCalls.length, 1);
|
it("should escape arguments containing spaces", async () => {
|
||||||
assert.strictEqual(spawnCalls[0].command, "echo hello");
|
await safeSpawn("echo", ["hello world"]);
|
||||||
assert.strictEqual(spawnCalls[0].options.shell, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`should escape arguments containing spaces (${variant})`, async () => {
|
assert.strictEqual(spawnCalls.length, 1);
|
||||||
await runSafeSpawn(variant, "echo", ["hello world"]);
|
// Argument should be escaped to prevent shell interpretation
|
||||||
|
assert.strictEqual(spawnCalls[0].command, 'echo "hello world"');
|
||||||
|
assert.strictEqual(spawnCalls[0].options.shell, true);
|
||||||
|
});
|
||||||
|
|
||||||
assert.strictEqual(spawnCalls.length, 1);
|
it("should prevent shell injection attacks", async () => {
|
||||||
// Argument should be escaped to prevent shell interpretation
|
await safeSpawn("ls", ["; rm test123.txt"]);
|
||||||
assert.strictEqual(spawnCalls[0].command, 'echo "hello world"');
|
|
||||||
assert.strictEqual(spawnCalls[0].options.shell, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`should prevent shell injection attacks (${variant})`, async () => {
|
assert.strictEqual(spawnCalls.length, 1);
|
||||||
await runSafeSpawn(variant, "ls", ["; rm test123.txt"]);
|
// Malicious command should be escaped to prevent execution
|
||||||
|
assert.strictEqual(spawnCalls[0].command, 'ls "; rm test123.txt"');
|
||||||
|
assert.strictEqual(spawnCalls[0].options.shell, true);
|
||||||
|
});
|
||||||
|
|
||||||
assert.strictEqual(spawnCalls.length, 1);
|
it("should escape single quotes in arguments", async () => {
|
||||||
// Malicious command should be escaped to prevent execution
|
await safeSpawn("echo", ["don't break"]);
|
||||||
assert.strictEqual(spawnCalls[0].command, 'ls "; rm test123.txt"');
|
|
||||||
assert.strictEqual(spawnCalls[0].options.shell, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`should escape single quotes in arguments (${variant})`, async () => {
|
assert.strictEqual(spawnCalls.length, 1);
|
||||||
await runSafeSpawn(variant, "echo", ["don't break"]);
|
// Single quote should be properly escaped with double quotes
|
||||||
|
assert.strictEqual(spawnCalls[0].command, 'echo "don\'t break"');
|
||||||
|
assert.strictEqual(spawnCalls[0].options.shell, true);
|
||||||
|
});
|
||||||
|
|
||||||
assert.strictEqual(spawnCalls.length, 1);
|
it("should handle double quotes with simpler escaping", async () => {
|
||||||
// Single quote should be properly escaped with double quotes
|
await safeSpawn("echo", ['say "hello"']);
|
||||||
assert.strictEqual(spawnCalls[0].command, 'echo "don\'t break"');
|
|
||||||
assert.strictEqual(spawnCalls[0].options.shell, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`should handle double quotes with simpler escaping (${variant})`, async () => {
|
assert.strictEqual(spawnCalls.length, 1);
|
||||||
await runSafeSpawn(variant, "echo", ['say "hello"']);
|
// If we switch to double quotes, this should be: "say \"hello\""
|
||||||
|
assert.strictEqual(spawnCalls[0].command, 'echo "say \\"hello\\""');
|
||||||
|
assert.strictEqual(spawnCalls[0].options.shell, true);
|
||||||
|
});
|
||||||
|
|
||||||
assert.strictEqual(spawnCalls.length, 1);
|
it("should not escape arguments with only safe characters", async () => {
|
||||||
// If we switch to double quotes, this should be: "say \"hello\""
|
await safeSpawn("npm", ["install", "axios", "--save"]);
|
||||||
assert.strictEqual(spawnCalls[0].command, 'echo "say \\"hello\\""');
|
|
||||||
assert.strictEqual(spawnCalls[0].options.shell, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`should not escape arguments with only safe characters (${variant})`, async () => {
|
assert.strictEqual(spawnCalls.length, 1);
|
||||||
await runSafeSpawn(variant, "npm", ["install", "axios", "--save"]);
|
// Safe arguments (alphanumeric, dash, underscore, dot, slash) shouldn't be quoted
|
||||||
|
assert.strictEqual(spawnCalls[0].command, "npm install axios --save");
|
||||||
|
assert.strictEqual(spawnCalls[0].options.shell, true);
|
||||||
|
});
|
||||||
|
|
||||||
assert.strictEqual(spawnCalls.length, 1);
|
it(`should escape ampersand character`, async () => {
|
||||||
// Safe arguments (alphanumeric, dash, underscore, dot, slash) shouldn't be quoted
|
await safeSpawn("npx", ["cypress", "run", "--env", "password=foo&bar"]);
|
||||||
assert.strictEqual(spawnCalls[0].command, "npm install axios --save");
|
|
||||||
assert.strictEqual(spawnCalls[0].options.shell, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`should escape ampersand character (${variant})`, async () => {
|
assert.strictEqual(spawnCalls.length, 1);
|
||||||
await runSafeSpawn(variant, "npx", ["cypress", "run", "--env", "password=foo&bar"]);
|
// & should be escaped by wrapping the arg in quotes
|
||||||
|
assert.strictEqual(
|
||||||
assert.strictEqual(spawnCalls.length, 1);
|
spawnCalls[0].command,
|
||||||
// & should be escaped by wrapping the arg in quotes
|
'npx cypress run --env "password=foo&bar"'
|
||||||
assert.strictEqual(spawnCalls[0].command, 'npx cypress run --env "password=foo&bar"');
|
);
|
||||||
assert.strictEqual(spawnCalls[0].options.shell, true);
|
assert.strictEqual(spawnCalls[0].options.shell, true);
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,26 @@ export class DockerTestContainer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dockerExec(command, daemon = false) {
|
||||||
|
if (!this.isRunning) {
|
||||||
|
throw new Error("Container is not running");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dockerExecCommand = `docker exec ${daemon ? "-d " : " "}${
|
||||||
|
this.containerName
|
||||||
|
} bash -c "${command}"`;
|
||||||
|
const output = execSync(dockerExecCommand, {
|
||||||
|
encoding: "utf-8",
|
||||||
|
stdio: "pipe",
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
return output;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to execute command: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async openShell(shell) {
|
async openShell(shell) {
|
||||||
let ptyProcess = pty.spawn(
|
let ptyProcess = pty.spawn(
|
||||||
"docker",
|
"docker",
|
||||||
|
|
@ -96,9 +116,11 @@ export class DockerTestContainer {
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
// Fallback in case the command doesn't finish in a reasonable time
|
// Fallback in case the command doesn't finish in a reasonable time
|
||||||
|
// oxlint-disable-next-line no-console - having this log in CI helps diagnose issues
|
||||||
|
console.log("Command timeout reached");
|
||||||
resolve({ allData, output: parseShellOutput(allData), command });
|
resolve({ allData, output: parseShellOutput(allData), command });
|
||||||
ptyProcess.removeListener("data", handleInput);
|
ptyProcess.removeListener("data", handleInput);
|
||||||
}, 10000);
|
}, 15000);
|
||||||
|
|
||||||
function handleInput(data) {
|
function handleInput(data) {
|
||||||
allData.push(data);
|
allData.push(data);
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ ARG PNPM_VERSION=latest
|
||||||
SHELL ["/bin/bash", "-c"]
|
SHELL ["/bin/bash", "-c"]
|
||||||
ENV BASH_ENV=~/.bashrc
|
ENV BASH_ENV=~/.bashrc
|
||||||
|
|
||||||
|
# Install a proxy
|
||||||
|
RUN apt-get update && apt-get install tinyproxy -y
|
||||||
|
|
||||||
# Install zsh
|
# Install zsh
|
||||||
RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v1.2.1/zsh-in-docker.sh)"
|
RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v1.2.1/zsh-in-docker.sh)"
|
||||||
# Install fish
|
# Install fish
|
||||||
|
|
@ -43,6 +46,9 @@ RUN volta install npm@${NPM_VERSION}
|
||||||
RUN volta install yarn@${YARN_VERSION}
|
RUN volta install yarn@${YARN_VERSION}
|
||||||
RUN volta install pnpm@${PNPM_VERSION}
|
RUN volta install pnpm@${PNPM_VERSION}
|
||||||
|
|
||||||
|
# Install Bun
|
||||||
|
RUN curl -fsSL https://bun.sh/install | bash
|
||||||
|
|
||||||
# Copy and install Safe chain
|
# Copy and install Safe chain
|
||||||
COPY --from=builder /app/*.tgz /pkgs/
|
COPY --from=builder /app/*.tgz /pkgs/
|
||||||
RUN npm install -g /pkgs/*.tgz
|
RUN npm install -g /pkgs/*.tgz
|
||||||
|
|
|
||||||
79
test/e2e/bun.e2e.spec.js
Normal file
79
test/e2e/bun.e2e.spec.js
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { describe, it, before, beforeEach, afterEach } from "node:test";
|
||||||
|
import { DockerTestContainer } from "./DockerTestContainer.js";
|
||||||
|
import assert from "node:assert";
|
||||||
|
|
||||||
|
describe("E2E: bun coverage", () => {
|
||||||
|
let container;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
DockerTestContainer.buildImage();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Run a new Docker container for each test
|
||||||
|
container = new DockerTestContainer();
|
||||||
|
await container.start();
|
||||||
|
|
||||||
|
const installationShell = await container.openShell("zsh");
|
||||||
|
await installationShell.runCommand("safe-chain setup");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Stop and clean up the container after each test
|
||||||
|
if (container) {
|
||||||
|
await container.stop();
|
||||||
|
container = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`safe-chain succesfully installs safe packages`, async () => {
|
||||||
|
const shell = await container.openShell("bash");
|
||||||
|
const result = await shell.runCommand("bun i axios");
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("no malicious packages found."),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`safe-chain blocks download of malicious packages already in package.json`, async () => {
|
||||||
|
const shell = await container.openShell("bash");
|
||||||
|
await shell.runCommand(
|
||||||
|
'echo \'{"name":"test-project","version":"1.0.0","dependencies":{"safe-chain-test":"0.0.1-security"}}\' > package.json'
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await shell.runCommand("bun install");
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("blocked 1 malicious package downloads"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("- safe-chain-test"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("Exiting without installing malicious packages."),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("safe-chain blocks bunx from downloading malicious packages", async () => {
|
||||||
|
const shell = await container.openShell("bash");
|
||||||
|
|
||||||
|
const result = await shell.runCommand("bunx safe-chain-test");
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("blocked 1 malicious package downloads"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("- safe-chain-test"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("Exiting without installing malicious packages."),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -36,7 +36,7 @@ describe("E2E: npm coverage using PATH", () => {
|
||||||
const result = await shell.runCommand("npm i axios");
|
const result = await shell.runCommand("npm i axios");
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
result.output.includes("No malicious packages detected."),
|
result.output.includes("no malicious packages found."),
|
||||||
`Output did not include expected text. Output was:\n${result.output}`
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ describe("E2E: npm coverage", () => {
|
||||||
const result = await shell.runCommand("npm i axios");
|
const result = await shell.runCommand("npm i axios");
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
result.output.includes("No malicious packages detected."),
|
result.output.includes("no malicious packages found."),
|
||||||
`Output did not include expected text. Output was:\n${result.output}`
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -60,6 +60,28 @@ describe("E2E: npm coverage", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it(`safe-chain blocks download of malicious packages already in package.json`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
await shell.runCommand(
|
||||||
|
'echo \'{"name":"test-project","version":"1.0.0","dependencies":{"safe-chain-test":"0.0.1-security"}}\' > package.json'
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await shell.runCommand("npm install");
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("blocked 1 malicious package downloads"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("- safe-chain-test"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("Exiting without installing malicious packages."),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("safe-chain blocks npx from executing malicious packages", async () => {
|
it("safe-chain blocks npx from executing malicious packages", async () => {
|
||||||
const shell = await container.openShell("zsh");
|
const shell = await container.openShell("zsh");
|
||||||
const result = await shell.runCommand("npx safe-chain-test");
|
const result = await shell.runCommand("npx safe-chain-test");
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "End-to-end tests for the Aikido Safe Chain",
|
"description": "End-to-end tests for the Aikido Safe Chain",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test **/*.spec.js"
|
"test": "node --test --test-concurrency=1 **/*.spec.js"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "Aikido Security",
|
"author": "Aikido Security",
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ describe("E2E: pnpm coverage", () => {
|
||||||
const result = await shell.runCommand("pnpm add axios");
|
const result = await shell.runCommand("pnpm add axios");
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
result.output.includes("No malicious packages detected."),
|
result.output.includes("no malicious packages found."),
|
||||||
`Output did not include expected text. Output was:\n${result.output}`
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ describe("E2E: pnpm coverage", () => {
|
||||||
const result = await shell.runCommand("pnpm add axios");
|
const result = await shell.runCommand("pnpm add axios");
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
result.output.includes("No malicious packages detected."),
|
result.output.includes("no malicious packages found."),
|
||||||
`Output did not include expected text. Output was:\n${result.output}`
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -60,6 +60,28 @@ describe("E2E: pnpm coverage", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it(`safe-chain blocks download of malicious packages already in package.json`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
await shell.runCommand(
|
||||||
|
'echo \'{"name":"test-project","version":"1.0.0","dependencies":{"safe-chain-test":"0.0.1-security"}}\' > package.json'
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await shell.runCommand("pnpm install");
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("blocked 1 malicious package downloads"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("- safe-chain-test"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("Exiting without installing malicious packages."),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("safe-chain blocks pnpx from executing malicious packages", async () => {
|
it("safe-chain blocks pnpx from executing malicious packages", async () => {
|
||||||
const shell = await container.openShell("zsh");
|
const shell = await container.openShell("zsh");
|
||||||
const result = await shell.runCommand("pnpx safe-chain-test");
|
const result = await shell.runCommand("pnpx safe-chain-test");
|
||||||
|
|
|
||||||
60
test/e2e/safe-chain-proxy.e2e.spec.js
Normal file
60
test/e2e/safe-chain-proxy.e2e.spec.js
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { describe, it, before, beforeEach, afterEach } from "node:test";
|
||||||
|
import { DockerTestContainer } from "./DockerTestContainer.js";
|
||||||
|
import assert from "node:assert";
|
||||||
|
|
||||||
|
describe("E2E: Safe chain proxy", () => {
|
||||||
|
let container;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
DockerTestContainer.buildImage();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Run a new Docker container for each test
|
||||||
|
container = new DockerTestContainer();
|
||||||
|
await container.start();
|
||||||
|
|
||||||
|
const installationShell = await container.openShell("zsh");
|
||||||
|
await installationShell.runCommand("safe-chain setup");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Stop and clean up the container after each test
|
||||||
|
if (container) {
|
||||||
|
await container.stop();
|
||||||
|
container = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`safe-chain proxy respects upstream proxy settings`, async () => {
|
||||||
|
// Configure and start a proxy inside the container
|
||||||
|
const proxy = await container.openShell("zsh");
|
||||||
|
await proxy.runCommand(
|
||||||
|
`echo 'BasicAuth user password' >> /etc/tinyproxy/tinyproxy.conf`
|
||||||
|
);
|
||||||
|
await proxy.runCommand("tinyproxy");
|
||||||
|
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
await shell.runCommand(
|
||||||
|
'export HTTPS_PROXY="http://user:password@localhost:8888"'
|
||||||
|
);
|
||||||
|
const { output } = await shell.runCommand("npm install axios");
|
||||||
|
|
||||||
|
// Check if the installation was successful
|
||||||
|
assert(
|
||||||
|
output.includes("added") || output.includes("up to date"),
|
||||||
|
"npm install did not complete successfully"
|
||||||
|
);
|
||||||
|
|
||||||
|
const proxyLog = await container.openShell("zsh");
|
||||||
|
const { output: logOutput } = await proxyLog.runCommand(
|
||||||
|
"cat /var/log/tinyproxy/tinyproxy.log"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if the proxy log contains entries for the npm install
|
||||||
|
assert(
|
||||||
|
logOutput.includes("CONNECT registry.npmjs.org:443"),
|
||||||
|
"Proxy log does not contain expected entries"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -36,7 +36,7 @@ describe("E2E: yarn coverage", () => {
|
||||||
const result = await shell.runCommand("yarn add axios");
|
const result = await shell.runCommand("yarn add axios");
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
result.output.includes("No malicious packages detected."),
|
result.output.includes("no malicious packages found."),
|
||||||
`Output did not include expected text. Output was:\n${result.output}`
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,53 @@ describe("E2E: yarn coverage", () => {
|
||||||
const result = await shell.runCommand("yarn add axios");
|
const result = await shell.runCommand("yarn add axios");
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
result.output.includes("No malicious packages detected."),
|
result.output.includes("no malicious packages found."),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`safe-chain blocks installation of malicious packages`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
const result = await shell.runCommand("yarn add safe-chain-test");
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("Malicious changes detected:"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("- safe-chain-test"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("Exiting without installing malicious packages."),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const listResult = await shell.runCommand("yarn list");
|
||||||
|
assert.ok(
|
||||||
|
!listResult.output.includes("safe-chain-test"),
|
||||||
|
`Malicious package was installed despite safe-chain protection. Output of 'yarn list' was:\n${listResult.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`safe-chain blocks download of malicious packages already in package.json`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
await shell.runCommand(
|
||||||
|
'echo \'{"name":"test-project","version":"1.0.0","dependencies":{"safe-chain-test":"0.0.1-security"}}\' > package.json'
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await shell.runCommand("yarn");
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("blocked 1 malicious package downloads"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("- safe-chain-test"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("Exiting without installing malicious packages."),
|
||||||
`Output did not include expected text. Output was:\n${result.output}`
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue