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

@ -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);