mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 20:20: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 { setup } from "../src/shell-integration/setup.js";
|
||||||
import { teardown } from "../src/shell-integration/teardown.js";
|
import { teardown } from "../src/shell-integration/teardown.js";
|
||||||
import { setupCi } from "../src/shell-integration/setup-ci.js";
|
import { setupCi } from "../src/shell-integration/setup-ci.js";
|
||||||
|
import { runCommand } from "../src/agent/runCommand.js";
|
||||||
|
|
||||||
if (process.argv.length < 3) {
|
if (process.argv.length < 3) {
|
||||||
ui.writeError("No command provided. Please provide a command to execute.");
|
ui.writeError("No command provided. Please provide a command to execute.");
|
||||||
|
|
@ -27,6 +28,10 @@ if (command === "setup") {
|
||||||
teardown();
|
teardown();
|
||||||
} else if (command === "setup-ci") {
|
} else if (command === "setup-ci") {
|
||||||
setupCi();
|
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") {
|
} else if (command === "--version" || command === "-v" || command === "-v") {
|
||||||
ui.writeInformation(`Current safe-chain version: ${getVersion()}`);
|
ui.writeInformation(`Current safe-chain version: ${getVersion()}`);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -46,9 +51,9 @@ function writeHelp() {
|
||||||
ui.writeInformation(
|
ui.writeInformation(
|
||||||
`Available commands: ${chalk.cyan("setup")}, ${chalk.cyan(
|
`Available commands: ${chalk.cyan("setup")}, ${chalk.cyan(
|
||||||
"teardown"
|
"teardown"
|
||||||
)}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("help")}, ${chalk.cyan(
|
)}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("run")}, ${chalk.cyan(
|
||||||
"--version"
|
"help"
|
||||||
)}`
|
)}, ${chalk.cyan("--version")}`
|
||||||
);
|
);
|
||||||
ui.emptyLine();
|
ui.emptyLine();
|
||||||
ui.writeInformation(
|
ui.writeInformation(
|
||||||
|
|
@ -66,6 +71,11 @@ function writeHelp() {
|
||||||
"safe-chain setup-ci"
|
"safe-chain setup-ci"
|
||||||
)}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.`
|
)}: 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(
|
ui.writeInformation(
|
||||||
`- ${chalk.cyan(
|
`- ${chalk.cyan(
|
||||||
"safe-chain --version"
|
"safe-chain --version"
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@
|
||||||
},
|
},
|
||||||
"./scanning": {
|
"./scanning": {
|
||||||
"default": "./src/scanning/audit/index.js"
|
"default": "./src/scanning/audit/index.js"
|
||||||
|
},
|
||||||
|
"./agent": {
|
||||||
|
"default": "./src/agent/standaloneProxy.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"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 { createSafeChainProxy } from "./registryProxy/registryProxy.js";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { getAuditStats } from "./scanning/audit/index.js";
|
import { getAuditStats } from "./scanning/audit/index.js";
|
||||||
|
import { readProxyState } from "./agent/proxyState.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string[]} args
|
* @param {string[]} args
|
||||||
|
|
@ -16,8 +17,33 @@ export async function main(args) {
|
||||||
process.on("SIGINT", handleProcessTermination);
|
process.on("SIGINT", handleProcessTermination);
|
||||||
process.on("SIGTERM", handleProcessTermination);
|
process.on("SIGTERM", handleProcessTermination);
|
||||||
|
|
||||||
const proxy = createSafeChainProxy();
|
// 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();
|
await proxy.startServer();
|
||||||
|
}
|
||||||
|
|
||||||
// Global error handlers to log unhandled errors
|
// Global error handlers to log unhandled errors
|
||||||
process.on("uncaughtException", (error) => {
|
process.on("uncaughtException", (error) => {
|
||||||
|
|
@ -65,12 +91,20 @@ export async function main(args) {
|
||||||
const auditStats = getAuditStats();
|
const auditStats = getAuditStats();
|
||||||
if (auditStats.totalPackages > 0) {
|
if (auditStats.totalPackages > 0) {
|
||||||
ui.emptyLine();
|
ui.emptyLine();
|
||||||
|
if (usingExistingProxy) {
|
||||||
|
ui.writeInformation(
|
||||||
|
`${chalk.green("✔")} Safe-chain: Scanned ${
|
||||||
|
auditStats.totalPackages
|
||||||
|
} packages via proxy, no malware found.`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
ui.writeInformation(
|
ui.writeInformation(
|
||||||
`${chalk.green("✔")} Safe-chain: Scanned ${
|
`${chalk.green("✔")} Safe-chain: Scanned ${
|
||||||
auditStats.totalPackages
|
auditStats.totalPackages
|
||||||
} packages, no malware found.`
|
} packages, no malware found.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Returning the exit code back to the caller allows the promise
|
// Returning the exit code back to the caller allows the promise
|
||||||
// to be awaited in the bin files and return the correct exit code
|
// to be awaited in the bin files and return the correct exit code
|
||||||
|
|
@ -82,9 +116,12 @@ export async function main(args) {
|
||||||
// to be awaited in the bin files and return the correct exit code
|
// to be awaited in the bin files and return the correct exit code
|
||||||
return 1;
|
return 1;
|
||||||
} finally {
|
} finally {
|
||||||
|
// Only stop the proxy if we started it (not using existing proxy)
|
||||||
|
if (!usingExistingProxy) {
|
||||||
await proxy.stopServer();
|
await proxy.stopServer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleProcessTermination() {
|
function handleProcessTermination() {
|
||||||
ui.writeBufferedLogsAndStopBuffering();
|
ui.writeBufferedLogsAndStopBuffering();
|
||||||
|
|
|
||||||
|
|
@ -21,5 +21,8 @@ export function createInterceptorForUrl(url) {
|
||||||
return pipInterceptorForUrl(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
|
* @typedef {Object} RequestInterceptionContext
|
||||||
* @property {string} targetUrl
|
* @property {string} targetUrl
|
||||||
|
* @property {(packageName: string | undefined, version: string | undefined) => void} packageChecked
|
||||||
* @property {(packageName: string | undefined, version: string | undefined) => void} blockMalware
|
* @property {(packageName: string | undefined, version: string | undefined) => void} blockMalware
|
||||||
* @property {() => RequestInterceptionHandler} build
|
* @property {() => RequestInterceptionHandler} build
|
||||||
*
|
*
|
||||||
|
|
@ -61,6 +62,20 @@ function createRequestContext(targetUrl, eventEmitter) {
|
||||||
/** @type {{statusCode: number, message: string} | undefined} */
|
/** @type {{statusCode: number, message: string} | undefined} */
|
||||||
let blockResponse = 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} packageName
|
||||||
* @param {string | undefined} version
|
* @param {string | undefined} version
|
||||||
|
|
@ -82,6 +97,7 @@ function createRequestContext(targetUrl, eventEmitter) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
targetUrl,
|
targetUrl,
|
||||||
|
packageChecked,
|
||||||
blockMalware,
|
blockMalware,
|
||||||
build() {
|
build() {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,11 @@ function buildNpmInterceptor(registry) {
|
||||||
reqContext.targetUrl,
|
reqContext.targetUrl,
|
||||||
registry
|
registry
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (packageName && version) {
|
||||||
|
reqContext.packageChecked(packageName, version);
|
||||||
|
}
|
||||||
|
|
||||||
if (await isMalwarePackage(packageName, version)) {
|
if (await isMalwarePackage(packageName, version)) {
|
||||||
reqContext.blockMalware(packageName, version);
|
reqContext.blockMalware(packageName, version);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,11 @@ function buildPipInterceptor(registry) {
|
||||||
reqContext.targetUrl,
|
reqContext.targetUrl,
|
||||||
registry
|
registry
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (packageName && version) {
|
||||||
|
reqContext.packageChecked(packageName, version);
|
||||||
|
}
|
||||||
|
|
||||||
if (await isMalwarePackage(packageName, version)) {
|
if (await isMalwarePackage(packageName, version)) {
|
||||||
reqContext.blockMalware(packageName, version);
|
reqContext.blockMalware(packageName, version);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,21 +8,67 @@ import chalk from "chalk";
|
||||||
import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js";
|
import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js";
|
||||||
|
|
||||||
const SERVER_STOP_TIMEOUT_MS = 1000;
|
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 = {
|
const state = {
|
||||||
port: null,
|
port: null,
|
||||||
blockedRequests: [],
|
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();
|
const server = createProxyServer();
|
||||||
|
|
||||||
|
// Initialize keepAlive from options if provided
|
||||||
|
if (options.keepAlive !== undefined) {
|
||||||
|
state.keepAlive = options.keepAlive;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startServer: () => startServer(server),
|
startServer: () => startServer(server),
|
||||||
stopServer: () => stopServer(server),
|
stopServer: () => stopServer(server),
|
||||||
verifyNoMaliciousPackages,
|
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 {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const certPath = state.certPath || getCaCertPath();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
HTTPS_PROXY: `http://localhost:${state.port}`,
|
HTTPS_PROXY: `http://localhost:${state.port}`,
|
||||||
GLOBAL_AGENT_HTTP_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();
|
const address = server.address();
|
||||||
if (address && typeof address === "object") {
|
if (address && typeof address === "object") {
|
||||||
state.port = address.port;
|
state.port = address.port;
|
||||||
|
// Only unref if keepAlive is false (for tests)
|
||||||
|
if (!state.keepAlive) {
|
||||||
|
server.unref();
|
||||||
|
}
|
||||||
resolve();
|
resolve();
|
||||||
} else {
|
} else {
|
||||||
reject(new Error("Failed to start proxy server"));
|
reject(new Error("Failed to start proxy server"));
|
||||||
|
|
@ -133,6 +185,13 @@ function handleConnect(req, clientSocket, head) {
|
||||||
const interceptor = createInterceptorForUrl(req.url || "");
|
const interceptor = createInterceptorForUrl(req.url || "");
|
||||||
|
|
||||||
if (interceptor) {
|
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
|
// Subscribe to malware blocked events
|
||||||
interceptor.on("malwareBlocked", (event) => {
|
interceptor.on("malwareBlocked", (event) => {
|
||||||
onMalwareBlocked(event.packageName, event.version, event.url);
|
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