diff --git a/README.md b/README.md index 844b637..33dcc26 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Aikido Safe Chain -The Aikido Safe Chain **prevents developers from installing malware** on their workstations through npm, npx, or yarn. +The Aikido Safe Chain **prevents developers from installing malware** on their workstations through npm, npx, yarn, pnpm and pnpx. -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), and [yarn](https://yarnpkg.com/) 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, or yarn 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/), 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. ![demo](https://aikido-production-staticfiles-public.s3.eu-west-1.amazonaws.com/safe-pkg.gif) @@ -11,7 +11,9 @@ Aikido Safe Chain works on Node.js version 18 and above and supports the followi - βœ… **npm** - βœ… **npx** - βœ… **yarn** -- 🚧 **pnpm** Coming soon +- βœ… **pnpm** +- βœ… **pnpx** +- 🚧 **bun** Coming soon # Usage @@ -28,20 +30,20 @@ Installing the Aikido Safe Chain is easy. You just need 3 simple steps: safe-chain setup ``` 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, and yarn 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 and pnpx are loaded correctly. If you do not restart your terminal, the aliases will not be available. 4. **Verify the installation** by running: ```shell npm install eslint-js ``` - 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`, or `yarn` 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` 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. ## How it works -The Aikido Safe Chain works by intercepting the npm, npx, and yarn commands and verifying the packages against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. +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 integrates with your shell to provide a seamless experience when using npm, npx, and yarn 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 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: - βœ… **Bash** - βœ… **Zsh** diff --git a/bin/aikido-pnpm.js b/bin/aikido-pnpm.js new file mode 100755 index 0000000..e7bac47 --- /dev/null +++ b/bin/aikido-pnpm.js @@ -0,0 +1,8 @@ +#!/usr/bin/env node + +import { main } from "../src/main.js"; +import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; + +const packageManagerName = "pnpm"; +initializePackageManager(packageManagerName, process.versions.node); +await main(process.argv.slice(2)); diff --git a/bin/aikido-pnpx.js b/bin/aikido-pnpx.js new file mode 100755 index 0000000..25884ce --- /dev/null +++ b/bin/aikido-pnpx.js @@ -0,0 +1,8 @@ +#!/usr/bin/env node + +import { main } from "../src/main.js"; +import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js"; + +const packageManagerName = "pnpx"; +initializePackageManager(packageManagerName, process.versions.node); +await main(process.argv.slice(2)); diff --git a/docs/shell-integration.md b/docs/shell-integration.md index 715344c..3243c20 100644 --- a/docs/shell-integration.md +++ b/docs/shell-integration.md @@ -2,7 +2,7 @@ ## Overview -The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`) with Aikido's security scanning functionality. This is achieved by adding shell aliases that redirect these commands to their Aikido-wrapped equivalents. +The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`) with Aikido's security scanning functionality. This is achieved by adding shell aliases that redirect these commands to their Aikido-wrapped equivalents. ## Supported Shells @@ -27,7 +27,7 @@ safe-chain setup This command: - Detects all supported shells on your system -- Adds aliases for `npm`, `npx`, and `yarn` to each shell's startup file +- Adds aliases for `npm`, `npx`, `yarn`, `pnpm` and `pnpx` to each shell's startup file ❗ After running this command, **you must restart your terminal** for the changes to take effect. This ensures that the aliases are loaded correctly. @@ -75,7 +75,7 @@ The system modifies the following files based on your shell configuration: This means the aliases 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 -- Verify the `aikido-npm`, `aikido-npx`, and `aikido-yarn` commands exist +- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm` and `aikido-pnpx` commands exist - Check that these commands are in your system's PATH ### Manual Verification @@ -105,4 +105,4 @@ To verify the integration is working, follow these steps: 3. **If you need to remove aliases manually:** - Edit the same startup file from step 1 and delete any lines containing `aikido-npm`, `aikido-npx`, or `aikido-yarn`. + Edit the same startup file from step 1 and delete any lines containing `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm` or `aikido-pnpx`. diff --git a/package-lock.json b/package-lock.json index 4335bd2..260ee8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,8 @@ "bin": { "aikido-npm": "bin/aikido-npm.js", "aikido-npx": "bin/aikido-npx.js", + "aikido-pnpm": "bin/aikido-pnpm.js", + "aikido-pnpx": "bin/aikido-pnpx.js", "aikido-yarn": "bin/aikido-yarn.js", "safe-chain": "bin/safe-chain.js" }, diff --git a/package.json b/package.json index 8a9241f..4ab4c95 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "aikido-npm": "bin/aikido-npm.js", "aikido-npx": "bin/aikido-npx.js", "aikido-yarn": "bin/aikido-yarn.js", + "aikido-pnpm": "bin/aikido-pnpm.js", + "aikido-pnpx": "bin/aikido-pnpx.js", "safe-chain": "bin/safe-chain.js" }, "type": "module", diff --git a/src/packagemanager/_shared/matchesCommand.js b/src/packagemanager/_shared/matchesCommand.js new file mode 100644 index 0000000..d72caca --- /dev/null +++ b/src/packagemanager/_shared/matchesCommand.js @@ -0,0 +1,13 @@ +export function matchesCommand(args, ...commandArgs) { + if (args.length < commandArgs.length) { + return false; + } + + for (var i = 0; i < commandArgs.length; i++) { + if (args[i].toLowerCase() !== commandArgs[i].toLowerCase()) { + return false; + } + } + + return true; +} diff --git a/src/packagemanager/currentPackageManager.js b/src/packagemanager/currentPackageManager.js index 62e365a..9497a20 100644 --- a/src/packagemanager/currentPackageManager.js +++ b/src/packagemanager/currentPackageManager.js @@ -1,5 +1,9 @@ import { createNpmPackageManager } from "./npm/createPackageManager.js"; import { createNpxPackageManager } from "./npx/createPackageManager.js"; +import { + createPnpmPackageManager, + createPnpxPackageManager, +} from "./pnpm/createPackageManager.js"; import { createYarnPackageManager } from "./yarn/createPackageManager.js"; const state = { @@ -13,6 +17,10 @@ export function initializePackageManager(packageManagerName, version) { state.packageManagerName = createNpxPackageManager(); } else if (packageManagerName === "yarn") { state.packageManagerName = createYarnPackageManager(); + } else if (packageManagerName === "pnpm") { + state.packageManagerName = createPnpmPackageManager(); + } else if (packageManagerName === "pnpx") { + state.packageManagerName = createPnpxPackageManager(); } else { throw new Error("Unsupported package manager: " + packageManagerName); } diff --git a/src/packagemanager/pnpm/createPackageManager.js b/src/packagemanager/pnpm/createPackageManager.js new file mode 100644 index 0000000..763d920 --- /dev/null +++ b/src/packagemanager/pnpm/createPackageManager.js @@ -0,0 +1,46 @@ +import { matchesCommand } from "../_shared/matchesCommand.js"; +import { commandArgumentScanner } from "./dependencyScanner/commandArgumentScanner.js"; +import { runPnpmCommand } from "./runPnpmCommand.js"; + +const scanner = commandArgumentScanner(); + +export function createPnpmPackageManager() { + return { + getWarningMessage: () => null, + runCommand: (args) => runPnpmCommand(args, "pnpm"), + isSupportedCommand: (args) => + matchesCommand(args, "add") || + matchesCommand(args, "update") || + matchesCommand(args, "upgrade") || + matchesCommand(args, "up") || + // dlx does not always come in the first position + // eg: pnpm --package=yo --package=generator-webapp dlx yo webapp + // documentation: https://pnpm.io/cli/dlx#--package-name + args.includes("dlx"), + getDependencyUpdatesForCommand: (args) => + getDependencyUpdatesForCommand(args, false), + }; +} + +export function createPnpxPackageManager() { + return { + getWarningMessage: () => null, + runCommand: (args) => runPnpmCommand(args, "pnpx"), + isSupportedCommand: () => true, + getDependencyUpdatesForCommand: (args) => + getDependencyUpdatesForCommand(args, true), + }; +} + +function getDependencyUpdatesForCommand(args, isPnpx) { + if (isPnpx) { + return scanner.scan(args); + } + if (args.includes("dlx")) { + // dlx is not always the first argument (eg: `pnpm --package=yo --package=generator-webapp dlx yo webapp`) + // so we need to filter it out instead of slicing the array + // documentation: https://pnpm.io/cli/dlx#--package-name + return scanner.scan(args.filter((arg) => arg !== "dlx")); + } + return scanner.scan(args.slice(1)); +} diff --git a/src/packagemanager/pnpm/dependencyScanner/commandArgumentScanner.js b/src/packagemanager/pnpm/dependencyScanner/commandArgumentScanner.js new file mode 100644 index 0000000..c184b38 --- /dev/null +++ b/src/packagemanager/pnpm/dependencyScanner/commandArgumentScanner.js @@ -0,0 +1,28 @@ +import { resolvePackageVersion } from "../../../api/npmApi.js"; +import { parsePackagesFromArguments } from "../parsing/parsePackagesFromArguments.js"; + +export function commandArgumentScanner() { + return { + scan: (args) => scanDependencies(args), + shouldScan: () => true, // There's no dry run for pnpm, so we always scan + }; +} + +async function scanDependencies(args) { + const changes = []; + const packageUpdates = parsePackagesFromArguments(args); + + for (const packageUpdate of packageUpdates) { + var exactVersion = await resolvePackageVersion( + packageUpdate.name, + packageUpdate.version + ); + if (exactVersion) { + packageUpdate.version = exactVersion; + } + + changes.push({ ...packageUpdate, type: "add" }); + } + + return changes; +} diff --git a/src/packagemanager/pnpm/parsing/parsePackagesFromArguments.js b/src/packagemanager/pnpm/parsing/parsePackagesFromArguments.js new file mode 100644 index 0000000..d0383c2 --- /dev/null +++ b/src/packagemanager/pnpm/parsing/parsePackagesFromArguments.js @@ -0,0 +1,88 @@ +export function parsePackagesFromArguments(args) { + const changes = []; + let defaultTag = "latest"; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + const option = getOption(arg); + + if (option) { + // If the option has a parameter, skip the next argument as well + i += option.numberOfParameters; + + continue; + } + + const packageDetails = parsePackagename(arg, defaultTag); + if (packageDetails) { + changes.push(packageDetails); + } + } + + return changes; +} + +function getOption(arg) { + if (isOptionWithParameter(arg)) { + return { + name: arg, + numberOfParameters: 1, + }; + } + + // Arguments starting with "-" or "--" are considered options + // except for "--package=" which contains the package name + if (arg.startsWith("-") && !arg.startsWith("--package=")) { + return { + name: arg, + numberOfParameters: 0, + }; + } + + return undefined; +} + +function isOptionWithParameter(arg) { + const optionsWithParameters = ["--C", "--dir"]; + + return optionsWithParameters.includes(arg); +} + +function parsePackagename(arg, defaultTag) { + // format can be --package=name@version + // in that case, we need to remove the --package= part + if (arg.startsWith("--package=")) { + arg = arg.slice(10); + } + + arg = removeAlias(arg); + + // Split at the last "@" to separate the package name and version + const lastAtIndex = arg.lastIndexOf("@"); + + let name, version; + // The index of the last "@" should be greater than 0 + // If the index is 0, it means the package name starts with "@" (eg: "@aikidosec/package-name") + if (lastAtIndex > 0) { + name = arg.slice(0, lastAtIndex); + version = arg.slice(lastAtIndex + 1); + } else { + name = arg; + version = defaultTag; // No tag specified (eg: "http-server"), use the default tag + } + + return { + name, + version, + }; +} + +function removeAlias(arg) { + // removes the alias. + // Eg.: server@npm:http-server@latest becomes http-server@latest + const aliasIndex = arg.indexOf("@npm:"); + if (aliasIndex !== -1) { + return arg.slice(aliasIndex + 5); + } + return arg; +} diff --git a/src/packagemanager/pnpm/parsing/parsePackagesFromArguments.spec.js b/src/packagemanager/pnpm/parsing/parsePackagesFromArguments.spec.js new file mode 100644 index 0000000..cc9b792 --- /dev/null +++ b/src/packagemanager/pnpm/parsing/parsePackagesFromArguments.spec.js @@ -0,0 +1,138 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { parsePackagesFromArguments } from "./parsePackagesFromArguments.js"; + +describe("standardPnpmArgumentParser", () => { + it("should return an empty array for no changes", () => { + const args = []; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, []); + }); + + it("should return an array of changes for one package", () => { + const args = ["axios@1.9.0"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]); + }); + + it("should return the package with latest tag if absent", () => { + const args = ["axios"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [{ name: "axios", version: "latest" }]); + }); + + it("should return the package with latest tag if the version is absent and package starts with @", () => { + const args = ["@aikidosec/package-name"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [ + { name: "@aikidosec/package-name", version: "latest" }, + ]); + }); + + it("should return the package with the specified tag if the package starts with @ and includes the version", () => { + const args = ["@aikidosec/package-name@1.0.0"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [ + { name: "@aikidosec/package-name", version: "1.0.0" }, + ]); + }); + + it("should only return all packages", () => { + const args = ["axios", "jest"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [ + { name: "axios", version: "latest" }, + { name: "jest", version: "latest" }, + ]); + }); + + it("should ignore options with parameters and return an array of changes", () => { + const args = ["--C", "/Users/johnsmith/dev/project", "axios@1.9.0"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]); + }); + + it("should parse version even for aliased packages", () => { + const args = ["server@npm:axios@1.9.0"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]); + }); + + it("should parse scoped packages", () => { + const args = ["@scope/package@1.0.0"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [{ name: "@scope/package", version: "1.0.0" }]); + }); + + it("should parse packages with version ranges", () => { + const args = ["axios@^1.9.0"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [{ name: "axios", version: "^1.9.0" }]); + }); + + it("should parse package folders", () => { + const args = ["./local-package"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [{ name: "./local-package", version: "latest" }]); + }); + + it("should parse tarballs", () => { + const args = ["file:./local-package.tgz"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [ + { name: "file:./local-package.tgz", version: "latest" }, + ]); + }); + + it("should parse tarball URLs", () => { + const args = ["https://example.com/local-package.tgz"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [ + { name: "https://example.com/local-package.tgz", version: "latest" }, + ]); + }); + + it("should parse git URLs", () => { + const args = ["git://github.com/http-party/http-server"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [ + { name: "git://github.com/http-party/http-server", version: "latest" }, + ]); + }); + + it("should parse packages with --package={packageName}", () => { + const args = ["--package=axios@1.9.0"]; + + const result = parsePackagesFromArguments(args); + + assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]); + }); +}); diff --git a/src/packagemanager/pnpm/runPnpmCommand.js b/src/packagemanager/pnpm/runPnpmCommand.js new file mode 100644 index 0000000..37e7f3b --- /dev/null +++ b/src/packagemanager/pnpm/runPnpmCommand.js @@ -0,0 +1,24 @@ +import { spawnSync } from "child_process"; +import { ui } from "../../environment/userInteraction.js"; + +export function runPnpmCommand(args, toolName = "pnpm") { + try { + let result; + + if (toolName === "pnpm") { + result = spawnSync("pnpm", args, { stdio: "inherit" }); + } else if (toolName === "pnpx") { + result = spawnSync("pnpx", args, { stdio: "inherit" }); + } else { + throw new Error(`Unsupported tool name for aikido-pnpm: ${toolName}`); + } + + if (result.status !== null) { + return { status: result.status }; + } + } catch (error) { + ui.writeError("Error executing command:", error.message); + return { status: 1 }; + } + return { status: 0 }; +} diff --git a/src/shell-integration/helpers.js b/src/shell-integration/helpers.js index 0295858..4226801 100644 --- a/src/shell-integration/helpers.js +++ b/src/shell-integration/helpers.js @@ -6,7 +6,9 @@ export const knownAikidoTools = [ { tool: "npm", aikidoCommand: "aikido-npm" }, { tool: "npx", aikidoCommand: "aikido-npx" }, { tool: "yarn", aikidoCommand: "aikido-yarn" }, - // When adding a new tool here, also update the expected alias in the tests (shellIntegration.spec.js) + { tool: "pnpm", aikidoCommand: "aikido-pnpm" }, + { 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) // and add the documentation for the new tool in the README.md ];