mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Compare commits
6 commits
main
...
0.0.6-new-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67a8f2db52 | ||
|
|
0411a579ae | ||
|
|
6006760b67 | ||
|
|
22580bcf5f | ||
|
|
f6abfb8a4e | ||
|
|
9d1f7ac6fd |
3 changed files with 290 additions and 119 deletions
|
|
@ -317,6 +317,35 @@ main() {
|
||||||
|
|
||||||
info "Binary installed to: $FINAL_FILE"
|
info "Binary installed to: $FINAL_FILE"
|
||||||
|
|
||||||
|
# Download safechain-proxy
|
||||||
|
if [ "$OS" = "macos" ] || [ "$OS" = "linux" ] || [ "$OS" = "linuxstatic" ]; then
|
||||||
|
info "Downloading safechain-proxy..."
|
||||||
|
|
||||||
|
if [ "$OS" = "macos" ]; then
|
||||||
|
if [ "$ARCH" = "arm64" ]; then
|
||||||
|
PROXY_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v0.0.4-linux-proxy-bins/safechain-proxy-darwin-arm64"
|
||||||
|
else
|
||||||
|
PROXY_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v0.0.4-linux-proxy-bins/safechain-proxy-darwin-amd64"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Linux (both linux and linuxstatic)
|
||||||
|
if [ "$ARCH" = "x64" ]; then
|
||||||
|
PROXY_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v0.0.4-linux-proxy-bins/safechain-proxy-linux-amd64"
|
||||||
|
else
|
||||||
|
# Skip for non-x64 Linux architectures
|
||||||
|
info "Skipping safechain-proxy download (not available for linux-${ARCH})"
|
||||||
|
PROXY_URL=""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$PROXY_URL" ]; then
|
||||||
|
PROXY_FILE="${INSTALL_DIR}/safechain-proxy"
|
||||||
|
download "$PROXY_URL" "$PROXY_FILE"
|
||||||
|
chmod +x "$PROXY_FILE" || error "Failed to make proxy executable"
|
||||||
|
info "Proxy installed to: $PROXY_FILE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Build setup command based on arguments
|
# Build setup command based on arguments
|
||||||
SETUP_CMD="setup"
|
SETUP_CMD="setup"
|
||||||
SETUP_ARGS=""
|
SETUP_ARGS=""
|
||||||
|
|
|
||||||
115
packages/safe-chain/src/registryProxy/createRamaProxy.js
Normal file
115
packages/safe-chain/src/registryProxy/createRamaProxy.js
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { ChildProcess, spawn } from "node:child_process";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { mkdtempSync, readFile, writeFile } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { ui } from "../environment/userInteraction.js";
|
||||||
|
import { getLoggingLevel, LOGGING_VERBOSE } from "../config/settings.js";
|
||||||
|
|
||||||
|
const readFilePromise = promisify(readFile);
|
||||||
|
const writeFilePromise = promisify(writeFile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} RamaProxyInstance
|
||||||
|
* @property {ChildProcess} process
|
||||||
|
* @property {string} proxyAddress
|
||||||
|
* @property {string} metaAddress
|
||||||
|
* @property {string} certPath
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {String | null}
|
||||||
|
*/
|
||||||
|
export function getRamaPath() {
|
||||||
|
const executableDir = dirname(process.execPath);
|
||||||
|
const ramaPath = join(executableDir, "safechain-proxy");
|
||||||
|
|
||||||
|
if (existsSync(ramaPath)) {
|
||||||
|
return ramaPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} ramaPath
|
||||||
|
*
|
||||||
|
* @returns {import("./registryProxy.js").SafeChainProxy} */
|
||||||
|
export function createRamaProxy(ramaPath) {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), "safe-chain-proxy-"));
|
||||||
|
/** @type {RamaProxyInstance | null} */
|
||||||
|
let ramaInstance = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
startServer: async () => {
|
||||||
|
ramaInstance = await startRama(ramaPath, tempDir);
|
||||||
|
ui.writeVerbose(
|
||||||
|
`Proxy started at address "${ramaInstance.proxyAddress}"`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
stopServer: async () => {
|
||||||
|
if (ramaInstance) {
|
||||||
|
ramaInstance.process.kill();
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
verifyNoMaliciousPackages: () => true,
|
||||||
|
hasSuppressedVersions: () => false,
|
||||||
|
getServerPort: () => {
|
||||||
|
if (!ramaInstance) return null;
|
||||||
|
const url = new URL(`http://${ramaInstance.proxyAddress}`);
|
||||||
|
return url.port ? parseInt(url.port, 10) : null;
|
||||||
|
},
|
||||||
|
getCombinedCaBundlePath: () => ramaInstance?.certPath ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} ramaPath
|
||||||
|
* @param {string} dataFolder
|
||||||
|
* @returns {Promise<RamaProxyInstance>}
|
||||||
|
*/
|
||||||
|
async function startRama(ramaPath, dataFolder) {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const args = ["--secrets", "memory", "--data", dataFolder];
|
||||||
|
const process =
|
||||||
|
getLoggingLevel() === LOGGING_VERBOSE
|
||||||
|
? spawn(ramaPath, args, {
|
||||||
|
stdio: "inherit",
|
||||||
|
})
|
||||||
|
: spawn(ramaPath, args);
|
||||||
|
|
||||||
|
// wait for the proxy process to start (poll for proxy.addr.txt file)
|
||||||
|
const proxyAddrPath = join(dataFolder, "proxy.addr.txt");
|
||||||
|
const maxWaitTime = 60000; // 60 seconds
|
||||||
|
const pollInterval = 500; // 500 ms
|
||||||
|
|
||||||
|
while (!existsSync(proxyAddrPath)) {
|
||||||
|
if (Date.now() - startTime > maxWaitTime) {
|
||||||
|
throw new Error("Timeout waiting for proxy to start");
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsedTime = Date.now() - startTime;
|
||||||
|
ui.writeVerbose(`Proxy started in ${elapsedTime}ms`);
|
||||||
|
|
||||||
|
const proxyAddress = await readFilePromise(proxyAddrPath, "utf-8");
|
||||||
|
const metaAddress = await readFilePromise(
|
||||||
|
join(dataFolder, "meta.addr.txt"),
|
||||||
|
"utf-8"
|
||||||
|
);
|
||||||
|
|
||||||
|
const certResponse = await fetch(`http://${metaAddress}/ca`);
|
||||||
|
const cert = await certResponse.text();
|
||||||
|
const certPath = join(dataFolder, "cert.ca");
|
||||||
|
await writeFilePromise(certPath, cert);
|
||||||
|
|
||||||
|
return {
|
||||||
|
process,
|
||||||
|
proxyAddress,
|
||||||
|
metaAddress,
|
||||||
|
certPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -7,37 +7,47 @@ import { ui } from "../environment/userInteraction.js";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js";
|
import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js";
|
||||||
import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js";
|
import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js";
|
||||||
|
import { createRamaProxy, getRamaPath } from "./createRamaProxy.js";
|
||||||
|
|
||||||
const SERVER_STOP_TIMEOUT_MS = 1000;
|
|
||||||
/**
|
/**
|
||||||
* @type {{port: number | null, blockedRequests: {packageName: string, version: string, url: string}[]}}
|
* @typedef {Object} SafeChainProxy
|
||||||
|
* @prop {() => Promise<void>} startServer
|
||||||
|
* @prop {() => Promise<void>} stopServer
|
||||||
|
* @prop {() => boolean} verifyNoMaliciousPackages
|
||||||
|
* @prop {() => boolean} hasSuppressedVersions
|
||||||
|
* @prop {() => Number | null} getServerPort
|
||||||
|
* @prop {() => string} getCombinedCaBundlePath
|
||||||
*/
|
*/
|
||||||
const state = {
|
|
||||||
port: null,
|
/** @type {SafeChainProxy} */
|
||||||
blockedRequests: [],
|
let server;
|
||||||
};
|
|
||||||
|
|
||||||
export function createSafeChainProxy() {
|
export function createSafeChainProxy() {
|
||||||
const server = createProxyServer();
|
if (server) {
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
let ramaPath = getRamaPath();
|
||||||
startServer: () => startServer(server),
|
if (ramaPath) {
|
||||||
stopServer: () => stopServer(server),
|
ui.writeInformation("Starting safe-chain rama proxy");
|
||||||
verifyNoMaliciousPackages,
|
server = createRamaProxy(ramaPath);
|
||||||
hasSuppressedVersions: getHasSuppressedVersions,
|
} else {
|
||||||
};
|
server = createBuiltInProxyServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
return server;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {Record<string, string>}
|
* @returns {Record<string, string>}
|
||||||
*/
|
*/
|
||||||
function getSafeChainProxyEnvironmentVariables() {
|
function getSafeChainProxyEnvironmentVariables() {
|
||||||
if (!state.port) {
|
if (!server || !server.getServerPort()) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const proxyUrl = `http://localhost:${state.port}`;
|
const proxyUrl = `http://localhost:${server.getServerPort()}`;
|
||||||
const caCertPath = getCombinedCaBundlePath();
|
const caCertPath = server.getCombinedCaBundlePath();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
HTTPS_PROXY: proxyUrl,
|
HTTPS_PROXY: proxyUrl,
|
||||||
|
|
@ -68,7 +78,17 @@ export function mergeSafeChainProxyEnvironmentVariables(env) {
|
||||||
return proxyEnv;
|
return proxyEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createProxyServer() {
|
/** @returns {SafeChainProxy} */
|
||||||
|
function createBuiltInProxyServer() {
|
||||||
|
const SERVER_STOP_TIMEOUT_MS = 1000;
|
||||||
|
/**
|
||||||
|
* @type {{port: number | null, blockedRequests: {packageName: string, version: string, url: string}[]}}
|
||||||
|
*/
|
||||||
|
const state = {
|
||||||
|
port: null,
|
||||||
|
blockedRequests: [],
|
||||||
|
};
|
||||||
|
|
||||||
const server = http.createServer(
|
const server = http.createServer(
|
||||||
// This handles direct HTTP requests (non-CONNECT requests)
|
// This handles direct HTTP requests (non-CONNECT requests)
|
||||||
// This is normally http-only traffic, but we also handle
|
// This is normally http-only traffic, but we also handle
|
||||||
|
|
@ -79,114 +99,121 @@ function createProxyServer() {
|
||||||
// This handles HTTPS requests via the CONNECT method
|
// This handles HTTPS requests via the CONNECT method
|
||||||
server.on("connect", handleConnect);
|
server.on("connect", handleConnect);
|
||||||
|
|
||||||
return server;
|
return {
|
||||||
}
|
startServer: () => startServer(server),
|
||||||
|
stopServer: () => stopServer(server),
|
||||||
|
verifyNoMaliciousPackages,
|
||||||
|
hasSuppressedVersions: getHasSuppressedVersions,
|
||||||
|
getServerPort: () => state.port,
|
||||||
|
getCombinedCaBundlePath,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("http").Server} server
|
* @param {import("http").Server} server
|
||||||
*
|
*
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
function startServer(server) {
|
function startServer(server) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// Passing port 0 makes the OS assign an available port
|
// Passing port 0 makes the OS assign an available port
|
||||||
server.listen(0, () => {
|
server.listen(0, () => {
|
||||||
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;
|
||||||
resolve();
|
resolve();
|
||||||
} else {
|
} else {
|
||||||
reject(new Error("Failed to start proxy server"));
|
reject(new Error("Failed to start proxy server"));
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
server.on("error", (err) => {
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {import("http").Server} server
|
|
||||||
*
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
function stopServer(server) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
try {
|
|
||||||
server.close(() => {
|
|
||||||
resolve();
|
|
||||||
});
|
});
|
||||||
} catch {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
setTimeout(() => resolve(), SERVER_STOP_TIMEOUT_MS);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
server.on("error", (err) => {
|
||||||
* @param {import("http").IncomingMessage} req
|
reject(err);
|
||||||
* @param {import("http").ServerResponse} clientSocket
|
});
|
||||||
* @param {Buffer} head
|
});
|
||||||
*
|
}
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
function handleConnect(req, clientSocket, head) {
|
|
||||||
// CONNECT method is used for HTTPS requests
|
|
||||||
// It establishes a tunnel to the server identified by the request URL
|
|
||||||
|
|
||||||
const interceptor = createInterceptorForUrl(req.url || "");
|
/**
|
||||||
|
* @param {import("http").Server} server
|
||||||
if (interceptor) {
|
*
|
||||||
// Subscribe to malware blocked events
|
* @returns {Promise<void>}
|
||||||
interceptor.on(
|
*/
|
||||||
"malwareBlocked",
|
function stopServer(server) {
|
||||||
(
|
return new Promise((resolve) => {
|
||||||
/** @type {import("./interceptors/interceptorBuilder.js").MalwareBlockedEvent} */ event
|
try {
|
||||||
) => {
|
server.close(() => {
|
||||||
onMalwareBlocked(event.packageName, event.version, event.targetUrl);
|
resolve();
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
resolve();
|
||||||
}
|
}
|
||||||
|
setTimeout(() => resolve(), SERVER_STOP_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import("http").IncomingMessage} req
|
||||||
|
* @param {import("http").ServerResponse} clientSocket
|
||||||
|
* @param {Buffer} head
|
||||||
|
*
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function handleConnect(req, clientSocket, head) {
|
||||||
|
// CONNECT method is used for HTTPS requests
|
||||||
|
// It establishes a tunnel to the server identified by the request URL
|
||||||
|
|
||||||
|
const interceptor = createInterceptorForUrl(req.url || "");
|
||||||
|
|
||||||
|
if (interceptor) {
|
||||||
|
// Subscribe to malware blocked events
|
||||||
|
interceptor.on(
|
||||||
|
"malwareBlocked",
|
||||||
|
(
|
||||||
|
/** @type {import("./interceptors/interceptorBuilder.js").MalwareBlockedEvent} */ event
|
||||||
|
) => {
|
||||||
|
onMalwareBlocked(event.packageName, event.version, event.targetUrl);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
mitmConnect(req, clientSocket, interceptor);
|
||||||
|
} else {
|
||||||
|
// For other hosts, just tunnel the request to the destination tcp socket
|
||||||
|
ui.writeVerbose(`Safe-chain: Tunneling request to ${req.url}`);
|
||||||
|
tunnelRequest(req, clientSocket, head);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} packageName
|
||||||
|
* @param {string} version
|
||||||
|
* @param {string} url
|
||||||
|
*/
|
||||||
|
function onMalwareBlocked(packageName, version, url) {
|
||||||
|
state.blockedRequests.push({ packageName, version, url });
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyNoMaliciousPackages() {
|
||||||
|
if (state.blockedRequests.length === 0) {
|
||||||
|
// No malicious packages were blocked, so nothing to block
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.emptyLine();
|
||||||
|
|
||||||
|
ui.writeInformation(
|
||||||
|
`Safe-chain: ${chalk.bold(
|
||||||
|
`blocked ${state.blockedRequests.length} malicious package downloads`
|
||||||
|
)}:`
|
||||||
);
|
);
|
||||||
|
|
||||||
mitmConnect(req, clientSocket, interceptor);
|
for (const req of state.blockedRequests) {
|
||||||
} else {
|
ui.writeInformation(` - ${req.packageName}@${req.version} (${req.url})`);
|
||||||
// For other hosts, just tunnel the request to the destination tcp socket
|
}
|
||||||
ui.writeVerbose(`Safe-chain: Tunneling request to ${req.url}`);
|
|
||||||
tunnelRequest(req, clientSocket, head);
|
ui.emptyLine();
|
||||||
|
ui.writeExitWithoutInstallingMaliciousPackages();
|
||||||
|
ui.emptyLine();
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param {string} packageName
|
|
||||||
* @param {string} version
|
|
||||||
* @param {string} url
|
|
||||||
*/
|
|
||||||
function onMalwareBlocked(packageName, version, url) {
|
|
||||||
state.blockedRequests.push({ packageName, version, url });
|
|
||||||
}
|
|
||||||
|
|
||||||
function verifyNoMaliciousPackages() {
|
|
||||||
if (state.blockedRequests.length === 0) {
|
|
||||||
// No malicious packages were blocked, so nothing to block
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.emptyLine();
|
|
||||||
|
|
||||||
ui.writeInformation(
|
|
||||||
`Safe-chain: ${chalk.bold(
|
|
||||||
`blocked ${state.blockedRequests.length} malicious package downloads`
|
|
||||||
)}:`
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const req of state.blockedRequests) {
|
|
||||||
ui.writeInformation(` - ${req.packageName}@${req.version} (${req.url})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.emptyLine();
|
|
||||||
ui.writeExitWithoutInstallingMaliciousPackages();
|
|
||||||
ui.emptyLine();
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue