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
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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue