mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Merge pull request #70 from AikidoSec/non-interactive-terminal-support
Support for CI/CD
This commit is contained in:
commit
cea4507559
13 changed files with 797 additions and 5 deletions
58
README.md
58
README.md
|
|
@ -88,4 +88,60 @@ npm install suspicious-package --safe-chain-malware-action=prompt
|
||||||
|
|
||||||
# Usage in CI/CD
|
# 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.
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import chalk from "chalk";
|
||||||
import { ui } from "../src/environment/userInteraction.js";
|
import { ui } from "../src/environment/userInteraction.js";
|
||||||
import { setup } from "../src/shell-integration/setup.js";
|
import { setup } from "../src/shell-integration/setup.js";
|
||||||
import { teardown } from "../src/shell-integration/teardown.js";
|
import { teardown } from "../src/shell-integration/teardown.js";
|
||||||
|
import { setupCi } from "../src/shell-integration/setup-ci.js";
|
||||||
|
|
||||||
if (process.argv.length < 3) {
|
if (process.argv.length < 3) {
|
||||||
ui.writeError("No command provided. Please provide a command to execute.");
|
ui.writeError("No command provided. Please provide a command to execute.");
|
||||||
|
|
@ -23,6 +24,8 @@ if (command === "setup") {
|
||||||
setup();
|
setup();
|
||||||
} else if (command === "teardown") {
|
} else if (command === "teardown") {
|
||||||
teardown();
|
teardown();
|
||||||
|
} else if (command === "setup-ci") {
|
||||||
|
setupCi();
|
||||||
} else {
|
} else {
|
||||||
ui.writeError(`Unknown command: ${command}.`);
|
ui.writeError(`Unknown command: ${command}.`);
|
||||||
ui.emptyLine();
|
ui.emptyLine();
|
||||||
|
|
@ -53,5 +56,10 @@ function writeHelp() {
|
||||||
"safe-chain teardown"
|
"safe-chain teardown"
|
||||||
)}: This will remove safe-chain aliases from your shell configuration.`
|
)}: 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();
|
ui.emptyLine();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,22 @@ export const knownAikidoTools = [
|
||||||
// and add the documentation for the new tool in the README.md
|
// 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) {
|
export function doesExecutableExistOnSystem(executableName) {
|
||||||
if (os.platform() === "win32") {
|
if (os.platform() === "win32") {
|
||||||
const result = spawnSync("where", [executableName], { stdio: "ignore" });
|
const result = spawnSync("where", [executableName], { stdio: "ignore" });
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
123
packages/safe-chain/src/shell-integration/setup-ci.js
Normal file
123
packages/safe-chain/src/shell-integration/setup-ci.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
150
packages/safe-chain/src/shell-integration/setup-ci.spec.js
Normal file
150
packages/safe-chain/src/shell-integration/setup-ci.spec.js
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { ui } from "../environment/userInteraction.js";
|
import { ui } from "../environment/userInteraction.js";
|
||||||
import { detectShells } from "./shellDetection.js";
|
import { detectShells } from "./shellDetection.js";
|
||||||
import { knownAikidoTools } from "./helpers.js";
|
import { knownAikidoTools, getPackageManagerList } from "./helpers.js";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
@ -13,7 +13,7 @@ import { fileURLToPath } from "url";
|
||||||
export async function setup() {
|
export async function setup() {
|
||||||
ui.writeInformation(
|
ui.writeInformation(
|
||||||
chalk.bold("Setting up shell aliases.") +
|
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();
|
ui.emptyLine();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { ui } from "../environment/userInteraction.js";
|
import { ui } from "../environment/userInteraction.js";
|
||||||
import { detectShells } from "./shellDetection.js";
|
import { detectShells } from "./shellDetection.js";
|
||||||
import { knownAikidoTools } from "./helpers.js";
|
import { knownAikidoTools, getPackageManagerList } from "./helpers.js";
|
||||||
|
|
||||||
export async function teardown() {
|
export async function teardown() {
|
||||||
ui.writeInformation(
|
ui.writeInformation(
|
||||||
chalk.bold("Removing shell aliases.") +
|
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();
|
ui.emptyLine();
|
||||||
|
|
||||||
|
|
|
||||||
103
test/e2e/npm-ci.e2e.spec.js
Normal file
103
test/e2e/npm-ci.e2e.spec.js
Normal 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}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
123
test/e2e/pnpm-ci.e2e.spec.js
Normal file
123
test/e2e/pnpm-ci.e2e.spec.js
Normal 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}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
82
test/e2e/setup-ci.e2e.spec.js
Normal file
82
test/e2e/setup-ci.e2e.spec.js
Normal 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}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
85
test/e2e/yarn-ci.e2e.spec.js
Normal file
85
test/e2e/yarn-ci.e2e.spec.js
Normal 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}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue