mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Skeleton
This commit is contained in:
parent
6b208a8730
commit
4c64dcf201
13 changed files with 1410 additions and 15 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@
|
|||
},
|
||||
"./scanning": {
|
||||
"default": "./src/scanning/audit/index.js"
|
||||
},
|
||||
"./agent": {
|
||||
"default": "./src/agent/standaloneProxy.js"
|
||||
}
|
||||
},
|
||||
"keywords": [],
|
||||
|
|
|
|||
82
packages/safe-chain/src/agent/proxyState.js
Normal file
82
packages/safe-chain/src/agent/proxyState.js
Normal 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;
|
||||
}
|
||||
132
packages/safe-chain/src/agent/runCommand.js
Normal file
132
packages/safe-chain/src/agent/runCommand.js
Normal 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);
|
||||
}
|
||||
}
|
||||
176
packages/safe-chain/src/agent/standaloneProxy.js
Normal file
176
packages/safe-chain/src/agent/standaloneProxy.js
Normal 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();
|
||||
}
|
||||
}
|
||||
402
packages/safe-chain/src/agent/standaloneProxy.spec.js
Normal file
402
packages/safe-chain/src/agent/standaloneProxy.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
465
test/e2e/agent-mode.e2e.spec.js
Normal file
465
test/e2e/agent-mode.e2e.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue