From d1c0982942613683cf4b52dee3a72a54d5be2f88 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Thu, 18 Sep 2025 17:44:42 +0200 Subject: [PATCH 1/7] Base safe-chain setupci implementation --- packages/safe-chain/bin/safe-chain.js | 8 ++ .../src/shell-integration/helpers.js | 16 +++ .../templates/unix-wrapper.template.sh | 22 ++++ .../templates/windows-wrapper.template.cmd | 24 ++++ .../src/shell-integration/setup-ci.js | 121 ++++++++++++++++++ .../safe-chain/src/shell-integration/setup.js | 4 +- .../src/shell-integration/teardown.js | 4 +- 7 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh create mode 100644 packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd create mode 100644 packages/safe-chain/src/shell-integration/setup-ci.js 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 d6c19d7..f338684 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -12,6 +12,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..2338894 --- /dev/null +++ b/packages/safe-chain/src/shell-integration/setup-ci.js @@ -0,0 +1,121 @@ +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.` + ); + } + + // detect azure pipelines + if (process.env.TF_BUILD) { + ui.writeInformation("##vso[task.prependpath]" + shimsDir); + } +} diff --git a/packages/safe-chain/src/shell-integration/setup.js b/packages/safe-chain/src/shell-integration/setup.js index 792aa3c..9a37f7c 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(); From bbc111c5779b89e945ca761c27bb25109f91860a Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 19 Sep 2025 11:56:05 +0200 Subject: [PATCH 2/7] Add some unit tests on setup-ci --- .../src/shell-integration/setup-ci.spec.js | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 packages/safe-chain/src/shell-integration/setup-ci.spec.js 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 From 3675a58636372cadce717670dd33f9e8d683d0f1 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 19 Sep 2025 13:11:13 +0200 Subject: [PATCH 3/7] Add npm, pnpm and yarn tests for PATH integration --- test/e2e/npm-ci.e2e.spec.js | 103 +++++++++++++++++++++++++++++ test/e2e/pnpm-ci.e2e.spec.js | 123 +++++++++++++++++++++++++++++++++++ test/e2e/yarn-ci.e2e.spec.js | 85 ++++++++++++++++++++++++ 3 files changed, 311 insertions(+) create mode 100644 test/e2e/npm-ci.e2e.spec.js create mode 100644 test/e2e/pnpm-ci.e2e.spec.js create mode 100644 test/e2e/yarn-ci.e2e.spec.js 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/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}` + ); + }); +}); From 7f8bc4763d3d1e7d709c165f2f7ab0460c4ef29c Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Fri, 19 Sep 2025 14:16:18 +0200 Subject: [PATCH 4/7] Add e2e tests for setup-ci command --- test/e2e/setup-ci.e2e.spec.js | 82 +++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 test/e2e/setup-ci.e2e.spec.js 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}` + ); + }); +}); From f2fd82aa932e30f6bf8f9b8bc8a37bcde1ccaae3 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 23 Sep 2025 13:36:35 +0200 Subject: [PATCH 5/7] Docment CI/CD implementation --- README.md | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 45317a0..26aa2ce 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-1.0.0.tgz + 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-1.0.0.tgz + 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. From 61d940696e364e0ba2b1d9a49ed38efe196fef78 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Tue, 23 Sep 2025 13:39:33 +0200 Subject: [PATCH 6/7] Use production package name in documentation --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 26aa2ce..b1accef 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ This automatically configures your CI environment to use Aikido Safe Chain for a - name: Setup safe-chain run: | - npm i -g ./aikidosec-safe-chain-1.0.0.tgz + npm i -g @aikidosec/safe-chain safe-chain setup-ci - name: Install dependencies @@ -135,7 +135,7 @@ This automatically configures your CI environment to use Aikido Safe Chain for a displayName: "Install Node.js" - script: | - npm i -g ./aikidosec-safe-chain-1.0.0.tgz + npm i -g @aikidosec/safe-chain safe-chain setup-ci displayName: "Install safe chain" From e38dcc1ea8dc73e1ff51ddf578b391a34c13dfe9 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 24 Sep 2025 14:35:48 +0200 Subject: [PATCH 7/7] Clarify how path is modified in Azure Pipelines with a comment --- packages/safe-chain/src/shell-integration/setup-ci.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/safe-chain/src/shell-integration/setup-ci.js b/packages/safe-chain/src/shell-integration/setup-ci.js index 2338894..0449ac4 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.js @@ -114,8 +114,10 @@ function modifyPathForCi(shimsDir) { ); } - // detect azure pipelines 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); } }