Merge branch 'main' into escape-special-chars-in-shell

This commit is contained in:
Sander Declerck 2025-10-23 09:52:38 +02:00
commit 8447d3cac5
No known key found for this signature in database
62 changed files with 2212 additions and 4032 deletions

View file

@ -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: |

View file

@ -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
View 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"
}
}
]
}

View file

@ -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.
![demo](./docs/safe-package-manager-demo.png) ![demo](./docs/safe-package-manager-demo.png)
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**

View file

@ -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.

View file

@ -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']),
]);

3438
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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"
} }
} }

View file

@ -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

View 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);

View 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);

View file

@ -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";
}
}

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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;
}

View file

@ -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"

View file

@ -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";

View file

@ -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;
} }
} catch (error) {
ui.writeError("Failed to check for malicious packages:", error.message);
process.exit(1);
} }
var result = getPackageManager().runCommand(args); const packageManagerResult = await getPackageManager().runCommand(args);
process.exit(result.status);
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) {
ui.writeError("Failed to check for malicious packages:", error.message);
// 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 1;
} finally {
await proxy.stopServer();
}
} }

View file

@ -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 };
}
}
}

View file

@ -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);
} }

View file

@ -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) {

View file

@ -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";
}

View file

@ -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);
});
});
});

View file

@ -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 };
}

View file

@ -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);
});
});

View file

@ -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);

View file

@ -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",
};

View file

@ -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`

View file

@ -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 };
} }

View file

@ -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}`);
} }

View file

@ -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();
} }

View file

