This commit is contained in:
Reinier Criel 2025-11-18 13:08:05 -08:00
parent 6b208a8730
commit 4c64dcf201
13 changed files with 1410 additions and 15 deletions

View file

@ -6,6 +6,7 @@ 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";
import { runCommand } from "../src/agent/runCommand.js";
if (process.argv.length < 3) {
ui.writeError("No command provided. Please provide a command to execute.");
@ -27,6 +28,10 @@ if (command === "setup") {
teardown();
} else if (command === "setup-ci") {
setupCi();
} else if (command === "run") {
// Pass remaining arguments to runCommand
const runArgs = process.argv.slice(3);
runCommand(runArgs);
} else if (command === "--version" || command === "-v" || command === "-v") {
ui.writeInformation(`Current safe-chain version: ${getVersion()}`);
} else {
@ -46,9 +51,9 @@ function writeHelp() {
ui.writeInformation(
`Available commands: ${chalk.cyan("setup")}, ${chalk.cyan(
"teardown"
)}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("help")}, ${chalk.cyan(
"--version"
)}`
)}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("run")}, ${chalk.cyan(
"help"
)}, ${chalk.cyan("--version")}`
);
ui.emptyLine();
ui.writeInformation(
@ -66,6 +71,11 @@ function writeHelp() {
"safe-chain setup-ci"
)}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.`
);
ui.writeInformation(
`- ${chalk.cyan(
"safe-chain run"
)}: Run the proxy as a standalone service. Options: --all (default), --js, --py, --ecosystem=<type>`
);
ui.writeInformation(
`- ${chalk.cyan(
"safe-chain --version"

View file

@ -28,6 +28,9 @@
},
"./scanning": {
"default": "./src/scanning/audit/index.js"
},
"./agent": {
"default": "./src/agent/standaloneProxy.js"
}
},
"keywords": [],

View file

@ -0,0 +1,82 @@
import fs from "fs";
import path from "path";
import os from "os";
/**
* Get the path to the proxy state file
* @returns {string}
*/
export function getProxyStateFilePath() {
const homeDir = os.homedir();
const safeChainDir = path.join(homeDir, ".safe-chain");
// Ensure directory exists
if (!fs.existsSync(safeChainDir)) {
fs.mkdirSync(safeChainDir, { recursive: true });
}
return path.join(safeChainDir, "proxy-state.json");
}
/**
* Write the proxy state to a file that shell scripts can read
* @param {{port: number, url: string, pid: number, ecosystem: string, certPath: string}} state
*/
export function writeProxyState(state) {
const statePath = getProxyStateFilePath();
fs.writeFileSync(statePath, JSON.stringify(state, null, 2), "utf-8");
}
/**
* Read the current proxy state
* @returns {{port: number, url: string, pid: number, ecosystem: string, certPath: string} | null}
*/
export function readProxyState() {
const statePath = getProxyStateFilePath();
if (!fs.existsSync(statePath)) {
return null;
}
try {
const content = fs.readFileSync(statePath, "utf-8");
const state = JSON.parse(content);
// Verify the process is still running
if (state.pid) {
try {
// Sending signal 0 checks if process exists without actually sending a signal
process.kill(state.pid, 0);
return state;
} catch {
// Process doesn't exist, clean up state file
clearProxyState();
return null;
}
}
return state;
} catch {
return null;
}
}
/**
* Clear the proxy state file
*/
export function clearProxyState() {
const statePath = getProxyStateFilePath();
if (fs.existsSync(statePath)) {
fs.unlinkSync(statePath);
}
}
/**
* Check if a proxy is currently running
* @returns {boolean}
*/
export function isProxyRunning() {
const state = readProxyState();
return state !== null;
}

View file

@ -0,0 +1,132 @@
import { StandaloneProxyService } from "./standaloneProxy.js";
import { ui } from "../environment/userInteraction.js";
import chalk from "chalk";
import { initializeCliArguments } from "../config/cliArguments.js";
import { writeProxyState, clearProxyState } from "./proxyState.js";
import { getCaCertPath } from "../registryProxy/certUtils.js";
import { setup } from "../shell-integration/setup.js";
import { teardown } from "../shell-integration/teardown.js";
/**
* Run the Safe Chain proxy as a standalone service
* @param {string[]} args - Command line arguments
*/
export async function runCommand(args) {
// Agent mode automatically supports all package managers
// No need to specify ecosystem - it's determined by the URL being proxied
// Convert --verbose to safe-chain argument format
const processedArgs = args.map(arg => {
if (arg === "--verbose" || arg === "-v") {
return "--safe-chain-logging=verbose";
}
return arg;
});
// Initialize logging from args
initializeCliArguments(processedArgs);
// Automatically set up shell integration
await setup();
ui.emptyLine();
const service = new StandaloneProxyService({
autoVerify: false
});
// Setup event listeners
service.on("started", ({ port, url }) => {
// Write proxy state to file so shell integration can detect it
writeProxyState({
port,
url,
pid: process.pid,
ecosystem: 'all',
certPath: getCaCertPath(),
});
ui.emptyLine();
ui.writeInformation(chalk.green("✔") + " Safe Chain proxy started successfully!");
ui.emptyLine();
ui.writeInformation(chalk.bold("Proxy Information:"));
ui.writeInformation(` Port: ${chalk.cyan(port)}`);
ui.writeInformation(` URL: ${chalk.cyan(url)}`);
ui.writeInformation(` PID: ${chalk.cyan(process.pid)}`);
ui.emptyLine();
ui.writeInformation(chalk.bold("How to Use:"));
ui.writeInformation(chalk.dim(" Restart your terminal, then run package managers normally:"));
ui.writeInformation(chalk.cyan(" npm install <package>"));
ui.writeInformation(chalk.cyan(" yarn add <package>"));
ui.writeInformation(chalk.cyan(" pip3 install <package>"));
ui.emptyLine();
ui.writeInformation(
chalk.dim("Press Ctrl+C to stop the proxy")
);
});
service.on("stopped", ({ blockedPackages }) => {
// Clear proxy state file
clearProxyState();
ui.emptyLine();
ui.writeInformation(chalk.yellow("Proxy stopped."));
if (blockedPackages.length > 0) {
ui.emptyLine();
ui.writeInformation(
chalk.red(`⚠ Blocked ${blockedPackages.length} malicious package(s):`)
);
for (const pkg of blockedPackages) {
ui.writeInformation(
` - ${chalk.bold(pkg.packageName)}@${pkg.version}`
);
}
} else {
ui.writeInformation(chalk.green("No malicious packages detected."));
}
ui.emptyLine();
});
// Handle graceful shutdown
let isShuttingDown = false;
const shutdown = async () => {
if (isShuttingDown) {
return;
}
isShuttingDown = true;
ui.emptyLine();
ui.writeInformation(chalk.yellow("Shutting down proxy..."));
try {
await service.stop();
// Remove shell integration
ui.emptyLine();
await teardown();
process.exit(0);
} catch (/** @type {any} */ error) {
ui.writeError(`Error stopping proxy: ${error.message}`);
process.exit(1);
}
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
// Start the service
try {
await service.start();
// Keep the process running
// The proxy will continue to intercept requests until interrupted
await new Promise(() => {}); // Never resolves - keeps process alive
} catch (/** @type {any} */ error) {
ui.writeError(`Failed to start proxy: ${error.message}`);
process.exit(1);
}
}

View file

@ -0,0 +1,176 @@
import { EventEmitter } from "node:events";
import { createSafeChainProxy } from "../registryProxy/registryProxy.js";
/**
* @typedef {Object} StandaloneProxyOptions
* @property {(event: MalwareBlockedEvent) => void} [onMalwareDetected] - Callback when malware is detected
* @property {boolean} [autoVerify=false] - Automatically verify for malicious packages on stop
* @property {boolean} [keepAlive=true] - Keep the process alive while proxy is running (disable for tests)
*/
/**
* @typedef {Object} MalwareBlockedEvent
* @property {string} packageName - Name of the blocked package
* @property {string} version - Version of the blocked package
* @property {string} url - URL that was blocked
*/
/**
* @typedef {Object} ProxyInfo
* @property {number} port - Port number the proxy is listening on
* @property {string} url - Full proxy URL (http://localhost:port)
* @property {Record<string, string>} environmentVariables - Environment variables to set for clients
*/
/**
* Standalone proxy service for running the Safe Chain proxy as a long-lived service
* or agent, independent of CLI usage. Suitable for integration with tools like VS Code
* extensions or other development environments.
*
* The agent mode automatically supports all package managers (npm, yarn, pnpm, pip, etc.)
* without needing to specify an ecosystem.
*
* @example
* const service = new StandaloneProxyService();
* const info = await service.start();
* console.log(`Proxy running on port ${info.port}`);
* // ... later
* await service.stop();
*/
export class StandaloneProxyService extends EventEmitter {
/**
* @param {StandaloneProxyOptions} [options={}]
*/
constructor(options = {}) {
super();
this.options = {
onMalwareDetected: options.onMalwareDetected,
autoVerify: options.autoVerify || false,
keepAlive: options.keepAlive !== undefined ? options.keepAlive : true,
};
this.proxy = null;
this.isRunning = false;
}
/**
* Start the proxy server
* @returns {Promise<ProxyInfo>}
* @throws {Error} If proxy is already running or fails to start
*/
async start() {
if (this.isRunning) {
throw new Error("Proxy is already running");
}
// Agent mode always supports all package managers
// The interceptor will automatically try both npm and pip based on the URL
// No need to set a specific ecosystem
this.proxy = createSafeChainProxy();
this.proxy.setKeepAlive(this.options.keepAlive);
await this.proxy.startServer();
this.isRunning = true;
const port = this.proxy.getPort();
const url = this.proxy.getProxyUrl();
const environmentVariables = this.proxy.getEnvironmentVariables();
if (!port || !url) {
throw new Error("Failed to start proxy server: no port assigned");
}
// Emit started event
this.emit("started", { port, url, environmentVariables });
return {
port,
url,
environmentVariables,
};
}
/**
* Stop the proxy server
* @returns {Promise<{blockedPackages: MalwareBlockedEvent[]}>}
* @throws {Error} If proxy is not running
*/
async stop() {
if (!this.isRunning || !this.proxy) {
throw new Error("Proxy is not running");
}
const blockedRequests = this.proxy.getBlockedRequests();
// If autoVerify is enabled, check for malicious packages
if (this.options.autoVerify) {
const hasNoMalware = this.proxy.verifyNoMaliciousPackages();
if (!hasNoMalware) {
this.emit("malwareDetected", blockedRequests);
}
}
await this.proxy.stopServer();
this.isRunning = false;
// Emit stopped event
this.emit("stopped", { blockedPackages: blockedRequests });
return {
blockedPackages: blockedRequests,
};
}
/**
* Get current proxy information
* @returns {ProxyInfo | null}
*/
getInfo() {
if (!this.isRunning || !this.proxy) {
return null;
}
const port = this.proxy.getPort();
const url = this.proxy.getProxyUrl();
if (!port || !url) {
return null;
}
return {
port,
url,
environmentVariables: this.proxy.getEnvironmentVariables(),
};
}
/**
* Get list of blocked requests (if any)
* @returns {MalwareBlockedEvent[]}
*/
getBlockedRequests() {
if (!this.proxy) {
return [];
}
return this.proxy.getBlockedRequests();
}
/**
* Check if the proxy is currently running
* @returns {boolean}
*/
isProxyRunning() {
return this.isRunning;
}
/**
* Restart the proxy server
* @returns {Promise<ProxyInfo>}
*/
async restart() {
if (this.isRunning) {
await this.stop();
}
return await this.start();
}
}

View file

@ -0,0 +1,402 @@
import { after, describe, it } from "node:test";
import assert from "node:assert";
import { StandaloneProxyService } from "./standaloneProxy.js";
describe("StandaloneProxyService", () => {
describe("constructor", () => {
it("should create service with default options", () => {
const service = new StandaloneProxyService();
assert.strictEqual(service.isProxyRunning(), false);
assert.strictEqual(service.options.autoVerify, false);
assert.strictEqual(service.options.keepAlive, true);
// Note: No cleanup needed - service not started
});
it("should create service with custom options", () => {
const onMalwareDetected = () => {};
const service = new StandaloneProxyService({
onMalwareDetected,
autoVerify: true,
keepAlive: false,
});
assert.strictEqual(service.options.onMalwareDetected, onMalwareDetected);
assert.strictEqual(service.options.autoVerify, true);
assert.strictEqual(service.options.keepAlive, false);
});
});
describe("start", () => {
let service;
after(async () => {
try {
if (service && service.isProxyRunning()) {
await service.stop();
}
} catch {
// Ignore cleanup errors
}
});
it("should start the proxy server and return info", async () => {
service = new StandaloneProxyService({ keepAlive: false });
const info = await service.start();
assert.ok(info.port, "Should have a port");
assert.strictEqual(typeof info.port, "number");
assert.ok(info.port > 0, "Port should be positive");
assert.ok(info.url, "Should have a URL");
assert.strictEqual(info.url, `http://localhost:${info.port}`);
assert.ok(info.environmentVariables, "Should have environment variables");
assert.ok(
info.environmentVariables.HTTPS_PROXY,
"Should have HTTPS_PROXY"
);
assert.ok(
info.environmentVariables.NODE_EXTRA_CA_CERTS,
"Should have NODE_EXTRA_CA_CERTS"
);
assert.strictEqual(service.isProxyRunning(), true);
});
it("should throw error if already running", async () => {
service = new StandaloneProxyService({ keepAlive: false });
await service.start();
await assert.rejects(
async () => {
await service.start();
},
{
message: "Proxy is already running",
}
);
});
it("should emit started event", async () => {
service = new StandaloneProxyService({ keepAlive: false });
let startedEvent = null;
service.on("started", (event) => {
startedEvent = event;
});
const info = await service.start();
assert.ok(startedEvent, "Should emit started event");
assert.strictEqual(startedEvent.port, info.port);
assert.strictEqual(startedEvent.url, info.url);
assert.deepStrictEqual(
startedEvent.environmentVariables,
info.environmentVariables
);
});
});
describe("stop", () => {
let service;
after(async () => {
try {
if (service && service.isProxyRunning()) {
await service.stop();
}
} catch {
// Ignore cleanup errors
}
});
it("should stop the proxy server", async () => {
service = new StandaloneProxyService({ keepAlive: false });
await service.start();
const result = await service.stop();
assert.ok(result, "Should return result");
assert.ok(Array.isArray(result.blockedPackages));
assert.strictEqual(service.isProxyRunning(), false);
});
it("should throw error if not running", async () => {
service = new StandaloneProxyService({ keepAlive: false });
await assert.rejects(
async () => {
await service.stop();
},
{
message: "Proxy is not running",
}
);
});
it("should emit stopped event", async () => {
service = new StandaloneProxyService({ keepAlive: false });
await service.start();
let stoppedEvent = null;
service.on("stopped", (event) => {
stoppedEvent = event;
});
const result = await service.stop();
assert.ok(stoppedEvent, "Should emit stopped event");
assert.deepStrictEqual(
stoppedEvent.blockedPackages,
result.blockedPackages
);
});
it("should return blocked packages", async () => {
service = new StandaloneProxyService({ keepAlive: false });
await service.start();
const result = await service.stop();
assert.ok(Array.isArray(result.blockedPackages));
// Initially should be empty as no packages were blocked
assert.strictEqual(result.blockedPackages.length, 0);
});
});
describe("getInfo", () => {
let service;
after(async () => {
try {
if (service && service.isProxyRunning()) {
await service.stop();
}
} catch {
// Ignore cleanup errors
}
});
it("should return null when not running", () => {
service = new StandaloneProxyService({ keepAlive: false });
const info = service.getInfo();
assert.strictEqual(info, null);
});
it("should return proxy info when running", async () => {
service = new StandaloneProxyService({ keepAlive: false });
const startInfo = await service.start();
const info = service.getInfo();
assert.ok(info, "Should return info");
assert.strictEqual(info.port, startInfo.port);
assert.strictEqual(info.url, startInfo.url);
assert.deepStrictEqual(
info.environmentVariables,
startInfo.environmentVariables
);
});
});
describe("getBlockedRequests", () => {
let service;
after(async () => {
try {
if (service && service.isProxyRunning()) {
await service.stop();
}
} catch {
// Ignore cleanup errors
}
});
it("should return empty array when proxy not created", () => {
service = new StandaloneProxyService({ keepAlive: false });
const blocked = service.getBlockedRequests();
assert.ok(Array.isArray(blocked));
assert.strictEqual(blocked.length, 0);
});
it("should return blocked requests when proxy is running", async () => {
service = new StandaloneProxyService({ keepAlive: false });
await service.start();
const blocked = service.getBlockedRequests();
assert.ok(Array.isArray(blocked));
// Should be empty initially
assert.strictEqual(blocked.length, 0);
});
});
describe("isProxyRunning", () => {
let service;
after(async () => {
try {
if (service && service.isProxyRunning()) {
await service.stop();
}
} catch {
// Ignore cleanup errors
}
});
it("should return false when not started", () => {
service = new StandaloneProxyService({ keepAlive: false });
assert.strictEqual(service.isProxyRunning(), false);
});
it("should return true when running", async () => {
service = new StandaloneProxyService({ keepAlive: false });
await service.start();
assert.strictEqual(service.isProxyRunning(), true);
});
it("should return false after stopped", async () => {
service = new StandaloneProxyService({ keepAlive: false });
await service.start();
await service.stop();
assert.strictEqual(service.isProxyRunning(), false);
});
});
describe("restart", () => {
let service;
after(async () => {
try {
if (service && service.isProxyRunning()) {
await service.stop();
}
} catch {
// Ignore cleanup errors
}
});
it("should start proxy if not running", async () => {
service = new StandaloneProxyService({ keepAlive: false });
const info = await service.restart();
assert.ok(info, "Should return info");
assert.ok(info.port, "Should have a port");
assert.strictEqual(service.isProxyRunning(), true);
});
it("should restart proxy if already running", async () => {
service = new StandaloneProxyService({ keepAlive: false });
await service.start();
const newInfo = await service.restart();
assert.ok(newInfo, "Should return new info");
assert.ok(newInfo.port, "Should have a port");
assert.strictEqual(service.isProxyRunning(), true);
// Port might be different after restart
assert.strictEqual(typeof newInfo.port, "number");
});
it("should emit stopped and started events on restart", async () => {
service = new StandaloneProxyService({ keepAlive: false });
await service.start();
let stoppedEmitted = false;
let startedEmitted = false;
service.on("stopped", () => {
stoppedEmitted = true;
});
service.on("started", () => {
startedEmitted = true;
});
await service.restart();
assert.strictEqual(stoppedEmitted, true, "Should emit stopped event");
assert.strictEqual(startedEmitted, true, "Should emit started event");
});
});
describe("lifecycle events", () => {
let service;
after(async () => {
try {
if (service && service.isProxyRunning()) {
await service.stop();
}
} catch {
// Ignore cleanup errors
}
});
it("should support event emitter pattern", async () => {
service = new StandaloneProxyService({ keepAlive: false });
const events = [];
service.on("started", (data) => {
events.push({ type: "started", data });
});
service.on("stopped", (data) => {
events.push({ type: "stopped", data });
});
await service.start();
await service.stop();
assert.strictEqual(events.length, 2);
assert.strictEqual(events[0].type, "started");
assert.strictEqual(events[1].type, "stopped");
});
});
describe("multiple instances", () => {
const services = [];
after(async () => {
try {
for (const service of services) {
if (service.isProxyRunning()) {
await service.stop();
}
}
} catch {
// Ignore cleanup errors
}
});
it("should allow multiple proxy instances on different ports", async () => {
const service1 = new StandaloneProxyService({ keepAlive: false });
const service2 = new StandaloneProxyService({ keepAlive: false });
services.push(service1, service2);
const info1 = await service1.start();
const info2 = await service2.start();
assert.notStrictEqual(
info1.port,
info2.port,
"Ports should be different"
);
assert.strictEqual(service1.isProxyRunning(), true);
assert.strictEqual(service2.isProxyRunning(), true);
});
});
});

