diff --git a/README.md b/README.md index 2105789..d36f1a0 100644 --- a/README.md +++ b/README.md @@ -88,4 +88,60 @@ npm install suspicious-package --safe-chain-malware-action=prompt # Usage in CI/CD -[Learn more about Safe Chain CI/CD integration in the Aikido docs.](https://help.aikido.dev/code-scanning/aikido-malware-scanning/malware-scanning-with-safe-chain-in-ci-cd-environments) +You can protect your CI/CD pipelines from malicious packages by integrating Aikido Safe Chain into your build process. This ensures that any packages installed during your automated builds are checked for malware before installation. + +For optimal protection in CI/CD environments, we recommend using **npm >= 10.4.0** as it provides full dependency tree scanning. Other package managers currently offer limited scanning of install command arguments only. + +## Setup + +To use Aikido Safe Chain in CI/CD environments, run the following command after installing the package: + +```shell +safe-chain setup-ci +``` + +This automatically configures your CI environment to use Aikido Safe Chain for all package manager commands. + +## Supported Platforms + +- ✅ **GitHub Actions** +- ✅ **Azure Pipelines** + +## GitHub Actions Example + +```yaml +- name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + +- name: Setup safe-chain + run: | + npm i -g @aikidosec/safe-chain + safe-chain setup-ci + +- name: Install dependencies + run: | + npm ci +``` + +## Azure DevOps Example + +```yaml +- task: NodeTool@0 + inputs: + versionSpec: "22.x" + displayName: "Install Node.js" + +- script: | + npm i -g @aikidosec/safe-chain + safe-chain setup-ci + displayName: "Install safe chain" + +- script: | + npm ci + displayName: "npm install and build" +``` + +After setup, all subsequent package manager commands in your CI pipeline will automatically be protected by Aikido Safe Chain's malware detection. diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index a89aa6b..5a7d94b 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -4,6 +4,7 @@ import chalk from "chalk"; import { ui } from "../src/environment/userInteraction.js"; import { setup } from "../src/shell-integration/setup.js"; import { teardown } from "../src/shell-integration/teardown.js"; +import { setupCi } from "../src/shell-integration/setup-ci.js"; if (process.argv.length < 3) { ui.writeError("No command provided. Please provide a command to execute."); @@ -23,6 +24,8 @@ if (command === "setup") { setup(); } else if (command === "teardown") { teardown(); +} else if (command === "setup-ci") { + setupCi(); } else { ui.writeError(`Unknown command: ${command}.`); ui.emptyLine(); @@ -53,5 +56,10 @@ function writeHelp() { "safe-chain teardown" )}: This will remove safe-chain aliases from your shell configuration.` ); + ui.writeInformation( + `- ${chalk.cyan( + "safe-chain setup-ci" + )}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.` + ); ui.emptyLine(); } diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index aac54d6..4137471 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -13,6 +13,22 @@ export const knownAikidoTools = [ // and add the documentation for the new tool in the README.md ]; +/** + * Returns a formatted string listing all supported package managers. + * Example: "npm, npx, yarn, pnpm, and pnpx commands" + */ +export function getPackageManagerList() { + const tools = knownAikidoTools.map(t => t.tool); + if (tools.length <= 1) { + return `${tools[0] || ''} commands`; + } + if (tools.length === 2) { + return `${tools[0]} and ${tools[1]} commands`; + } + const lastTool = tools.pop(); + return `${tools.join(', ')}, and ${lastTool} commands`; +} + export function doesExecutableExistOnSystem(executableName) { if (os.platform() === "win32") { const result = spawnSync("where", [executableName], { stdio: "ignore" }); diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh new file mode 100644 index 0000000..6e6d826 --- /dev/null +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# Generated wrapper for {{PACKAGE_MANAGER}} by safe-chain +# This wrapper intercepts {{PACKAGE_MANAGER}} calls for non-interactive environments + +# Function to remove shim from PATH (POSIX-compliant) +remove_shim_from_path() { + echo "$PATH" | sed "s|$HOME/.safe-chain/shims:||g" +} + +if command -v {{AIKIDO_COMMAND}} >/dev/null 2>&1; then + # Remove shim directory from PATH when calling {{AIKIDO_COMMAND}} to prevent infinite loops + PATH=$(remove_shim_from_path) exec {{AIKIDO_COMMAND}} "$@" +else + # Dynamically find original {{PACKAGE_MANAGER}} (excluding this shim directory) + original_cmd=$(PATH=$(remove_shim_from_path) command -v {{PACKAGE_MANAGER}}) + if [ -n "$original_cmd" ]; then + exec "$original_cmd" "$@" + else + echo "Error: Could not find original {{PACKAGE_MANAGER}}" >&2 + exit 1 + fi +fi \ No newline at end of file diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd b/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd new file mode 100644 index 0000000..b7a65fa --- /dev/null +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd @@ -0,0 +1,24 @@ +@echo off +REM Generated wrapper for {{PACKAGE_MANAGER}} by safe-chain +REM This wrapper intercepts {{PACKAGE_MANAGER}} calls for non-interactive environments + +REM Remove shim directory from PATH to prevent infinite loops +set "SHIM_DIR=%USERPROFILE%\.safe-chain\shims" +call set "CLEAN_PATH=%%PATH:%SHIM_DIR%;=%%" + +REM Check if aikido command is available with clean PATH +set "PATH=%CLEAN_PATH%" & where {{AIKIDO_COMMAND}} >nul 2>&1 +if %errorlevel%==0 ( + REM Call aikido command with clean PATH + set "PATH=%CLEAN_PATH%" & {{AIKIDO_COMMAND}} %* +) else ( + REM Find the original command with clean PATH + for /f "tokens=*" %%i in ('set "PATH=%CLEAN_PATH%" ^& where {{PACKAGE_MANAGER}} 2^>nul') do ( + "%%i" %* + goto :eof + ) + + REM If we get here, original command was not found + echo Error: Could not find original {{PACKAGE_MANAGER}} >&2 + exit /b 1 +) \ No newline at end of file diff --git a/packages/safe-chain/src/shell-integration/setup-ci.js b/packages/safe-chain/src/shell-integration/setup-ci.js new file mode 100644 index 0000000..0449ac4 --- /dev/null +++ b/packages/safe-chain/src/shell-integration/setup-ci.js @@ -0,0 +1,123 @@ +import chalk from "chalk"; +import { ui } from "../environment/userInteraction.js"; +import { knownAikidoTools, getPackageManagerList } from "./helpers.js"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { fileURLToPath } from "url"; + +/** + * Loops over the detected shells and calls the setup function for each. + */ +export async function setupCi() { + ui.writeInformation( + chalk.bold("Setting up shell aliases.") + + ` This will wrap safe-chain around ${getPackageManagerList()}.` + ); + ui.emptyLine(); + + const shimsDir = path.join(os.homedir(), ".safe-chain", "shims"); + // Create the shims directory if it doesn't exist + if (!fs.existsSync(shimsDir)) { + fs.mkdirSync(shimsDir, { recursive: true }); + } + + createShims(shimsDir); + ui.writeInformation(`Created shims in ${shimsDir}`); + modifyPathForCi(shimsDir); + ui.writeInformation(`Added shims directory to PATH for CI environments.`); +} + +function createUnixShims(shimsDir) { + // Read the template file + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const templatePath = path.resolve( + __dirname, + "path-wrappers", + "templates", + "unix-wrapper.template.sh" + ); + + if (!fs.existsSync(templatePath)) { + ui.writeError(`Template file not found: ${templatePath}`); + return; + } + + const template = fs.readFileSync(templatePath, "utf-8"); + + // Create a shim for each tool + for (const toolInfo of knownAikidoTools) { + const shimContent = template + .replaceAll("{{PACKAGE_MANAGER}}", toolInfo.tool) + .replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand); + + const shimPath = path.join(shimsDir, toolInfo.tool); + fs.writeFileSync(shimPath, shimContent, "utf-8"); + + // Make the shim executable on Unix systems + fs.chmodSync(shimPath, 0o755); + } + + ui.writeInformation( + `Created ${knownAikidoTools.length} Unix shim(s) in ${shimsDir}` + ); +} + +function createWindowsShims(shimsDir) { + // Read the template file + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const templatePath = path.resolve( + __dirname, + "path-wrappers", + "templates", + "windows-wrapper.template.cmd" + ); + + if (!fs.existsSync(templatePath)) { + ui.writeError(`Windows template file not found: ${templatePath}`); + return; + } + + const template = fs.readFileSync(templatePath, "utf-8"); + + // Create a shim for each tool + for (const toolInfo of knownAikidoTools) { + const shimContent = template + .replaceAll("{{PACKAGE_MANAGER}}", toolInfo.tool) + .replaceAll("{{AIKIDO_COMMAND}}", toolInfo.aikidoCommand); + + const shimPath = path.join(shimsDir, `${toolInfo.tool}.cmd`); + fs.writeFileSync(shimPath, shimContent, "utf-8"); + } + + ui.writeInformation( + `Created ${knownAikidoTools.length} Windows shim(s) in ${shimsDir}` + ); +} + +function createShims(shimsDir) { + if (os.platform() === "win32") { + createWindowsShims(shimsDir); + } else { + createUnixShims(shimsDir); + } +} + +function modifyPathForCi(shimsDir) { + if (process.env.GITHUB_PATH) { + // In GitHub Actions, append the shims directory to GITHUB_PATH + fs.appendFileSync(process.env.GITHUB_PATH, shimsDir + os.EOL, "utf-8"); + ui.writeInformation( + `Added shims directory to GITHUB_PATH for GitHub Actions.` + ); + } + + if (process.env.TF_BUILD) { + // In Azure Pipelines, prepending the path is done via a logging command: + // ##vso[task.prependpath]/path/to/add + // Logging this to stdout will cause the Azure Pipelines agent to pick it up + ui.writeInformation("##vso[task.prependpath]" + shimsDir); + } +} diff --git a/packages/safe-chain/src/shell-integration/setup-ci.spec.js b/packages/safe-chain/src/shell-integration/setup-ci.spec.js new file mode 100644 index 0000000..0a26124 --- /dev/null +++ b/packages/safe-chain/src/shell-integration/setup-ci.spec.js @@ -0,0 +1,150 @@ +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; +import { tmpdir } from "node:os"; +import fs from "node:fs"; +import path from "path"; + +describe("Setup CI shell integration", () => { + let mockShimsDir; + let mockTemplateDir; + let setupCi; + let mockHomeDir; + let mockPlatform; + + beforeEach(async () => { + mockPlatform = "linux"; + // Create temporary directories for testing + mockHomeDir = path.join(tmpdir(), `test-home-${Date.now()}`); + mockShimsDir = path.join(mockHomeDir, ".safe-chain", "shims"); + mockTemplateDir = path.join(tmpdir(), `test-templates-${Date.now()}`); + + // Create template directories and files + fs.mkdirSync(path.join(mockTemplateDir, "path-wrappers", "templates"), { recursive: true }); + fs.writeFileSync( + path.join(mockTemplateDir, "path-wrappers", "templates", "unix-wrapper.template.sh"), + "#!/bin/bash\n# Template for {{PACKAGE_MANAGER}}\nexec {{AIKIDO_COMMAND}} \"$@\"\n", + "utf-8" + ); + fs.writeFileSync( + path.join(mockTemplateDir, "path-wrappers", "templates", "windows-wrapper.template.cmd"), + "@echo off\nREM Template for {{PACKAGE_MANAGER}}\n{{AIKIDO_COMMAND}} %*\n", + "utf-8" + ); + + // Mock the ui module + mock.module("../environment/userInteraction.js", { + namedExports: { + ui: { + writeInformation: () => {}, + emptyLine: () => {}, + writeError: () => {}, + }, + }, + }); + + // Mock the helpers module + mock.module("./helpers.js", { + namedExports: { + knownAikidoTools: [ + { tool: "npm", aikidoCommand: "aikido-npm" }, + { tool: "yarn", aikidoCommand: "aikido-yarn" }, + ], + getPackageManagerList: () => "npm, yarn", + }, + }); + + // Mock os module + mock.module("os", { + namedExports: { + homedir: () => mockHomeDir, + platform: () => mockPlatform, + EOL: "\n", + }, + }); + + // Mock path module to resolve templates correctly + mock.module("path", { + namedExports: { + join: path.join, + dirname: () => mockTemplateDir, + resolve: (...args) => path.resolve(mockTemplateDir, ...args.slice(1)), + }, + }); + + // Mock fileURLToPath + mock.module("url", { + namedExports: { + fileURLToPath: () => path.join(mockTemplateDir, "setup-ci.js"), + }, + }); + + // Import setupCi module after mocking + setupCi = (await import("./setup-ci.js")).setupCi; + }); + + afterEach(() => { + // Clean up test directories + if (fs.existsSync(mockShimsDir)) { + fs.rmSync(mockShimsDir, { recursive: true, force: true }); + } + if (fs.existsSync(mockHomeDir)) { + fs.rmSync(mockHomeDir, { recursive: true, force: true }); + } + if (fs.existsSync(mockTemplateDir)) { + fs.rmSync(mockTemplateDir, { recursive: true, force: true }); + } + + // Reset mocks + mock.reset(); + mockPlatform = "linux"; + }); + + describe("setupCi", () => { + it("should create shims directory and Unix shims", async () => { + await setupCi(); + + // Check if shims directory was created + assert.ok(fs.existsSync(mockShimsDir), "Shims directory should exist"); + + // Check if npm shim was created + const npmShimPath = path.join(mockShimsDir, "npm"); + assert.ok(fs.existsSync(npmShimPath), "npm shim should exist"); + + // Check if yarn shim was created + const yarnShimPath = path.join(mockShimsDir, "yarn"); + assert.ok(fs.existsSync(yarnShimPath), "yarn shim should exist"); + + // Check content of npm shim + const npmShimContent = fs.readFileSync(npmShimPath, "utf-8"); + assert.ok(npmShimContent.includes("aikido-npm"), "npm shim should contain aikido-npm"); + assert.ok(npmShimContent.includes("#!/bin/bash"), "npm shim should have bash shebang"); + }); + + it("should create Windows .cmd shims on win32 platform", async () => { + // Change platform for this test + mockPlatform = "win32"; + + await setupCi(); + + // Check if shims directory was created + assert.ok(fs.existsSync(mockShimsDir), "Shims directory should exist"); + + // Check if .cmd files were created instead of Unix scripts + const npmShimPath = path.join(mockShimsDir, "npm.cmd"); + assert.ok(fs.existsSync(npmShimPath), "npm.cmd shim should exist"); + + const yarnShimPath = path.join(mockShimsDir, "yarn.cmd"); + assert.ok(fs.existsSync(yarnShimPath), "yarn.cmd shim should exist"); + + // Check content of npm.cmd shim + const npmShimContent = fs.readFileSync(npmShimPath, "utf-8"); + assert.ok(npmShimContent.includes("aikido-npm"), "npm.cmd should contain aikido-npm"); + assert.ok(npmShimContent.includes("@echo off"), "npm.cmd should have Windows batch header"); + assert.ok(npmShimContent.includes("%*"), "npm.cmd should use Windows argument passing"); + + // Verify Unix shims were NOT created + const unixNpmShim = path.join(mockShimsDir, "npm"); + assert.ok(!fs.existsSync(unixNpmShim), "Unix npm shim should not exist on Windows"); + }); + }); +}); \ No newline at end of file diff --git a/packages/safe-chain/src/shell-integration/setup.js b/packages/safe-chain/src/shell-integration/setup.js index b58eb51..afa96e8 100644 --- a/packages/safe-chain/src/shell-integration/setup.js +++ b/packages/safe-chain/src/shell-integration/setup.js @@ -1,7 +1,7 @@ import chalk from "chalk"; import { ui } from "../environment/userInteraction.js"; import { detectShells } from "./shellDetection.js"; -import { knownAikidoTools } from "./helpers.js"; +import { knownAikidoTools, getPackageManagerList } from "./helpers.js"; import fs from "fs"; import os from "os"; import path from "path"; @@ -13,7 +13,7 @@ import { fileURLToPath } from "url"; export async function setup() { ui.writeInformation( chalk.bold("Setting up shell aliases.") + - " This will wrap safe-chain around npm, npx, and yarn commands." + ` This will wrap safe-chain around ${getPackageManagerList()}.` ); ui.emptyLine(); diff --git a/packages/safe-chain/src/shell-integration/teardown.js b/packages/safe-chain/src/shell-integration/teardown.js index 9f74300..d6b1277 100644 --- a/packages/safe-chain/src/shell-integration/teardown.js +++ b/packages/safe-chain/src/shell-integration/teardown.js @@ -1,12 +1,12 @@ import chalk from "chalk"; import { ui } from "../environment/userInteraction.js"; import { detectShells } from "./shellDetection.js"; -import { knownAikidoTools } from "./helpers.js"; +import { knownAikidoTools, getPackageManagerList } from "./helpers.js"; export async function teardown() { ui.writeInformation( chalk.bold("Removing shell aliases.") + - " This will remove safe-chain aliases for npm, npx, and yarn commands." + ` This will remove safe-chain aliases for ${getPackageManagerList()}.` ); ui.emptyLine(); diff --git a/test/e2e/npm-ci.e2e.spec.js b/test/e2e/npm-ci.e2e.spec.js new file mode 100644 index 0000000..3e08c3d --- /dev/null +++ b/test/e2e/npm-ci.e2e.spec.js @@ -0,0 +1,103 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +describe("E2E: npm coverage using PATH", () => { + 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-ci"); + + // Add $HOME/.safe-chain/shims to PATH for the test commands + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc" + ); + }); + + 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("zsh"); + const result = await shell.runCommand("npm i axios"); + + assert.ok( + result.output.includes("No malicious packages detected."), + `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("npm i 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("npm list"); + assert.ok( + !listResult.output.includes("safe-chain-test"), + `Malicious package was installed despite safe-chain protection. Output of 'npm list' was:\n${listResult.output}` + ); + }); + + it("safe-chain blocks npx from executing malicious packages", async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand("npx 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}` + ); + }); + + it("safe-chain blocks npm exec from executing malicious packages", async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand("npm exec 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}` + ); + }); +}); diff --git a/test/e2e/pnpm-ci.e2e.spec.js b/test/e2e/pnpm-ci.e2e.spec.js new file mode 100644 index 0000000..9a8c6a2 --- /dev/null +++ b/test/e2e/pnpm-ci.e2e.spec.js @@ -0,0 +1,123 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +describe("E2E: pnpm 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-ci"); + + // Add $HOME/.safe-chain/shims to PATH for the test commands + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc" + ); + }); + + 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("zsh"); + const result = await shell.runCommand("pnpm add axios"); + + assert.ok( + result.output.includes("No malicious packages detected."), + `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("pnpm 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("pnpm list"); + assert.ok( + !listResult.output.includes("safe-chain-test"), + `Malicious package was installed despite safe-chain protection. Output of 'pnpm list' was:\n${listResult.output}` + ); + }); + + it("safe-chain blocks pnpx from executing malicious packages", async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand("pnpx 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}` + ); + }); + + it("safe-chain blocks pnpm dlx from executing malicious packages", async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand("pnpm dlx 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}` + ); + }); + + it("safe-chain blocks pnpm --package=name dlx from executing malicious packages", async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand( + "pnpm --package=safe-chain-test dlx 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}` + ); + }); +}); diff --git a/test/e2e/setup-ci.e2e.spec.js b/test/e2e/setup-ci.e2e.spec.js new file mode 100644 index 0000000..9356f88 --- /dev/null +++ b/test/e2e/setup-ci.e2e.spec.js @@ -0,0 +1,82 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +describe("E2E: safe-chain setup-ci command", () => { + let container; + + before(async () => { + DockerTestContainer.buildImage(); + }); + + beforeEach(async () => { + container = new DockerTestContainer(); + await container.start(); + }); + + afterEach(async () => { + if (container) { + await container.stop(); + container = null; + } + }); + + for (let shell of ["bash", "zsh"]) { + it(`safe-chain setup-ci wraps npm command with PATH shim after installation for ${shell}`, async () => { + // setting up the container + const installationShell = await container.openShell(shell); + await installationShell.runCommand("safe-chain setup-ci"); + + // Add $HOME/.safe-chain/shims to PATH for the test commands + // Usually this would be done by adding ENV in a Dockerfile, or by + // the CI system picking up GITHUB_PATH or similar. Here we do it manually + // to simulate the effect. + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc" + ); + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.bashrc" + ); + + const projectShell = await container.openShell(shell); + const result = await projectShell.runCommand("npm i axios"); + + const hasExpectedOutput = result.output.includes( + "Scanning for malicious packages..." + ); + assert.ok( + hasExpectedOutput, + hasExpectedOutput + ? "Expected npm command to be wrapped by safe-chain" + : `Output did not contain "Scanning for malicious packages...": \n${result.output}` + ); + }); + } + + it("writes to GITHUB_PATH when GITHUB_PATH is set", async () => { + const installationShell = await container.openShell("zsh"); + await installationShell.runCommand("export GITHUB_PATH=/tmp/github_path"); + await installationShell.runCommand("safe-chain setup-ci"); + + const result = await installationShell.runCommand( + "cat /tmp/github_path | grep '.safe-chain/shims'" + ); + + assert.ok( + result.output.includes("/root/.safe-chain/shims"), + `GITHUB_PATH did not contain expected shim path. Output was:\n${result.output}` + ); + }); + + it("writes ##vso[task.prependpath] when TF_BUILD is set", async () => { + const installationShell = await container.openShell("zsh"); + await installationShell.runCommand("export TF_BUILD=true"); + + var result = await installationShell.runCommand("safe-chain setup-ci"); + + assert.ok( + result.output.includes("##vso[task.prependpath]/root/.safe-chain/shims"), + `TF_BUILD did not contain expected prepend path. Output was:\n${result.output}` + ); + }); +}); diff --git a/test/e2e/yarn-ci.e2e.spec.js b/test/e2e/yarn-ci.e2e.spec.js new file mode 100644 index 0000000..5466851 --- /dev/null +++ b/test/e2e/yarn-ci.e2e.spec.js @@ -0,0 +1,85 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +describe("E2E: yarn 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-ci"); + + // Add $HOME/.safe-chain/shims to PATH for the test commands + await installationShell.runCommand( + "echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc" + ); + }); + + 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("zsh"); + const result = await shell.runCommand("yarn add axios"); + + assert.ok( + result.output.includes("No malicious packages detected."), + `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 yarn dlx from executing malicious packages", async () => { + const shell = await container.openShell("zsh"); + const result = await shell.runCommand("yarn dlx 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}` + ); + }); +});