@ -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}`
);
}
});
});

View 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,
};
}

View 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;
}

View 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 };
}

View file

@ -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);
});
});
});

View 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);
}

View 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;
}

View 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();
}
});
}

View file

@ -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;
} }

View file

@ -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 () => {
@ -199,7 +210,6 @@ describe("scanCommand", async () => {
mockConfirm.mock.resetCalls(); mockConfirm.mock.resetCalls();
let failureMessageWasSet = false; let failureMessageWasSet = false;
let exitCode = null;
mockStartProcess.mock.mockImplementationOnce(() => ({ mockStartProcess.mock.mockImplementationOnce(() => ({
setText: () => {}, setText: () => {},
@ -207,33 +217,17 @@ 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" },
]); ]);
// 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);
}); });
@ -245,13 +239,13 @@ describe("scanCommand", async () => {
// 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(() => [
@ -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);
}); });
}); });

View file

@ -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() {

View file

@ -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");
} }

View file

@ -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,7 +31,6 @@ 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");
@ -42,7 +41,7 @@ describe("removeLinesMatchingPatternTests", () => {
"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");
@ -55,8 +54,14 @@ describe("removeLinesMatchingPatternTests", () => {
// 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 () => {
@ -68,7 +73,7 @@ describe("removeLinesMatchingPatternTests", () => {
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");
@ -90,11 +95,9 @@ describe("removeLinesMatchingPatternTests", () => {
// 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");
@ -110,4 +113,72 @@ describe("removeLinesMatchingPatternTests", () => {
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");
});
}); });

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,
}); });
}); });

View file

@ -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,26 +42,16 @@ 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"]) {
it(`should pass basic command and arguments correctly (${variant})`, async () => {
await runSafeSpawn(variant, "echo", ["hello"]);
assert.strictEqual(spawnCalls.length, 1); assert.strictEqual(spawnCalls.length, 1);
assert.strictEqual(spawnCalls[0].command, "echo hello"); assert.strictEqual(spawnCalls[0].command, "echo hello");
assert.strictEqual(spawnCalls[0].options.shell, true); assert.strictEqual(spawnCalls[0].options.shell, true);
}); });
it(`should escape arguments containing spaces (${variant})`, async () => { it("should escape arguments containing spaces", async () => {
await runSafeSpawn(variant, "echo", ["hello world"]); await safeSpawn("echo", ["hello world"]);
assert.strictEqual(spawnCalls.length, 1); assert.strictEqual(spawnCalls.length, 1);
// Argument should be escaped to prevent shell interpretation // Argument should be escaped to prevent shell interpretation
@ -70,8 +59,8 @@ describe("safeSpawn", () => {
assert.strictEqual(spawnCalls[0].options.shell, true); assert.strictEqual(spawnCalls[0].options.shell, true);
}); });
it(`should prevent shell injection attacks (${variant})`, async () => { it("should prevent shell injection attacks", async () => {
await runSafeSpawn(variant, "ls", ["; rm test123.txt"]); await safeSpawn("ls", ["; rm test123.txt"]);
assert.strictEqual(spawnCalls.length, 1); assert.strictEqual(spawnCalls.length, 1);
// Malicious command should be escaped to prevent execution // Malicious command should be escaped to prevent execution
@ -79,8 +68,8 @@ describe("safeSpawn", () => {
assert.strictEqual(spawnCalls[0].options.shell, true); assert.strictEqual(spawnCalls[0].options.shell, true);
}); });
it(`should escape single quotes in arguments (${variant})`, async () => { it("should escape single quotes in arguments", async () => {
await runSafeSpawn(variant, "echo", ["don't break"]); await safeSpawn("echo", ["don't break"]);
assert.strictEqual(spawnCalls.length, 1); assert.strictEqual(spawnCalls.length, 1);
// Single quote should be properly escaped with double quotes // Single quote should be properly escaped with double quotes
@ -88,8 +77,8 @@ describe("safeSpawn", () => {
assert.strictEqual(spawnCalls[0].options.shell, true); assert.strictEqual(spawnCalls[0].options.shell, true);
}); });
it(`should handle double quotes with simpler escaping (${variant})`, async () => { it("should handle double quotes with simpler escaping", async () => {
await runSafeSpawn(variant, "echo", ['say "hello"']); await safeSpawn("echo", ['say "hello"']);
assert.strictEqual(spawnCalls.length, 1); assert.strictEqual(spawnCalls.length, 1);
// If we switch to double quotes, this should be: "say \"hello\"" // If we switch to double quotes, this should be: "say \"hello\""
@ -97,8 +86,8 @@ describe("safeSpawn", () => {
assert.strictEqual(spawnCalls[0].options.shell, true); assert.strictEqual(spawnCalls[0].options.shell, true);
}); });
it(`should not escape arguments with only safe characters (${variant})`, async () => { it("should not escape arguments with only safe characters", async () => {
await runSafeSpawn(variant, "npm", ["install", "axios", "--save"]); await safeSpawn("npm", ["install", "axios", "--save"]);
assert.strictEqual(spawnCalls.length, 1); assert.strictEqual(spawnCalls.length, 1);
// Safe arguments (alphanumeric, dash, underscore, dot, slash) shouldn't be quoted // Safe arguments (alphanumeric, dash, underscore, dot, slash) shouldn't be quoted
@ -106,13 +95,15 @@ describe("safeSpawn", () => {
assert.strictEqual(spawnCalls[0].options.shell, true); assert.strictEqual(spawnCalls[0].options.shell, true);
}); });
it(`should escape ampersand character (${variant})`, async () => { it(`should escape ampersand character`, async () => {
await runSafeSpawn(variant, "npx", ["cypress", "run", "--env", "password=foo&bar"]); await safeSpawn("npx", ["cypress", "run", "--env", "password=foo&bar"]);
assert.strictEqual(spawnCalls.length, 1); assert.strictEqual(spawnCalls.length, 1);
// & should be escaped by wrapping the arg in quotes // & should be escaped by wrapping the arg in quotes
assert.strictEqual(spawnCalls[0].command, '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);
}); });
}
}); });

View file

@ -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);

View file

@ -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
View 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}`
);
});
});

View file

@ -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}`
); );
}); });

View file

@ -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");

View file

@ -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",

View file

@ -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}`
); );
}); });

View file

@ -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");

View 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"
);
});
});

View file

@ -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}`
); );
}); });

View file

@ -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}`
); );
}); });