mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Merge pull request #382 from mcmeeking/feature/add-rush-monorepo-support
Add Rush support (for monorepos)
This commit is contained in:
commit
0c8de1e606
24 changed files with 817 additions and 11 deletions
10
README.md
10
README.md
|
|
@ -25,6 +25,8 @@ Aikido Safe Chain supports the following package managers:
|
|||
- 📦 **yarn**
|
||||
- 📦 **pnpm**
|
||||
- 📦 **pnpx**
|
||||
- 📦 **rush**
|
||||
- 📦 **rushx**
|
||||
- 📦 **bun**
|
||||
- 📦 **bunx**
|
||||
- 📦 **pip**
|
||||
|
|
@ -75,7 +77,7 @@ You can find all available versions on the [releases page](https://github.com/Ai
|
|||
### Verify the installation
|
||||
|
||||
1. **❗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, pnpx, bun, bunx, pip, pip3, poetry, uv, uvx and pipx 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, rush, rushx, bun, bunx, pip, pip3, poetry, uv, uvx and pipx are loaded correctly. If you do not restart your terminal, the aliases will not be available.
|
||||
|
||||
2. **Verify the installation** by running the verification command:
|
||||
|
||||
|
|
@ -106,7 +108,7 @@ You can find all available versions on the [releases page](https://github.com/Ai
|
|||
|
||||
- The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware.
|
||||
|
||||
When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command.
|
||||
When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command.
|
||||
|
||||
You can check the installed version by running:
|
||||
|
||||
|
|
@ -118,7 +120,7 @@ safe-chain --version
|
|||
|
||||
### Malware Blocking
|
||||
|
||||
The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, uv, uvx, poetry or pipx 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 works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, pip, pip3, uv, uvx, poetry or pipx 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.
|
||||
|
||||
### Minimum package age
|
||||
|
||||
|
|
@ -137,7 +139,7 @@ By default, the minimum package age is 48 hours. This provides an additional sec
|
|||
|
||||
### Shell Integration
|
||||
|
||||
The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and Python package managers (pip, uv, uvx, poetry, pipx). 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:
|
||||
The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, and Python package managers (pip, uv, uvx, poetry, pipx). 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**
|
||||
- ✅ **Zsh**
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Overview
|
||||
|
||||
The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry`, `pipx`) with Aikido's security scanning functionality. It also intercepts Python module invocations for pip when available: `python -m pip`, `python -m pip3`, `python3 -m pip`, `python3 -m pip3`. 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`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry`, `pipx`) with Aikido's security scanning functionality. It also intercepts Python module invocations for pip when available: `python -m pip`, `python -m pip3`, `python3 -m pip`, `python3 -m pip3`. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents.
|
||||
|
||||
## Supported Shells
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ This command:
|
|||
|
||||
- Copies necessary startup scripts to Safe Chain's installation directory (`~/.safe-chain/scripts`)
|
||||
- Detects all supported shells on your system
|
||||
- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx`
|
||||
- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx`
|
||||
- Adds lightweight interceptors so `python -m pip[...]` and `python3 -m pip[...]` route through Safe Chain when invoked by name
|
||||
|
||||
❗ After running this command, **you must restart your terminal** for the changes to take effect. This ensures that the startup scripts are sourced correctly.
|
||||
|
|
@ -78,7 +78,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:
|
||||
|
||||
- Make sure Aikido Safe Chain is properly installed on your system
|
||||
- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-bun`, `aikido-bunx`, `aikido-pip`, `aikido-pip3`, `aikido-uv`, `aikido-uvx`, `aikido-poetry` and `aikido-pipx` commands exist
|
||||
- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-rush`, `aikido-rushx`, `aikido-bun`, `aikido-bunx`, `aikido-pip`, `aikido-pip3`, `aikido-uv`, `aikido-uvx`, `aikido-poetry` and `aikido-pipx` commands exist
|
||||
- Check that these commands are in your system's PATH
|
||||
|
||||
### Manual Verification
|
||||
|
|
@ -121,7 +121,7 @@ npm() {
|
|||
}
|
||||
```
|
||||
|
||||
Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes.
|
||||
Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes.
|
||||
|
||||
To intercept Python module invocations for pip without altering Python itself, you can add small forwarding functions:
|
||||
|
||||
|
|
|
|||
3
npm-shrinkwrap.json
generated
3
npm-shrinkwrap.json
generated
|
|
@ -2417,7 +2417,6 @@
|
|||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -3138,6 +3137,8 @@
|
|||
"aikido-poetry": "bin/aikido-poetry.js",
|
||||
"aikido-python": "bin/aikido-python.js",
|
||||
"aikido-python3": "bin/aikido-python3.js",
|
||||
"aikido-rush": "bin/aikido-rush.js",
|
||||
"aikido-rushx": "bin/aikido-rushx.js",
|
||||
"aikido-uv": "bin/aikido-uv.js",
|
||||
"aikido-uvx": "bin/aikido-uvx.js",
|
||||
"aikido-yarn": "bin/aikido-yarn.js",
|
||||
|
|
|
|||
14
packages/safe-chain/bin/aikido-rush.js
Executable file
14
packages/safe-chain/bin/aikido-rush.js
Executable file
|
|
@ -0,0 +1,14 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { main } from "../src/main.js";
|
||||
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||
import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js";
|
||||
|
||||
setEcoSystem(ECOSYSTEM_JS);
|
||||
const packageManagerName = "rush";
|
||||
initializePackageManager(packageManagerName);
|
||||
|
||||
(async () => {
|
||||
var exitCode = await main(process.argv.slice(2));
|
||||
process.exit(exitCode);
|
||||
})();
|
||||
14
packages/safe-chain/bin/aikido-rushx.js
Executable file
14
packages/safe-chain/bin/aikido-rushx.js
Executable file
|
|
@ -0,0 +1,14 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { main } from "../src/main.js";
|
||||
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
|
||||
import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js";
|
||||
|
||||
setEcoSystem(ECOSYSTEM_JS);
|
||||
const packageManagerName = "rushx";
|
||||
initializePackageManager(packageManagerName);
|
||||
|
||||
(async () => {
|
||||
var exitCode = await main(process.argv.slice(2));
|
||||
process.exit(exitCode);
|
||||
})();
|
||||
|
|
@ -108,7 +108,7 @@ function writeHelp() {
|
|||
ui.writeInformation(
|
||||
`- ${chalk.cyan(
|
||||
"safe-chain setup",
|
||||
)}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, bun, bunx, pip and pip3.`,
|
||||
)}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, pip and pip3.`,
|
||||
);
|
||||
ui.writeInformation(
|
||||
`- ${chalk.cyan(
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@
|
|||
"aikido-yarn": "bin/aikido-yarn.js",
|
||||
"aikido-pnpm": "bin/aikido-pnpm.js",
|
||||
"aikido-pnpx": "bin/aikido-pnpx.js",
|
||||
"aikido-rush": "bin/aikido-rush.js",
|
||||
"aikido-rushx": "bin/aikido-rushx.js",
|
||||
"aikido-bun": "bin/aikido-bun.js",
|
||||
"aikido-bunx": "bin/aikido-bunx.js",
|
||||
"aikido-uv": "bin/aikido-uv.js",
|
||||
|
|
@ -37,7 +39,7 @@
|
|||
"keywords": [],
|
||||
"author": "Aikido Security",
|
||||
"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/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), and [pip](https://pip.pypa.io/) 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, bunx, uv, uvx, or pip/pip3 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), [rush](https://rushjs.io/), [rushx](https://rushjs.io/pages/commands/rushx/), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), and [pip](https://pip.pypa.io/) 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, rush, rushx, bun, bunx, uv, uvx, or pip/pip3 from downloading or running the malware.",
|
||||
"dependencies": {
|
||||
"certifi": "14.5.15",
|
||||
"chalk": "5.4.1",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import { createPipPackageManager } from "./pip/createPackageManager.js";
|
|||
import { createUvPackageManager } from "./uv/createUvPackageManager.js";
|
||||
import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js";
|
||||
import { createPipXPackageManager } from "./pipx/createPipXPackageManager.js";
|
||||
import { createRushPackageManager } from "./rush/createRushPackageManager.js";
|
||||
import { createRushxPackageManager } from "./rushx/createRushxPackageManager.js";
|
||||
import { createUvxPackageManager } from "./uvx/createUvxPackageManager.js";
|
||||
|
||||
/**
|
||||
|
|
@ -67,6 +69,10 @@ export function initializePackageManager(packageManagerName, context) {
|
|||
state.packageManagerName = createPoetryPackageManager();
|
||||
} else if (packageManagerName === "pipx") {
|
||||
state.packageManagerName = createPipXPackageManager();
|
||||
} else if (packageManagerName === "rush") {
|
||||
state.packageManagerName = createRushPackageManager();
|
||||
} else if (packageManagerName === "rushx") {
|
||||
state.packageManagerName = createRushxPackageManager();
|
||||
} else {
|
||||
throw new Error("Unsupported package manager: " + packageManagerName);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
import { runRushCommand } from "./runRushCommand.js";
|
||||
import { resolvePackageVersion } from "../../api/npmApi.js";
|
||||
import { parsePackagesFromRushAddArgs } from "./parsing/parsePackagesFromRushAddArgs.js";
|
||||
|
||||
/**
|
||||
* @returns {import("../currentPackageManager.js").PackageManager}
|
||||
*/
|
||||
export function createRushPackageManager() {
|
||||
return {
|
||||
runCommand: (args) => runRushCommand("rush", args),
|
||||
// We pre-scan rush add commands and rely on MITM for install/update flows.
|
||||
isSupportedCommand: (args) => getRushCommand(args) === "add",
|
||||
getDependencyUpdatesForCommand: scanRushAddCommand,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} args
|
||||
* @returns {Promise<import("../currentPackageManager.js").GetDependencyUpdatesResult[]>}
|
||||
*/
|
||||
async function scanRushAddCommand(args) {
|
||||
if (getRushCommand(args) !== "add") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsedSpecs = parsePackagesFromRushAddArgs(args.slice(1));
|
||||
|
||||
const resolvedVersions = await Promise.all(
|
||||
parsedSpecs.map(async (parsed) => {
|
||||
const exactVersion = await resolvePackageVersion(parsed.name, parsed.version);
|
||||
return {
|
||||
parsed,
|
||||
exactVersion,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const changes = [];
|
||||
for (const resolved of resolvedVersions) {
|
||||
if (!resolved.exactVersion) {
|
||||
continue;
|
||||
}
|
||||
|
||||
changes.push({
|
||||
name: resolved.parsed.name,
|
||||
version: resolved.exactVersion,
|
||||
type: "add",
|
||||
});
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} args
|
||||
* @returns {string | undefined}
|
||||
*/
|
||||
function getRushCommand(args) {
|
||||
if (!args || args.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return args[0]?.toLowerCase();
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { test, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
|
||||
test("createRushPackageManager", async (t) => {
|
||||
mock.module("../../api/npmApi.js", {
|
||||
namedExports: {
|
||||
resolvePackageVersion: async (name, version) => {
|
||||
if (name === "safe-chain-test") {
|
||||
return "0.0.1-security";
|
||||
}
|
||||
|
||||
if (name === "@scope/tool") {
|
||||
return version || "2.0.0";
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const { createRushPackageManager } = await import("./createRushPackageManager.js");
|
||||
|
||||
await t.test("should create package manager with required interface", () => {
|
||||
const pm = createRushPackageManager();
|
||||
|
||||
assert.ok(pm);
|
||||
assert.strictEqual(typeof pm.runCommand, "function");
|
||||
assert.strictEqual(typeof pm.isSupportedCommand, "function");
|
||||
assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function");
|
||||
});
|
||||
|
||||
await t.test("should scan rush add commands", () => {
|
||||
const pm = createRushPackageManager();
|
||||
|
||||
assert.strictEqual(pm.isSupportedCommand(["add", "--package", "safe-chain-test"]), true);
|
||||
assert.strictEqual(pm.isSupportedCommand(["install"]), false);
|
||||
});
|
||||
|
||||
await t.test("should parse rush add package specs and resolve versions", async () => {
|
||||
const pm = createRushPackageManager();
|
||||
|
||||
const changes = await pm.getDependencyUpdatesForCommand([
|
||||
"add",
|
||||
"--package",
|
||||
"safe-chain-test",
|
||||
"--package=@scope/tool@1.2.3",
|
||||
]);
|
||||
|
||||
assert.deepStrictEqual(changes, [
|
||||
{ name: "safe-chain-test", version: "0.0.1-security", type: "add" },
|
||||
{ name: "@scope/tool", version: "1.2.3", type: "add" },
|
||||
]);
|
||||
});
|
||||
|
||||
await t.test("should return no changes for non-add commands", async () => {
|
||||
const pm = createRushPackageManager();
|
||||
|
||||
const changes = await pm.getDependencyUpdatesForCommand(["install"]);
|
||||
|
||||
assert.deepStrictEqual(changes, []);
|
||||
});
|
||||
} finally {
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* @param {string[]} args
|
||||
* @returns {{name: string, version: string | null}[]}
|
||||
*/
|
||||
export function parsePackagesFromRushAddArgs(args) {
|
||||
const packageSpecs = [];
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (!arg) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--package" || arg === "-p") {
|
||||
const next = args[i + 1];
|
||||
if (next && !next.startsWith("-")) {
|
||||
packageSpecs.push(next);
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith("--package=")) {
|
||||
const value = arg.slice("--package=".length);
|
||||
if (value) {
|
||||
packageSpecs.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return packageSpecs
|
||||
.map((spec) => parsePackageSpec(spec))
|
||||
.filter((spec) => spec !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} spec
|
||||
* @returns {{name: string, version: string | null} | null}
|
||||
*/
|
||||
function parsePackageSpec(spec) {
|
||||
const value = removeAlias(spec.trim());
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastAtIndex = value.lastIndexOf("@");
|
||||
if (lastAtIndex > 0) {
|
||||
return {
|
||||
name: value.slice(0, lastAtIndex),
|
||||
version: value.slice(lastAtIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: value,
|
||||
version: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} spec
|
||||
* @returns {string}
|
||||
*/
|
||||
function removeAlias(spec) {
|
||||
const aliasIndex = spec.indexOf("@npm:");
|
||||
if (aliasIndex !== -1) {
|
||||
return spec.slice(aliasIndex + 5);
|
||||
}
|
||||
|
||||
return spec;
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { parsePackagesFromRushAddArgs } from "./parsePackagesFromRushAddArgs.js";
|
||||
|
||||
describe("parsePackagesFromRushAddArgs", () => {
|
||||
it("returns an empty array when no packages are provided", () => {
|
||||
const result = parsePackagesFromRushAddArgs([]);
|
||||
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
it("parses packages from --package arguments", () => {
|
||||
const result = parsePackagesFromRushAddArgs([
|
||||
"--package",
|
||||
"axios@1.9.0",
|
||||
"--package",
|
||||
"@scope/tool@2.0.0",
|
||||
]);
|
||||
|
||||
assert.deepEqual(result, [
|
||||
{ name: "axios", version: "1.9.0" },
|
||||
{ name: "@scope/tool", version: "2.0.0" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses packages from -p arguments", () => {
|
||||
const result = parsePackagesFromRushAddArgs(["-p", "axios"]);
|
||||
|
||||
assert.deepEqual(result, [{ name: "axios", version: null }]);
|
||||
});
|
||||
|
||||
it("parses packages from --package=value arguments", () => {
|
||||
const result = parsePackagesFromRushAddArgs(["--package=axios@^1.9.0"]);
|
||||
|
||||
assert.deepEqual(result, [{ name: "axios", version: "^1.9.0" }]);
|
||||
});
|
||||
|
||||
it("ignores positional packages because rush add requires --package", () => {
|
||||
const result = parsePackagesFromRushAddArgs(["axios", "--dev"]);
|
||||
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
it("parses aliases", () => {
|
||||
const result = parsePackagesFromRushAddArgs(["--package", "server@npm:axios@1.9.0"]);
|
||||
|
||||
assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
||||
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
|
||||
|
||||
/**
|
||||
* @param {"rush" | "rushx"} executableName
|
||||
* @param {string[]} args
|
||||
* @returns {Promise<{status: number}>}
|
||||
*/
|
||||
export async function runRushCommand(executableName, args) {
|
||||
try {
|
||||
const result = await safeSpawn(executableName, args, {
|
||||
stdio: "inherit",
|
||||
env: mergeSafeChainProxyEnvironmentVariables(process.env),
|
||||
});
|
||||
|
||||
return { status: result.status };
|
||||
} catch (/** @type any */ error) {
|
||||
return reportCommandExecutionFailure(error, executableName);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
import { describe, it, beforeEach, afterEach, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
|
||||
describe("runRushCommand", () => {
|
||||
let runRushCommand;
|
||||
let safeSpawnMock;
|
||||
let mergeCalls;
|
||||
let mergeResultEnv;
|
||||
let nextSpawnStatus;
|
||||
let nextSpawnError;
|
||||
|
||||
beforeEach(async () => {
|
||||
mergeCalls = [];
|
||||
mergeResultEnv = null;
|
||||
nextSpawnStatus = 0;
|
||||
nextSpawnError = null;
|
||||
safeSpawnMock = mock.fn(async () => {
|
||||
if (nextSpawnError) {
|
||||
const error = nextSpawnError;
|
||||
nextSpawnError = null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { status: nextSpawnStatus };
|
||||
});
|
||||
|
||||
mock.module("../../utils/safeSpawn.js", {
|
||||
namedExports: {
|
||||
safeSpawn: safeSpawnMock,
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("../../registryProxy/registryProxy.js", {
|
||||
namedExports: {
|
||||
mergeSafeChainProxyEnvironmentVariables: (env) => {
|
||||
mergeCalls.push(env);
|
||||
if (mergeResultEnv) {
|
||||
return mergeResultEnv;
|
||||
}
|
||||
|
||||
return {
|
||||
...env,
|
||||
HTTPS_PROXY: "http://localhost:8080",
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// commandErrors reports through ui on failures, so provide a no-op mock
|
||||
mock.module("../../environment/userInteraction.js", {
|
||||
namedExports: {
|
||||
ui: {
|
||||
writeError: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mod = await import("./runRushCommand.js");
|
||||
runRushCommand = mod.runRushCommand;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
it("spawns rush with merged proxy env", async () => {
|
||||
const res = await runRushCommand("rush", ["install"]);
|
||||
|
||||
assert.strictEqual(res.status, 0);
|
||||
assert.strictEqual(safeSpawnMock.mock.calls.length, 1);
|
||||
|
||||
const [command, args, options] = safeSpawnMock.mock.calls[0].arguments;
|
||||
assert.strictEqual(command, "rush");
|
||||
assert.deepStrictEqual(args, ["install"]);
|
||||
assert.strictEqual(options.stdio, "inherit");
|
||||
assert.strictEqual(options.env.HTTPS_PROXY, "http://localhost:8080");
|
||||
assert.ok(mergeCalls.length >= 1, "proxy env merge should be called");
|
||||
});
|
||||
|
||||
it("returns spawn result status", async () => {
|
||||
nextSpawnStatus = 7;
|
||||
|
||||
const res = await runRushCommand("rush", ["update"]);
|
||||
|
||||
assert.strictEqual(res.status, 7);
|
||||
});
|
||||
|
||||
it("reports failures with rush target", async () => {
|
||||
nextSpawnError = Object.assign(new Error("spawn failed"), {
|
||||
code: "ENOENT",
|
||||
});
|
||||
|
||||
const res = await runRushCommand("rush", ["install"]);
|
||||
|
||||
assert.strictEqual(res.status, 1);
|
||||
});
|
||||
|
||||
it("does not mutate merged env object", async () => {
|
||||
mergeResultEnv = {
|
||||
HTTPS_PROXY: "http://localhost:8080",
|
||||
};
|
||||
|
||||
await runRushCommand("rush", ["install"]);
|
||||
|
||||
assert.deepStrictEqual(mergeResultEnv, {
|
||||
HTTPS_PROXY: "http://localhost:8080",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { runRushCommand } from "../rush/runRushCommand.js";
|
||||
|
||||
/**
|
||||
* @returns {import("../currentPackageManager.js").PackageManager}
|
||||
*/
|
||||
export function createRushxPackageManager() {
|
||||
return {
|
||||
/**
|
||||
* @param {string[]} args
|
||||
*/
|
||||
runCommand: (args) => {
|
||||
return runRushCommand("rushx", args);
|
||||
},
|
||||
// For rushx, rely solely on MITM.
|
||||
isSupportedCommand: () => false,
|
||||
getDependencyUpdatesForCommand: () => [],
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { test } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { createRushxPackageManager } from "./createRushxPackageManager.js";
|
||||
|
||||
test("createRushxPackageManager returns valid package manager interface", () => {
|
||||
const pm = createRushxPackageManager();
|
||||
|
||||
assert.ok(pm);
|
||||
assert.strictEqual(typeof pm.runCommand, "function");
|
||||
assert.strictEqual(typeof pm.isSupportedCommand, "function");
|
||||
assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function");
|
||||
assert.strictEqual(pm.isSupportedCommand(), false);
|
||||
assert.deepStrictEqual(pm.getDependencyUpdatesForCommand(), []);
|
||||
});
|
||||
|
|
@ -48,6 +48,18 @@ export const knownAikidoTools = [
|
|||
ecoSystem: ECOSYSTEM_JS,
|
||||
internalPackageManagerName: "pnpx",
|
||||
},
|
||||
{
|
||||
tool: "rush",
|
||||
aikidoCommand: "aikido-rush",
|
||||
ecoSystem: ECOSYSTEM_JS,
|
||||
internalPackageManagerName: "rush",
|
||||
},
|
||||
{
|
||||
tool: "rushx",
|
||||
aikidoCommand: "aikido-rushx",
|
||||
ecoSystem: ECOSYSTEM_JS,
|
||||
internalPackageManagerName: "rushx",
|
||||
},
|
||||
{
|
||||
tool: "bun",
|
||||
aikidoCommand: "aikido-bun",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,14 @@ function pnpx
|
|||
wrapSafeChainCommand "pnpx" $argv
|
||||
end
|
||||
|
||||
function rush
|
||||
wrapSafeChainCommand "rush" $argv
|
||||
end
|
||||
|
||||
function rushx
|
||||
wrapSafeChainCommand "rushx" $argv
|
||||
end
|
||||
|
||||
function bun
|
||||
wrapSafeChainCommand "bun" $argv
|
||||
end
|
||||
|
|
|
|||
|
|
@ -28,6 +28,14 @@ function pnpx() {
|
|||
wrapSafeChainCommand "pnpx" "$@"
|
||||
}
|
||||
|
||||
function rush() {
|
||||
wrapSafeChainCommand "rush" "$@"
|
||||
}
|
||||
|
||||
function rushx() {
|
||||
wrapSafeChainCommand "rushx" "$@"
|
||||
}
|
||||
|
||||
function bun() {
|
||||
wrapSafeChainCommand "bun" "$@"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,14 @@ function pnpx {
|
|||
Invoke-WrappedCommand "pnpx" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
||||
}
|
||||
|
||||
function rush {
|
||||
Invoke-WrappedCommand "rush" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
||||
}
|
||||
|
||||
function rushx {
|
||||
Invoke-WrappedCommand "rushx" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
||||
}
|
||||
|
||||
function bun {
|
||||
Invoke-WrappedCommand "bun" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ ARG NODE_VERSION=latest
|
|||
ARG NPM_VERSION=latest
|
||||
ARG YARN_VERSION=latest
|
||||
ARG PNPM_VERSION=latest
|
||||
ARG RUSH_VERSION=latest
|
||||
ARG PYTHON_VERSION=3
|
||||
|
||||
SHELL ["/bin/bash", "-c"]
|
||||
|
|
@ -46,6 +47,7 @@ RUN volta install node@${NODE_VERSION}
|
|||
RUN volta install npm@${NPM_VERSION}
|
||||
RUN volta install yarn@${YARN_VERSION}
|
||||
RUN volta install pnpm@${PNPM_VERSION}
|
||||
RUN volta install @microsoft/rush@${RUSH_VERSION}
|
||||
|
||||
# Install Bun
|
||||
RUN curl -fsSL https://bun.sh/install | bash
|
||||
|
|
|
|||
148
test/e2e/rush.e2e.spec.js
Normal file
148
test/e2e/rush.e2e.spec.js
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { describe, it, before, beforeEach, afterEach } from "node:test";
|
||||
import { DockerTestContainer } from "./DockerTestContainer.js";
|
||||
import {
|
||||
buildRushConfig,
|
||||
resolveRushVersions,
|
||||
writeTextFile,
|
||||
} from "./utils/rushtestutils.mjs";
|
||||
import assert from "node:assert";
|
||||
|
||||
// These tests cover safe-chain's Rush wrapper: pre-scanning `rush add` and
|
||||
// blocking malicious packages downloaded during `rush update` via the MITM
|
||||
// proxy. They use a single Rush-internal package manager (pnpm) — see
|
||||
// `utils/rushtestutils.mjs` for why this suite isn't parameterised over the
|
||||
// CI matrix's NPM_VERSION/PNPM_VERSION/YARN_VERSION values.
|
||||
|
||||
describe("E2E: rush coverage", () => {
|
||||
let container;
|
||||
/** @type {{ rushVersion: string, pnpmVersion: string } | undefined} */
|
||||
let resolvedVersions;
|
||||
|
||||
before(async () => {
|
||||
DockerTestContainer.buildImage();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
container = new DockerTestContainer();
|
||||
await container.start();
|
||||
|
||||
const installationShell = await container.openShell("zsh");
|
||||
await installationShell.runCommand("safe-chain setup");
|
||||
|
||||
if (!resolvedVersions) {
|
||||
resolvedVersions = await resolveRushVersions(installationShell);
|
||||
}
|
||||
|
||||
await setupRushWorkspace(installationShell, { resolvedVersions });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (container) {
|
||||
await container.stop();
|
||||
container = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("safe-chain successfully adds safe packages", async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
const result = await shell.runCommand(
|
||||
"cd /testapp/apps/test-app && rush add --package axios@1.13.0 --exact --skip-update --safe-chain-logging=verbose"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malware found."),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it("safe-chain blocks rush add of malicious packages", async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
const result = await shell.runCommand(
|
||||
"cd /testapp/apps/test-app && rush add --package safe-chain-test --skip-update"
|
||||
);
|
||||
|
||||
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 packageJson = await shell.runCommand(
|
||||
"cat /testapp/apps/test-app/package.json"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
!packageJson.output.includes("safe-chain-test"),
|
||||
`Malicious package was added despite safe-chain protection. Output was:\n${packageJson.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it("safe-chain proxy blocks malicious package downloads during rush update", async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
await setupRushWorkspace(shell, {
|
||||
resolvedVersions,
|
||||
packageJson: `{
|
||||
"name": "test-app",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"safe-chain-test": "0.0.1-security"
|
||||
}
|
||||
}`,
|
||||
});
|
||||
|
||||
// `--safe-chain-skip-minimum-package-age` is needed because Rush's
|
||||
// internal pnpm bootstrap (`npm install pnpm@<resolvedVersion>`) goes
|
||||
// through the safe-chain proxy. When the CI matrix selects pnpm
|
||||
// `latest`, the just-released version can be below the minimum age
|
||||
// threshold and Rush's install would otherwise be blocked before our
|
||||
// malicious-download assertion is reached.
|
||||
const result = await shell.runCommand(
|
||||
"cd /testapp/apps/test-app && rush update --safe-chain-skip-minimum-package-age"
|
||||
);
|
||||
|
||||
assert.match(
|
||||
result.output,
|
||||
/blocked \d+ 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}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
async function setupRushWorkspace(shell, { resolvedVersions, packageJson }) {
|
||||
const rushConfig = buildRushConfig({
|
||||
rushVersion: resolvedVersions.rushVersion,
|
||||
pnpmVersion: resolvedVersions.pnpmVersion,
|
||||
});
|
||||
|
||||
await shell.runCommand("rm -rf /testapp/common /testapp/apps/test-app");
|
||||
await shell.runCommand("mkdir -p /testapp/apps/test-app");
|
||||
await writeTextFile(
|
||||
shell,
|
||||
"/testapp/rush.json",
|
||||
JSON.stringify(rushConfig, null, 2)
|
||||
);
|
||||
await writeTextFile(
|
||||
shell,
|
||||
"/testapp/apps/test-app/package.json",
|
||||
packageJson ??
|
||||
`{
|
||||
"name": "test-app",
|
||||
"version": "1.0.0"
|
||||
}`
|
||||
);
|
||||
}
|
||||
100
test/e2e/rushx.e2e.spec.js
Normal file
100
test/e2e/rushx.e2e.spec.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { describe, it, before, beforeEach, afterEach } from "node:test";
|
||||
import { DockerTestContainer } from "./DockerTestContainer.js";
|
||||
import {
|
||||
buildRushConfig,
|
||||
resolveRushVersions,
|
||||
writeTextFile,
|
||||
} from "./utils/rushtestutils.mjs";
|
||||
import assert from "node:assert";
|
||||
|
||||
describe("E2E: rushx coverage", () => {
|
||||
let container;
|
||||
/** @type {{ rushVersion: string, pnpmVersion: string } | undefined} */
|
||||
let resolvedVersions;
|
||||
|
||||
before(async () => {
|
||||
DockerTestContainer.buildImage();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
container = new DockerTestContainer();
|
||||
await container.start();
|
||||
|
||||
const installationShell = await container.openShell("zsh");
|
||||
await installationShell.runCommand("safe-chain setup");
|
||||
|
||||
if (!resolvedVersions) {
|
||||
resolvedVersions = await resolveRushVersions(installationShell);
|
||||
}
|
||||
|
||||
await setupRushWorkspace(installationShell, { resolvedVersions });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (container) {
|
||||
await container.stop();
|
||||
container = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("safe-chain successfully scans safe package downloads from rushx scripts", async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
const result = await shell.runCommand(
|
||||
"cd /testapp/apps/test-app && rushx install-safe --safe-chain-logging=verbose"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("no malware found."),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
it("safe-chain blocks malicious package downloads from rushx scripts", async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
const result = await shell.runCommand(
|
||||
"cd /testapp/apps/test-app && rushx install-malicious"
|
||||
);
|
||||
|
||||
assert.match(
|
||||
result.output,
|
||||
/blocked \d+ 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}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
async function setupRushWorkspace(shell, { resolvedVersions }) {
|
||||
const rushConfig = buildRushConfig({
|
||||
rushVersion: resolvedVersions.rushVersion,
|
||||
pnpmVersion: resolvedVersions.pnpmVersion,
|
||||
});
|
||||
|
||||
await shell.runCommand(
|
||||
"mkdir -p /testapp/common/config/rush /testapp/apps/test-app"
|
||||
);
|
||||
await writeTextFile(
|
||||
shell,
|
||||
"/testapp/rush.json",
|
||||
JSON.stringify(rushConfig, null, 2)
|
||||
);
|
||||
await writeTextFile(
|
||||
shell,
|
||||
"/testapp/apps/test-app/package.json",
|
||||
`{
|
||||
"name": "test-app",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"install-safe": "npm install axios@1.13.0",
|
||||
"install-malicious": "npm install safe-chain-test@0.0.1-security"
|
||||
}
|
||||
}`
|
||||
);
|
||||
}
|
||||
69
test/e2e/utils/rushtestutils.mjs
Normal file
69
test/e2e/utils/rushtestutils.mjs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// Helpers for the Rush E2E suites.
|
||||
//
|
||||
// What these suites actually test: that safe-chain's shim intercepts `rush`
|
||||
// and `rushx` invocations correctly. The contents of `rush.json` are just
|
||||
// fixture noise needed to make Rush run at all — Rush's schema requires
|
||||
// exact semver for `rushVersion`/`pnpmVersion` and refuses dist-tags like
|
||||
// "latest", so we read both back from the binaries baked into the image.
|
||||
//
|
||||
// * `rushVersion` ← `rush --version` (image installs
|
||||
// `@microsoft/rush@${RUSH_VERSION:-latest}`).
|
||||
// * `pnpmVersion` ← `pnpm --version` (image installs
|
||||
// `pnpm@${PNPM_VERSION:-latest}`). Rush downloads its own copy of this
|
||||
// into `~/.rush/...`; using the same exact version as the system pnpm
|
||||
// just keeps the fixture in lockstep with whatever the CI matrix picks.
|
||||
|
||||
/** Resolves the versions to put into `rush.json`. */
|
||||
export async function resolveRushVersions(shell) {
|
||||
// Sequential: the helper drives a single PTY shell.
|
||||
const rushVersion = await getInstalledVersion(shell, "rush");
|
||||
const pnpmVersion = await getInstalledVersion(shell, "pnpm");
|
||||
return { rushVersion, pnpmVersion };
|
||||
}
|
||||
|
||||
/** Builds the standard `rush.json` body for the e2e fixtures. */
|
||||
export function buildRushConfig({ rushVersion, pnpmVersion, projects }) {
|
||||
return {
|
||||
$schema:
|
||||
"https://developer.microsoft.com/json-schemas/rush/v5/rush.schema.json",
|
||||
rushVersion,
|
||||
pnpmVersion,
|
||||
nodeSupportedVersionRange: ">=18.0.0",
|
||||
projectFolderMinDepth: 1,
|
||||
projectFolderMaxDepth: 2,
|
||||
gitPolicy: {},
|
||||
repository: {
|
||||
url: "https://example.com/testapp.git",
|
||||
defaultBranch: "main",
|
||||
},
|
||||
eventHooks: {
|
||||
preRushInstall: [],
|
||||
postRushInstall: [],
|
||||
preRushBuild: [],
|
||||
postRushBuild: [],
|
||||
},
|
||||
projects: projects ?? [
|
||||
{ packageName: "test-app", projectFolder: "apps/test-app" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a UTF-8 text file inside the container, base64-encoding the payload
|
||||
* to avoid shell escaping issues for arbitrary content.
|
||||
*/
|
||||
export async function writeTextFile(shell, filePath, content) {
|
||||
const encoded = Buffer.from(content).toString("base64");
|
||||
await shell.runCommand(`printf '%s' '${encoded}' | base64 -d > ${filePath}`);
|
||||
}
|
||||
|
||||
async function getInstalledVersion(shell, command) {
|
||||
const { output } = await shell.runCommand(`${command} --version`);
|
||||
const match = output.match(/\b(\d+\.\d+\.\d+)\b/);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Could not determine installed ${command} version. Output was:\n${output}`
|
||||
);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue