Merge pull request #70 from AikidoSec/non-interactive-terminal-support

Support for CI/CD
This commit is contained in:
Sander Declerck 2025-09-24 15:37:36 +02:00 committed by GitHub
commit cea4507559
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 797 additions and 5 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

103
test/e2e/npm-ci.e2e.spec.js Normal file
View file

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

View file

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

View file

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

View file

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