Some cleanup

This commit is contained in:
Reinier Criel 2026-04-10 14:08:59 -07:00
parent 24af6f21eb
commit b0f392522b
19 changed files with 286 additions and 8 deletions

View file

@ -316,6 +316,19 @@ The base URL should point to a server that mirrors the structure of `https://mal
- `/releases/npm.json` (JavaScript new packages list)
- `/releases/pypi.json` (Python new packages list)
## Custom Install Directory
By default, Safe Chain installs itself into `~/.safe-chain`. You can change this by setting `SAFE_CHAIN_DIR` before running the installer. This is useful for system-wide installations (e.g. inside a Docker image) or when you need to avoid conflicts with other tools.
When set, all Safe Chain data (binary, shims, scripts) is placed under the custom directory instead of `~/.safe-chain`.
```shell
export SAFE_CHAIN_DIR=/usr/local/.safe-chain
curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh
```
> **Note:** CLI argument and config file options are not supported for `SAFE_CHAIN_DIR`. The config file lives inside the Safe Chain directory itself, creating a chicken-and-egg problem, and passing a directory path as a flag to package manager commands (e.g. `npm install express --safe-chain-dir=...`) does not make sense.
# Usage in CI/CD
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.
@ -406,6 +419,7 @@ pipeline {
environment {
// Jenkins does not automatically persist PATH updates from setup-ci,
// so add the shims + binary directory explicitly for all stages.
// If you set SAFE_CHAIN_DIR, replace ~/.safe-chain with that path here.
PATH = "${env.HOME}/.safe-chain/shims:${env.HOME}/.safe-chain/bin:${env.PATH}"
}
@ -461,7 +475,7 @@ To add safe-chain in GitLab pipelines, you need to install it in the image runni
# Install safe-chain
RUN curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
# Add safe-chain to PATH
# Add safe-chain to PATH (update paths if you set SAFE_CHAIN_DIR during install)
ENV PATH="/root/.safe-chain/shims:/root/.safe-chain/bin:${PATH}"
```

View file

@ -8,7 +8,8 @@ param(
)
$Version = $env:SAFE_CHAIN_VERSION # Will be fetched from latest release if not set
$InstallDir = Join-Path $env:USERPROFILE ".safe-chain\bin"
$SafeChainBase = if ($env:SAFE_CHAIN_DIR) { $env:SAFE_CHAIN_DIR } else { Join-Path $env:USERPROFILE ".safe-chain" }
$InstallDir = Join-Path $SafeChainBase "bin"
$RepoUrl = "https://github.com/AikidoSec/safe-chain"
# Ensure TLS 1.2 is enabled for downloads

View file

@ -8,7 +8,8 @@ set -e # Exit on error
# Configuration
VERSION="${SAFE_CHAIN_VERSION:-}" # Will be fetched from latest release if not set
INSTALL_DIR="${HOME}/.safe-chain/bin"
SAFE_CHAIN_BASE="${SAFE_CHAIN_DIR:-${HOME}/.safe-chain}"
INSTALL_DIR="${SAFE_CHAIN_BASE}/bin"
REPO_URL="https://github.com/AikidoSec/safe-chain"
# Colors for output

View file

@ -4,7 +4,7 @@
# Use HOME on Unix, USERPROFILE on Windows (PowerShell Core is cross-platform)
$HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE }
$DotSafeChain = Join-Path $HomeDir ".safe-chain"
$DotSafeChain = if ($env:SAFE_CHAIN_DIR) { $env:SAFE_CHAIN_DIR } else { Join-Path $HomeDir ".safe-chain" }
$InstallDir = Join-Path $DotSafeChain "bin"
# Helper functions

View file