View file

@ -7,6 +7,7 @@ import { initializeCliArguments } from "./config/cliArguments.js";
import { createSafeChainProxy } from "./registryProxy/registryProxy.js";
import chalk from "chalk";
import { getAuditStats } from "./scanning/audit/index.js";
import { readProxyState } from "./agent/proxyState.js";
/**
* @param {string[]} args
@ -16,8 +17,33 @@ export async function main(args) {
process.on("SIGINT", handleProcessTermination);
process.on("SIGTERM", handleProcessTermination);
const proxy = createSafeChainProxy();
await proxy.startServer();
// Check if a proxy is already running from 'safe-chain run'
const existingProxy = readProxyState();
const usingExistingProxy = existingProxy !== null;
let proxy;
if (usingExistingProxy) {
// Use the existing proxy - don't start a new one
ui.writeInformation(`Safe-chain: Using existing proxy at ${existingProxy.url}`);
// Create a proxy object that uses the existing proxy
// We need to set the environment variables to point to the existing proxy
const url = new URL(existingProxy.url);
const port = parseInt(url.port);
// Import and set the proxy state so getSafeChainProxyEnvironmentVariables works
const { setProxyState } = await import("./registryProxy/registryProxy.js");
setProxyState(port, existingProxy.certPath);
proxy = {
verifyNoMaliciousPackages: () => true, // Existing proxy handles this
getBlockedRequests: () => [], // Can't access blocked requests from existing proxy
stopServer: async () => {}, // Don't stop the existing proxy
};
} else {
// No existing proxy, start one inline
proxy = createSafeChainProxy();
await proxy.startServer();
}
// Global error handlers to log unhandled errors
process.on("uncaughtException", (error) => {
@ -65,11 +91,19 @@ export async function main(args) {
const auditStats = getAuditStats();
if (auditStats.totalPackages > 0) {
ui.emptyLine();
ui.writeInformation(
`${chalk.green("✔")} Safe-chain: Scanned ${
auditStats.totalPackages
} packages, no malware found.`
);
if (usingExistingProxy) {
ui.writeInformation(
`${chalk.green("✔")} Safe-chain: Scanned ${
auditStats.totalPackages
} packages via proxy, no malware found.`
);
} else {
ui.writeInformation(
`${chalk.green("✔")} Safe-chain: Scanned ${
auditStats.totalPackages
} packages, no malware found.`
);
}
}
// Returning the exit code back to the caller allows the promise
@ -82,7 +116,10 @@ export async function main(args) {
// to be awaited in the bin files and return the correct exit code
return 1;
} finally {
await proxy.stopServer();
// Only stop the proxy if we started it (not using existing proxy)
if (!usingExistingProxy) {
await proxy.stopServer();
}
}
}

View file

@ -21,5 +21,8 @@ export function createInterceptorForUrl(url) {
return pipInterceptorForUrl(url);
}
return undefined;
// For agent mode or any other case, try both interceptors
// The correct one will match based on the URL
return npmInterceptorForUrl(url) || pipInterceptorForUrl(url);
}

View file

@ -9,6 +9,7 @@ import { EventEmitter } from "events";
*
* @typedef {Object} RequestInterceptionContext
* @property {string} targetUrl
* @property {(packageName: string | undefined, version: string | undefined) => void} packageChecked
* @property {(packageName: string | undefined, version: string | undefined) => void} blockMalware
* @property {() => RequestInterceptionHandler} build
*
@ -61,6 +62,20 @@ function createRequestContext(targetUrl, eventEmitter) {
/** @type {{statusCode: number, message: string} | undefined} */
let blockResponse = undefined;
/**
* @param {string | undefined} packageName
* @param {string | undefined} version
*/
function packageChecked(packageName, version) {
// Emit event for any package being checked
eventEmitter.emit("packageChecked", {
packageName,
version,
targetUrl,
timestamp: Date.now(),
});
}
/**
* @param {string | undefined} packageName
* @param {string | undefined} version
@ -82,6 +97,7 @@ function createRequestContext(targetUrl, eventEmitter) {
return {
targetUrl,
packageChecked,
blockMalware,
build() {
return {

View file

@ -27,6 +27,11 @@ function buildNpmInterceptor(registry) {
reqContext.targetUrl,
registry
);
if (packageName && version) {
reqContext.packageChecked(packageName, version);
}
if (await isMalwarePackage(packageName, version)) {
reqContext.blockMalware(packageName, version);
}

View file

@ -32,6 +32,11 @@ function buildPipInterceptor(registry) {
reqContext.targetUrl,
registry
);
if (packageName && version) {
reqContext.packageChecked(packageName, version);
}
if (await isMalwarePackage(packageName, version)) {
reqContext.blockMalware(packageName, version);
}

View file

@ -8,21 +8,67 @@ import chalk from "chalk";
import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js";
const SERVER_STOP_TIMEOUT_MS = 1000;
/**
* @type {{port: number | null, blockedRequests: {packageName: string, version: string, url: string}[]}}
* @type {{port: number | null, blockedRequests: {packageName: string, version: string, url: string}[], keepAlive: boolean, certPath: string | null}}
*/
const state = {
port: null,
blockedRequests: [],
keepAlive: true, // By default, keep process alive
certPath: null,
};
export function createSafeChainProxy() {
/**
* Set the proxy state (used when connecting to an existing proxy)
* @param {number} port - The port number
* @param {string} certPath - The certificate path
*/
export function setProxyState(port, certPath) {
state.port = port;
state.certPath = certPath;
}
/**
* @typedef {Object} ProxyOptions
* @property {boolean} [keepAlive=true] - Whether to keep the Node.js process alive
*/
/**
* @typedef {Object} ProxyControl
* @property {() => Promise<void>} startServer - Start the proxy server
* @property {() => Promise<void>} stopServer - Stop the proxy server
* @property {() => boolean} verifyNoMaliciousPackages - Verify no malicious packages were blocked
* @property {() => number | null} getPort - Get the proxy server port
* @property {() => string | null} getProxyUrl - Get the proxy URL
* @property {() => Record<string, string>} getEnvironmentVariables - Get environment variables for the proxy
* @property {() => Array<{packageName: string, version: string, url: string}>} getBlockedRequests - Get blocked package requests
* @property {(keepAlive: boolean) => void} setKeepAlive - Set whether to keep process alive
*/
/**
* @param {ProxyOptions} [options={}] - Configuration options
* @returns {ProxyControl} Proxy control object
*/
export function createSafeChainProxy(options = {}) {
const server = createProxyServer();
// Initialize keepAlive from options if provided
if (options.keepAlive !== undefined) {
state.keepAlive = options.keepAlive;
}
return {
startServer: () => startServer(server),
stopServer: () => stopServer(server),
verifyNoMaliciousPackages,
getPort: () => state.port,
getProxyUrl: () => (state.port ? `http://localhost:${state.port}` : null),
getEnvironmentVariables: () => getSafeChainProxyEnvironmentVariables(),
getBlockedRequests: () => [...state.blockedRequests],
setKeepAlive: (/** @type {boolean} */ keepAlive) => {
state.keepAlive = keepAlive;
},
};
}
@ -34,10 +80,12 @@ function getSafeChainProxyEnvironmentVariables() {
return {};
}
const certPath = state.certPath || getCaCertPath();
return {
HTTPS_PROXY: `http://localhost:${state.port}`,
GLOBAL_AGENT_HTTP_PROXY: `http://localhost:${state.port}`,
NODE_EXTRA_CA_CERTS: getCaCertPath(),
NODE_EXTRA_CA_CERTS: certPath,
};
}
@ -89,6 +137,10 @@ function startServer(server) {
const address = server.address();
if (address && typeof address === "object") {
state.port = address.port;
// Only unref if keepAlive is false (for tests)
if (!state.keepAlive) {
server.unref();
}
resolve();
} else {
reject(new Error("Failed to start proxy server"));
@ -133,6 +185,13 @@ function handleConnect(req, clientSocket, head) {
const interceptor = createInterceptorForUrl(req.url || "");
if (interceptor) {
// Subscribe to package checked events
interceptor.on("packageChecked", (event) => {
ui.writeVerbose(
`Safe-chain: Checking package ${event.packageName}@${event.version}`
);
});
// Subscribe to malware blocked events
interceptor.on("malwareBlocked", (event) => {
onMalwareBlocked(event.packageName, event.version, event.url);

View file

@ -0,0 +1,465 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert";
import { spawn } from "node:child_process";
import { existsSync, readFileSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { parseShellOutput } from "./parseShellOutput.js";
const SAFE_CHAIN_BIN = join(
process.cwd(),
"packages/safe-chain/bin/safe-chain.js"
);
const AIKIDO_NPM_BIN = join(
process.cwd(),
"packages/safe-chain/bin/aikido-npm.js"
);
const AIKIDO_PIP_BIN = join(
process.cwd(),
"packages/safe-chain/bin/aikido-pip3.js"
);
const PROXY_STATE_FILE = join(homedir(), ".safe-chain/proxy-state.json");
/**
* Helper to start safe-chain run in agent mode
* @param {string[]} args - Arguments to pass to safe-chain run
* @returns {Promise<{process: import('child_process').ChildProcess, port: number, pid: number}>}
*/
async function startAgentMode(args = []) {
return new Promise((resolve, reject) => {
const proc = spawn("node", [SAFE_CHAIN_BIN, "run", ...args], {
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
let output = "";
let hasResolved = false;
const onData = (data) => {
output += data.toString();
// Look for port and pid - they might arrive in separate chunks
const portMatch = output.match(/Port:\s+(\d+)/);
const pidMatch = output.match(/PID:\s+(\d+)/);
// Also check for the success checkmark as confirmation
const hasSuccess = output.includes("Safe Chain proxy started successfully");
if (portMatch && pidMatch && hasSuccess && !hasResolved) {
hasResolved = true;
resolve({
process: proc,
port: parseInt(portMatch[1]),
pid: parseInt(pidMatch[1]),
});
}
};
proc.stdout.on("data", onData);
proc.stderr.on("data", onData);
proc.on("error", (error) => {
if (!hasResolved) {
hasResolved = true;
reject(error);
}
});
proc.on("exit", (code) => {
if (!hasResolved && code !== 0) {
hasResolved = true;
reject(new Error(`Process exited with code ${code}\n${output}`));
}
});
// Timeout after 5 seconds
setTimeout(() => {
if (!hasResolved) {
hasResolved = true;
proc.kill();
reject(new Error(`Timeout waiting for agent to start\n${output}`));
}
}, 5000);
});
}
/**
* Helper to stop agent mode process
* @param {import('child_process').ChildProcess} proc
* @returns {Promise<void>}
*/
async function stopAgentMode(proc) {
return new Promise((resolve) => {
if (!proc || proc.killed) {
resolve();
return;
}
proc.on("exit", () => {
resolve();
});
// Send SIGTERM
proc.kill("SIGTERM");
// Force kill after 2 seconds if still running
setTimeout(() => {
if (!proc.killed) {
proc.kill("SIGKILL");
}
resolve();
}, 2000);
});
}
/**
* Helper to run aikido-npm command
* @param {string[]} args
* @returns {Promise<{stdout: string, stderr: string, exitCode: number}>}
*/
async function runAikidoNpm(args) {
return new Promise((resolve) => {
const proc = spawn("node", [AIKIDO_NPM_BIN, ...args], {
stdio: ["ignore", "pipe", "pipe"],
cwd: "/tmp",
});
let stdout = "";
let stderr = "";
proc.stdout.on("data", (data) => {
stdout += data.toString();
});
proc.stderr.on("data", (data) => {
stderr += data.toString();
});
proc.on("exit", (code) => {
resolve({
stdout,
stderr,
exitCode: code || 0,
});
});
});
}
/**
* Helper to run aikido-pip command
* @param {string[]} args
* @returns {Promise<{stdout: string, stderr: string, exitCode: number}>}
*/
async function runAikidoPip(args) {
return new Promise((resolve) => {
const proc = spawn("node", [AIKIDO_PIP_BIN, ...args], {
stdio: ["ignore", "pipe", "pipe"],
cwd: "/tmp",
});
let stdout = "";
let stderr = "";
proc.stdout.on("data", (data) => {
stdout += data.toString();
});
proc.stderr.on("data", (data) => {
stderr += data.toString();
});
proc.on("exit", (code) => {
resolve({
stdout,
stderr,
exitCode: code || 0,
});
});
});
}
/**
* Read and parse proxy state file
* @returns {{port: number, url: string, pid: number, ecosystem: string, certPath: string} | null}
*/
function readProxyState() {
try {
if (!existsSync(PROXY_STATE_FILE)) {
return null;
}
const content = readFileSync(PROXY_STATE_FILE, "utf-8");
const state = JSON.parse(content);
// Validate that process is still running (same as actual implementation)
try {
process.kill(state.pid, 0);
return state;
} catch {
// Process doesn't exist
return null;
}
} catch {
return null;
}
}
/**
* Clean up proxy state file
*/
function cleanupProxyState() {
try {
if (existsSync(PROXY_STATE_FILE)) {
unlinkSync(PROXY_STATE_FILE);
}
} catch {
// Ignore errors
}
}
describe("Agent Mode E2E", { timeout: 60000 }, () => {
before(() => {
// Clean up any existing proxy state
cleanupProxyState();
});
after(() => {
// Clean up after tests
cleanupProxyState();
});
describe("safe-chain run", () => {
it("should start proxy and create state file", async () => {
let agent;
try {
// Start agent mode
agent = await startAgentMode();
// Verify process is running
assert.ok(agent.process);
assert.ok(agent.port > 0);
assert.ok(agent.pid > 0);
// Verify state file was created
const state = readProxyState();
assert.ok(state, "State file should exist");
assert.strictEqual(state.port, agent.port);
assert.strictEqual(state.pid, agent.pid);
assert.strictEqual(state.ecosystem, "all");
assert.strictEqual(state.url, `http://localhost:${agent.port}`);
assert.ok(state.certPath);
assert.ok(state.certPath.includes(".safe-chain/certs/ca-cert.pem"));
} finally {
if (agent) {
await stopAgentMode(agent.process);
}
}
});
it("should accept verbose flag", async () => {
let agent;
try {
// Start agent mode with verbose flag
agent = await startAgentMode(["--verbose"]);
// Verify state file ecosystem is always 'all'
const state = readProxyState();
assert.ok(state);
assert.strictEqual(state.ecosystem, "all");
} finally {
if (agent) {
await stopAgentMode(agent.process);
}
}
});
it("should cleanup state file when proxy stops", async () => {
let agent;
try {
// Start agent mode
agent = await startAgentMode();
// Verify state file exists
assert.ok(readProxyState());
// Stop agent
await stopAgentMode(agent.process);
agent = null;
// Wait a bit for cleanup
await new Promise((resolve) => setTimeout(resolve, 500));
// Verify state file was removed
const state = readProxyState();
assert.strictEqual(state, null, "State file should be removed");
} finally {
if (agent) {
await stopAgentMode(agent.process);
}
}
});
});
describe("aikido-npm with agent mode", () => {
let agent;
before(async () => {
// Start agent mode for all npm tests
agent = await startAgentMode(["--verbose"]);
// Wait a bit to ensure proxy is fully ready
await new Promise((resolve) => setTimeout(resolve, 500));
});
after(async () => {
// Stop agent after all tests
if (agent) {
await stopAgentMode(agent.process);
}
});
it("should use existing proxy when running npm view", async () => {
const result = await runAikidoNpm(["view", "lodash", "version"]);
// Should succeed
assert.strictEqual(result.exitCode, 0);
// Should have output with version
assert.ok(result.stdout.includes("4.17") || result.stdout.includes("lodash"));
// Verify proxy intercepted the request (check stderr for proxy messages)
const allOutput = result.stdout + result.stderr;
assert.ok(
allOutput.includes("registry.npmjs.org") ||
allOutput.includes("MITM") ||
result.exitCode === 0,
"Should use proxy for request"
);
});
it("should use existing proxy when running npm info", async () => {
const result = await runAikidoNpm(["info", "express", "version"]);
// Should succeed
assert.strictEqual(result.exitCode, 0);
// Should have output
assert.ok(result.stdout.length > 0);
});
it("should detect malware using existing proxy", async () => {
// Note: This test assumes there's a known malware package in the database
// If no malware is configured, the test will just verify the command runs
const result = await runAikidoNpm(["view", "some-test-package", "version"]);
// Command should complete (may succeed or fail depending on package existence)
assert.ok(result.exitCode !== undefined);
});
});
describe("aikido-pip with agent mode", () => {
let agent;
before(async () => {
// Start agent mode for all pip tests
agent = await startAgentMode(["--verbose"]);
// Wait a bit to ensure proxy is fully ready
await new Promise((resolve) => setTimeout(resolve, 500));
});
after(async () => {
// Stop agent after all tests
if (agent) {
await stopAgentMode(agent.process);
}
});
it("should use existing proxy when running pip download", async () => {
// Use --dry-run to avoid actual installation
const result = await runAikidoPip(["download", "requests", "--dry-run"]);
// Command should complete
assert.ok(result.exitCode !== undefined);
// Should have some output
const allOutput = result.stdout + result.stderr;
assert.ok(allOutput.length > 0);
});
});
describe("inline mode (no agent)", () => {
before(() => {
// Ensure no agent is running
cleanupProxyState();
});
it("should start inline proxy when no agent is running", async () => {
// Verify no state file
assert.strictEqual(readProxyState(), null);
// Run aikido-npm without agent mode
const result = await runAikidoNpm(["view", "lodash", "version"]);
// Should succeed with inline proxy
assert.strictEqual(result.exitCode, 0);
// Should have output
assert.ok(result.stdout.includes("4.17") || result.stdout.includes("lodash"));
// State file should still not exist (inline mode doesn't create it)
assert.strictEqual(readProxyState(), null);
});
});
describe("proxy state validation", () => {
it("should ignore stale state file with dead process", async () => {
// Create a fake state file with a non-existent PID
const fakeState = {
port: 12345,
url: "http://localhost:12345",
pid: 99999999, // Very unlikely to exist
ecosystem: "js",
certPath: join(homedir(), ".safe-chain/certs/ca-cert.pem"),
};
// Write fake state file
const fs = await import("node:fs/promises");
const proxyStateDir = join(homedir(), ".safe-chain");
await fs.mkdir(proxyStateDir, { recursive: true });
await fs.writeFile(PROXY_STATE_FILE, JSON.stringify(fakeState, null, 2));
// Verify state file exists but process is dead
const state = readProxyState();
assert.strictEqual(state, null, "Should return null for dead process");
// Clean up
cleanupProxyState();
});
});
describe("combined ecosystems", () => {
let agent;
before(async () => {
// Start agent mode (supports all ecosystems by default)
agent = await startAgentMode(["--verbose"]);
await new Promise((resolve) => setTimeout(resolve, 500));
});
after(async () => {
if (agent) {
await stopAgentMode(agent.process);
}
});
it("should handle npm requests", async () => {
const result = await runAikidoNpm(["view", "chalk", "version"]);
assert.strictEqual(result.exitCode, 0);
assert.ok(result.stdout.length > 0);
});
it("should handle pip requests", async () => {
const result = await runAikidoPip(["download", "requests", "--dry-run"]);
// Command should complete
assert.ok(result.exitCode !== undefined);
});
});
});