@ -7,7 +7,7 @@
set -e # Exit on error
# Configuration
DOT_SAFE_CHAIN="${HOME}/.safe-chain"
DOT_SAFE_CHAIN="${SAFE_CHAIN_DIR:-${HOME}/.safe-chain}"
# Colors for output
RED='\033[0;31m'
@ -163,6 +163,7 @@ main() {
else
info "Installation directory $DOT_SAFE_CHAIN does not exist. Nothing to remove."
fi
}
main "$@"

View file

@ -3,6 +3,7 @@ import path from "path";
import os from "os";
import { ui } from "../environment/userInteraction.js";
import { getEcoSystem } from "./settings.js";
import { getSafeChainDir } from "./environmentVariables.js";
/**
* @typedef {Object} SafeChainConfig
@ -304,8 +305,7 @@ function getConfigFilePath() {
* @returns {string}
*/
export function getSafeChainDirectory() {
const homeDir = os.homedir();
const safeChainDir = path.join(homeDir, ".safe-chain");
const safeChainDir = getSafeChainDir() ?? path.join(os.homedir(), ".safe-chain");
if (!fs.existsSync(safeChainDir)) {
fs.mkdirSync(safeChainDir, { recursive: true });

View file

@ -4,6 +4,7 @@ import fs from "fs";
import path from "path";
import { ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js";
import { getSafeChainDir } from "../config/environmentVariables.js";
export { getSafeChainDir };
import { safeSpawn } from "../utils/safeSpawn.js";
import { ui } from "../environment/userInteraction.js";

View file

@ -122,7 +122,6 @@ function copyStartupFiles() {
fs.mkdirSync(targetDir, { recursive: true });
}
// Use absolute path for source
const sourcePath = path.join(dirname, "startup-scripts", file);
fs.copyFileSync(sourcePath, targetPath);
}

View file

@ -3,6 +3,7 @@ import {
doesExecutableExistOnSystem,
removeLinesMatchingPattern,
getScriptsDir,
getSafeChainDir,
} from "../helpers.js";
import { execSync, spawnSync } from "child_process";
import * as os from "os";
@ -41,12 +42,27 @@ function teardown(tools) {
eol
);
removeLinesMatchingPattern(
startupFile,
/^export\s+SAFE_CHAIN_DIR=.*#\s*Safe-chain/,
eol
);
return true;
}
function setup() {
const startupFile = getStartupFile();
const customDir = getSafeChainDir();
if (customDir) {
addLineToFile(
startupFile,
`export SAFE_CHAIN_DIR="${customDir}" # Safe-chain installation directory`,
eol
);
}
addLineToFile(
startupFile,
`source ${path.join(getScriptsDir(), "init-posix.sh")} # Safe-chain bash initialization script`,

View file

@ -10,6 +10,7 @@ describe("Bash shell integration", () => {
let bash;
let windowsCygwinPath = "";
let platform = "linux";
let getSafeChainDirResult = undefined;
beforeEach(async () => {
// Create temporary startup file for testing
@ -20,6 +21,7 @@ describe("Bash shell integration", () => {
namedExports: {
doesExecutableExistOnSystem: () => true,
getScriptsDir: () => "/test-home/.safe-chain/scripts",
getSafeChainDir: () => getSafeChainDirResult,
addLineToFile: (filePath, line) => {
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, "", "utf-8");
@ -89,6 +91,7 @@ describe("Bash shell integration", () => {
// Reset mocks
mock.reset();
platform = "linux";
getSafeChainDirResult = undefined;
});
describe("isInstalled", () => {
@ -200,6 +203,40 @@ describe("Bash shell integration", () => {
});
});
describe("SAFE_CHAIN_DIR", () => {
it("should write export line to rc file when custom dir is set", () => {
getSafeChainDirResult = "/custom/safe-chain";
bash.setup();
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes('export SAFE_CHAIN_DIR="/custom/safe-chain" # Safe-chain installation directory')
);
});
it("should not write export line when no custom dir is set", () => {
getSafeChainDirResult = undefined;
bash.setup();
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("SAFE_CHAIN_DIR"));
});
it("should remove export line on teardown", () => {
const initialContent = [
'#!/bin/bash',
'export SAFE_CHAIN_DIR="/custom/safe-chain" # Safe-chain installation directory',
'source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script',
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
bash.teardown(knownAikidoTools);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("SAFE_CHAIN_DIR"));
});
});
describe("integration tests", () => {
it("should handle complete setup and teardown cycle", () => {
const tools = [

View file

@ -3,6 +3,7 @@ import {
doesExecutableExistOnSystem,
removeLinesMatchingPattern,
getScriptsDir,
getSafeChainDir,
} from "../helpers.js";
import { execSync } from "child_process";
import path from "path";
@ -40,12 +41,27 @@ function teardown(tools) {
eol
);
removeLinesMatchingPattern(
startupFile,
/^set\s+-gx\s+SAFE_CHAIN_DIR\s+.*#\s*Safe-chain/,
eol
);
return true;
}
function setup() {
const startupFile = getStartupFile();
const customDir = getSafeChainDir();
if (customDir) {
addLineToFile(
startupFile,
`set -gx SAFE_CHAIN_DIR "${customDir}" # Safe-chain installation directory`,
eol
);
}
addLineToFile(
startupFile,
`source ${path.join(getScriptsDir(), "init-fish.fish")} # Safe-chain Fish initialization script`,

View file

@ -8,6 +8,7 @@ import { knownAikidoTools } from "../helpers.js";
describe("Fish shell integration", () => {
let mockStartupFile;
let fish;
let getSafeChainDirResult = undefined;
beforeEach(async () => {
// Create temporary startup file for testing
@ -18,6 +19,7 @@ describe("Fish shell integration", () => {
namedExports: {
doesExecutableExistOnSystem: () => true,
getScriptsDir: () => "/test-home/.safe-chain/scripts",
getSafeChainDir: () => getSafeChainDirResult,
addLineToFile: (filePath, line) => {
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, "", "utf-8");
@ -53,6 +55,7 @@ describe("Fish shell integration", () => {
// Reset mocks
mock.reset();
getSafeChainDirResult = undefined;
});
describe("isInstalled", () => {
@ -153,6 +156,39 @@ describe("Fish shell integration", () => {
});
});
describe("SAFE_CHAIN_DIR", () => {
it("should write set line to config file when custom dir is set", () => {
getSafeChainDirResult = "/custom/safe-chain";
fish.setup();
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes('set -gx SAFE_CHAIN_DIR "/custom/safe-chain" # Safe-chain installation directory')
);
});
it("should not write set line when no custom dir is set", () => {
getSafeChainDirResult = undefined;
fish.setup();
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("SAFE_CHAIN_DIR"));
});
it("should remove set line on teardown", () => {
const initialContent = [
'set -gx SAFE_CHAIN_DIR "/custom/safe-chain" # Safe-chain installation directory',
"source /test-home/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
fish.teardown(knownAikidoTools);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("SAFE_CHAIN_DIR"));
});
});
describe("integration tests", () => {
it("should handle complete setup and teardown cycle", () => {
const tools = [

View file

@ -4,6 +4,7 @@ import {
removeLinesMatchingPattern,
validatePowerShellExecutionPolicy,
getScriptsDir,
getSafeChainDir,
} from "../helpers.js";
import { execSync } from "child_process";
import path from "path";
@ -38,6 +39,11 @@ function teardown(tools) {
/^\.\s+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/,
);
removeLinesMatchingPattern(
startupFile,
/^\$env:SAFE_CHAIN_DIR\s*=.*#\s*Safe-chain/,
);
return true;
}
@ -52,6 +58,14 @@ async function setup() {
const startupFile = getStartupFile();
const customDir = getSafeChainDir();
if (customDir) {
addLineToFile(
startupFile,
`$env:SAFE_CHAIN_DIR = '${customDir}' # Safe-chain installation directory`,
);
}
addLineToFile(
startupFile,
`. "${path.join(getScriptsDir(), "init-pwsh.ps1")}" # Safe-chain PowerShell initialization script`,

View file

@ -9,6 +9,7 @@ describe("PowerShell Core shell integration", () => {
let mockStartupFile;
let powershell;
let executionPolicyResult;
let getSafeChainDirResult = undefined;
beforeEach(async () => {
// Create temporary startup file for testing
@ -26,6 +27,7 @@ describe("PowerShell Core shell integration", () => {
mock.module("../helpers.js", {
namedExports: {
doesExecutableExistOnSystem: () => true,
getSafeChainDir: () => getSafeChainDirResult,
addLineToFile: (filePath, line) => {
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, "", "utf-8");
@ -63,6 +65,7 @@ describe("PowerShell Core shell integration", () => {
// Reset mocks
mock.reset();
getSafeChainDirResult = undefined;
});
describe("isInstalled", () => {
@ -206,6 +209,40 @@ describe("PowerShell Core shell integration", () => {
});
});
describe("SAFE_CHAIN_DIR", () => {
it("should write $env:SAFE_CHAIN_DIR line to profile when custom dir is set", async () => {
getSafeChainDirResult = "C:\\custom\\safe-chain";
await powershell.setup();
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes("$env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain' # Safe-chain installation directory")
);
});
it("should not write $env:SAFE_CHAIN_DIR line when no custom dir is set", async () => {
getSafeChainDirResult = undefined;
await powershell.setup();
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("SAFE_CHAIN_DIR"));
});
it("should remove $env:SAFE_CHAIN_DIR line on teardown", () => {
const initialContent = [
"# PowerShell profile",
"$env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain' # Safe-chain installation directory",
'. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script',
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
powershell.teardown(knownAikidoTools);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("SAFE_CHAIN_DIR"));
});
});
describe("execution policy", () => {
it(`should throw for restricted policies`, async () => {
executionPolicyResult = {

View file

@ -4,6 +4,7 @@ import {
removeLinesMatchingPattern,
validatePowerShellExecutionPolicy,
getScriptsDir,
getSafeChainDir,
} from "../helpers.js";
import { execSync } from "child_process";
import path from "path";
@ -38,6 +39,11 @@ function teardown(tools) {
/^\.\s+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/,
);
removeLinesMatchingPattern(
startupFile,
/^\$env:SAFE_CHAIN_DIR\s*=.*#\s*Safe-chain/,
);
return true;
}
@ -52,6 +58,14 @@ async function setup() {
const startupFile = getStartupFile();
const customDir = getSafeChainDir();
if (customDir) {
addLineToFile(
startupFile,
`$env:SAFE_CHAIN_DIR = '${customDir}' # Safe-chain installation directory`,
);
}
addLineToFile(
startupFile,
`. "${path.join(getScriptsDir(), "init-pwsh.ps1")}" # Safe-chain PowerShell initialization script`,

View file

@ -9,6 +9,7 @@ describe("Windows PowerShell shell integration", () => {
let mockStartupFile;
let windowsPowershell;
let executionPolicyResult;
let getSafeChainDirResult = undefined;
beforeEach(async () => {
// Create temporary startup file for testing
@ -26,6 +27,7 @@ describe("Windows PowerShell shell integration", () => {
mock.module("../helpers.js", {
namedExports: {
doesExecutableExistOnSystem: () => true,
getSafeChainDir: () => getSafeChainDirResult,
addLineToFile: (filePath, line) => {
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, "", "utf-8");
@ -63,6 +65,7 @@ describe("Windows PowerShell shell integration", () => {
// Reset mocks
mock.reset();
getSafeChainDirResult = undefined;
});
describe("isInstalled", () => {
@ -206,6 +209,40 @@ describe("Windows PowerShell shell integration", () => {
});
});
describe("SAFE_CHAIN_DIR", () => {
it("should write $env:SAFE_CHAIN_DIR line to profile when custom dir is set", async () => {
getSafeChainDirResult = "C:\\custom\\safe-chain";
await windowsPowershell.setup();
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes("$env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain' # Safe-chain installation directory")
);
});
it("should not write $env:SAFE_CHAIN_DIR line when no custom dir is set", async () => {
getSafeChainDirResult = undefined;
await windowsPowershell.setup();
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("SAFE_CHAIN_DIR"));
});
it("should remove $env:SAFE_CHAIN_DIR line on teardown", () => {
const initialContent = [
"# Windows PowerShell profile",
"$env:SAFE_CHAIN_DIR = 'C:\\custom\\safe-chain' # Safe-chain installation directory",
'. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script',
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
windowsPowershell.teardown(knownAikidoTools);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("SAFE_CHAIN_DIR"));
});
});
describe("execution policy", () => {
it(`should throw for restricted policies`, async () => {
executionPolicyResult = {

View file

@ -3,6 +3,7 @@ import {
doesExecutableExistOnSystem,
removeLinesMatchingPattern,
getScriptsDir,
getSafeChainDir,
} from "../helpers.js";
import { execSync } from "child_process";
import path from "path";
@ -40,12 +41,27 @@ function teardown(tools) {
eol
);
removeLinesMatchingPattern(
startupFile,
/^export\s+SAFE_CHAIN_DIR=.*#\s*Safe-chain/,
eol
);
return true;
}
function setup() {
const startupFile = getStartupFile();
const customDir = getSafeChainDir();
if (customDir) {
addLineToFile(
startupFile,
`export SAFE_CHAIN_DIR="${customDir}" # Safe-chain installation directory`,
eol
);
}
addLineToFile(
startupFile,
`source ${path.join(getScriptsDir(), "init-posix.sh")} # Safe-chain Zsh initialization script`,

View file

@ -8,6 +8,7 @@ import { knownAikidoTools } from "../helpers.js";
describe("Zsh shell integration", () => {
let mockStartupFile;
let zsh;
let getSafeChainDirResult = undefined;
beforeEach(async () => {
// Create temporary startup file for testing
@ -18,6 +19,7 @@ describe("Zsh shell integration", () => {
namedExports: {
doesExecutableExistOnSystem: () => true,
getScriptsDir: () => "/test-home/.safe-chain/scripts",
getSafeChainDir: () => getSafeChainDirResult,
addLineToFile: (filePath, line) => {
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, "", "utf-8");
@ -53,6 +55,7 @@ describe("Zsh shell integration", () => {
// Reset mocks
mock.reset();
getSafeChainDirResult = undefined;
});
describe("isInstalled", () => {
@ -171,6 +174,40 @@ describe("Zsh shell integration", () => {
});
});
describe("SAFE_CHAIN_DIR", () => {
it("should write export line to rc file when custom dir is set", () => {
getSafeChainDirResult = "/custom/safe-chain";
zsh.setup();
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(
content.includes('export SAFE_CHAIN_DIR="/custom/safe-chain" # Safe-chain installation directory')
);
});
it("should not write export line when no custom dir is set", () => {
getSafeChainDirResult = undefined;
zsh.setup();
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("SAFE_CHAIN_DIR"));
});
it("should remove export line on teardown", () => {
const initialContent = [
"#!/bin/zsh",
'export SAFE_CHAIN_DIR="/custom/safe-chain" # Safe-chain installation directory',
"source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script",
].join("\n");
fs.writeFileSync(mockStartupFile, initialContent, "utf-8");
zsh.teardown(knownAikidoTools);
const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("SAFE_CHAIN_DIR"));
});
});
describe("integration tests", () => {
it("should handle complete setup and teardown cycle", () => {
const tools = [

View file

@ -109,4 +109,5 @@ export async function teardownDirectories() {
);
}
}
}