Merge branch 'main' into feature/pypi

This commit is contained in:
Reinier Criel 2025-11-04 06:54:00 -08:00
commit d789491561
26 changed files with 186 additions and 118 deletions

View file

@ -98,6 +98,14 @@ Example usage:
npm install express --safe-chain-logging=silent
```
- `--safe-chain-logging=verbose` - Enables detailed diagnostic output from Aikido Safe Chain. Useful for troubleshooting issues or understanding what Safe Chain is doing behind the scenes.
Example usage:
```shell
npm install express --safe-chain-logging=verbose
```
# Usage in CI/CD
You can protect your CI/CD pipelines from malicious packages by integrating Aikido Safe Chain into your build process. This ensures that any packages installed during your automated builds are checked for malware before installation.

View file

@ -9,7 +9,8 @@
"scripts": {
"test": "npm run test --workspace=packages/safe-chain --workspace=packages/safe-chain-bun",
"test:e2e": "npm run test --workspace=test/e2e",
"lint": "npm run lint --workspace=packages/safe-chain"
"lint": "npm run lint --workspace=packages/safe-chain",
"typecheck": "npm run typecheck --workspace=packages/safe-chain"
},
"repository": {
"type": "git",

View file

@ -9,5 +9,4 @@ const packageManagerName = "bun";
initializePackageManager(packageManagerName);
var exitCode = await main(process.argv.slice(2));
// @ts-expect-error scanCommand can return an empty array in main
process.exit(exitCode);

View file

@ -9,5 +9,4 @@ const packageManagerName = "bunx";
initializePackageManager(packageManagerName);
var exitCode = await main(process.argv.slice(2));
// @ts-expect-error scanCommand can return an empty array in main
process.exit(exitCode);

View file

@ -9,5 +9,4 @@ const packageManagerName = "npm";
initializePackageManager(packageManagerName);
var exitCode = await main(process.argv.slice(2));
// @ts-expect-error scanCommand can return an empty array in main
process.exit(exitCode);

View file

@ -9,5 +9,4 @@ const packageManagerName = "npx";
initializePackageManager(packageManagerName);
var exitCode = await main(process.argv.slice(2));
// @ts-expect-error scanCommand can return an empty array in main
process.exit(exitCode);

View file

@ -9,5 +9,4 @@ const packageManagerName = "pnpm";
initializePackageManager(packageManagerName);
var exitCode = await main(process.argv.slice(2));
// @ts-expect-error scanCommand can return an empty array in main
process.exit(exitCode);

View file

@ -9,5 +9,4 @@ const packageManagerName = "pnpx";
initializePackageManager(packageManagerName);
var exitCode = await main(process.argv.slice(2));
// @ts-expect-error scanCommand can return an empty array in main
process.exit(exitCode);

View file

@ -9,5 +9,4 @@ const packageManagerName = "yarn";
initializePackageManager(packageManagerName);
var exitCode = await main(process.argv.slice(2));
// @ts-expect-error scanCommand can return an empty array in main
process.exit(exitCode);

View file

@ -7,6 +7,10 @@ export function getLoggingLevel() {
return LOGGING_SILENT;
}
if (level === LOGGING_VERBOSE) {
return LOGGING_VERBOSE;
}
return LOGGING_NORMAL;
}
@ -34,3 +38,4 @@ export function setEcoSystem(setting) {
export const LOGGING_SILENT = "silent";
export const LOGGING_NORMAL = "normal";
export const LOGGING_VERBOSE = "verbose";

View file

@ -2,12 +2,28 @@
import chalk from "chalk";
import ora from "ora";
import { isCi } from "./environment.js";
import { getLoggingLevel, LOGGING_SILENT } from "../config/settings.js";
import {
getLoggingLevel,
LOGGING_SILENT,
LOGGING_VERBOSE,
} from "../config/settings.js";
/**
* @type {{ bufferOutput: boolean, bufferedMessages:(() => void)[]}}
*/
const state = {
bufferOutput: false,
bufferedMessages: [],
};
function isSilentMode() {
return getLoggingLevel() === LOGGING_SILENT;
}
function isVerboseMode() {
return getLoggingLevel() === LOGGING_VERBOSE;
}
function emptyLine() {
if (isSilentMode()) return;
@ -22,7 +38,7 @@ function emptyLine() {
function writeInformation(message, ...optionalParams) {
if (isSilentMode()) return;
console.log(message, ...optionalParams);
writeOrBuffer(() => console.log(message, ...optionalParams));
}
/**
@ -36,7 +52,7 @@ function writeWarning(message, ...optionalParams) {
if (!isCi()) {
message = chalk.yellow(message);
}
console.warn(message, ...optionalParams);
writeOrBuffer(() => console.warn(message, ...optionalParams));
}
/**
@ -48,7 +64,7 @@ function writeError(message, ...optionalParams) {
if (!isCi()) {
message = chalk.red(message);
}
console.error(message, ...optionalParams);
writeOrBuffer(() => console.error(message, ...optionalParams));
}
function writeExitWithoutInstallingMaliciousPackages() {
@ -56,7 +72,30 @@ function writeExitWithoutInstallingMaliciousPackages() {
if (!isCi()) {
message = chalk.red(message);
}
console.error(message);
writeOrBuffer(() => console.error(message));
}
/**
* @param {string} message
* @param {...any} optionalParams
* @returns {void}
*/
function writeVerbose(message, ...optionalParams) {
if (!isVerboseMode()) return;
writeOrBuffer(() => console.log(message, ...optionalParams));
}
/**
*
* @param {() => void} messageFunction
*/
function writeOrBuffer(messageFunction) {
if (state.bufferOutput) {
state.bufferedMessages.push(messageFunction);
} else {
messageFunction();
}
}
/**
@ -114,11 +153,27 @@ function startProcess(message) {
}
}
function startBufferingLogs() {
state.bufferOutput = true;
state.bufferedMessages = [];
}
function writeBufferedLogsAndStopBuffering() {
state.bufferOutput = false;
for (const log of state.bufferedMessages) {
log();
}
state.bufferedMessages = [];
}
export const ui = {
writeVerbose,
writeInformation,
writeWarning,
writeError,
writeExitWithoutInstallingMaliciousPackages,
emptyLine,
startProcess,
startBufferingLogs,
writeBufferedLogsAndStopBuffering,
};

View file

@ -9,16 +9,18 @@ import chalk from "chalk";
/**
* @param {string[]} args
* @returns {Promise<number | never[]>}
* @returns {Promise<number>}
*/
export async function main(args) {
process.on("SIGINT", handleProcessTermination);
process.on("SIGTERM", handleProcessTermination);
const proxy = createSafeChainProxy();
await proxy.startServer();
// Global error handlers to log unhandled errors
process.on("uncaughtException", (error) => {
ui.writeError(`Safe-chain: Uncaught exception: ${error.message}`);
// @ts-expect-error writeVerbose will be added in a future PR
ui.writeVerbose(`Stack trace: ${error.stack}`);
process.exit(1);
});
@ -26,7 +28,6 @@ export async function main(args) {
process.on("unhandledRejection", (reason) => {
ui.writeError(`Safe-chain: Unhandled promise rejection: ${reason}`);
if (reason instanceof Error) {
// @ts-expect-error writeVerbose will be added in a future PR
ui.writeVerbose(`Stack trace: ${reason.stack}`);
}
process.exit(1);
@ -46,8 +47,16 @@ export async function main(args) {
}
}
// Buffer logs during package manager execution, this avoids interleaving
// of logs from the package manager and safe-chain
// Not doing this could cause bugs to disappear when cursor movement codes
// are written by the package manager while safe-chain is writing logs
ui.startBufferingLogs();
const packageManagerResult = await getPackageManager().runCommand(args);
// Write all buffered logs
ui.writeBufferedLogsAndStopBuffering();
if (!proxy.verifyNoMaliciousPackages()) {
return 1;
}
@ -72,3 +81,7 @@ export async function main(args) {
await proxy.stopServer();
}
}
function handleProcessTermination() {
ui.writeBufferedLogsAndStopBuffering();
}

View file

@ -39,7 +39,6 @@ async function runBunCommand(command, args) {
try {
const result = await safeSpawn(command, args, {
stdio: "inherit",
// @ts-expect-error values of process.env can be string | undefined
env: mergeSafeChainProxyEnvironmentVariables(process.env),
});
return { status: result.status };

View file

@ -11,7 +11,6 @@ export async function runNpm(args) {
try {
const result = await safeSpawn("npm", args, {
stdio: "inherit",
// @ts-expect-error values of process.env can be string | undefined
env: mergeSafeChainProxyEnvironmentVariables(process.env),
});
return { status: result.status };
@ -24,37 +23,3 @@ export async function runNpm(args) {
}
}
}
/**
* @param {string[]} args
* @returns {Promise<{status: number, output?: string}>}
*/
export async function dryRunNpmCommandAndOutput(args) {
try {
const result = await safeSpawn(
"npm",
[...args, "--ignore-scripts", "--dry-run"],
{
stdio: "pipe",
// @ts-expect-error values of process.env can be string | undefined
env: mergeSafeChainProxyEnvironmentVariables(process.env),
}
);
return {
status: result.status,
output: result.status === 0 ? result.stdout : result.stderr,
};
} catch (/** @type any */ error) {
if (error.status) {
const output =
error.stdout?.toString() ??
error.stderr?.toString() ??
error.message ??
"";
return { status: error.status, output };
} else {
ui.writeError("Error executing command:", error.message);
return { status: 1 };
}
}
}

View file

@ -11,7 +11,6 @@ export async function runNpx(args) {
try {
const result = await safeSpawn("npx", args, {
stdio: "inherit",
// @ts-expect-error values of process.env can be string | undefined
env: mergeSafeChainProxyEnvironmentVariables(process.env),
});
return { status: result.status };

View file

@ -13,13 +13,11 @@ export async function runPnpmCommand(args, toolName = "pnpm") {
if (toolName === "pnpm") {
result = await safeSpawn("pnpm", args, {
stdio: "inherit",
// @ts-expect-error values of process.env can be string | undefined
env: mergeSafeChainProxyEnvironmentVariables(process.env),
});
} else if (toolName === "pnpx") {
result = await safeSpawn("pnpx", args, {
stdio: "inherit",
// @ts-expect-error values of process.env can be string | undefined
env: mergeSafeChainProxyEnvironmentVariables(process.env),
});
} else {

View file

@ -9,7 +9,6 @@ import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/reg
*/
export async function runYarnCommand(args) {
try {
// @ts-expect-error values of process.env can be string | undefined
const env = mergeSafeChainProxyEnvironmentVariables(process.env);
await fixYarnProxyEnvironmentVariables(env);
@ -36,29 +35,7 @@ export async function runYarnCommand(args) {
async function fixYarnProxyEnvironmentVariables(env) {
// Yarn ignores standard proxy environment variable HTTPS_PROXY
// It does respect NODE_EXTRA_CA_CERTS for custom CA certificates though.
// Don't use YARN_HTTPS_CA_FILE_PATH though, as it causes to ignore all system CAs
// Don't use YARN_HTTPS_CA_FILE_PATH or YARN_CA_FILE_PATH though, it causes yarn to ignore all system CAs
// Yarn v2/v3 and v4+ use different environment variables for proxy and CA certs
// When setting all variables, yarn returns an error about conflicting variables
// - v2/v3: "Usage Error: Unrecognized or legacy configuration settings found: httpsCaFilePath"
// - v4+: "Usage Error: Unrecognized or legacy configuration settings found: caFilePath"
const version = await yarnVersion();
const majorVersion = parseInt(version.split(".")[0]);
if (majorVersion >= 4) {
env.YARN_HTTPS_PROXY = env.HTTPS_PROXY;
} else if (majorVersion === 2 || majorVersion === 3) {
env.YARN_HTTPS_PROXY = env.HTTPS_PROXY;
}
}
async function yarnVersion() {
const result = await safeSpawn("yarn", ["--version"], {
stdio: "pipe",
});
if (result.status !== 0) {
throw new Error("Failed to get yarn version");
}
return result.stdout.trim();
}

View file

@ -103,13 +103,13 @@ describe("runYarnCommand", () => {
);
});
it("should not set Yarn-specific proxy vars for Yarn v1", async () => {
it("should set YARN_HTTPS_PROXY for Yarn v1", async () => {
yarnVersion = "1.22.19";
await runYarnCommand(["add", "lodash"]);
assert.strictEqual(
capturedEnv.YARN_HTTPS_PROXY,
undefined,
"http://localhost:8080",
"YARN_HTTPS_PROXY should not be set for Yarn v1"
);
assert.strictEqual(

View file

@ -5,13 +5,17 @@ import { ui } from "../environment/userInteraction.js";
/**
* @param {import("http").IncomingMessage} req
* @param {import("net").Socket} clientSocket
* @param {import("http").ServerResponse} clientSocket
* @param {(target: string) => Promise<boolean>} isAllowed
*/
export function mitmConnect(req, clientSocket, isAllowed) {
ui.writeVerbose(`Safe-chain: Set up MITM tunnel for ${req.url}`);
const { hostname } = new URL(`http://${req.url}`);
clientSocket.on("error", () => {
clientSocket.on("error", (err) => {
ui.writeVerbose(
`Safe-chain: Client socket error for ${req.url}: ${err.message}`
);
// NO-OP
// This can happen if the client TCP socket sends RST instead of FIN.
// Not subscribing to 'close' event will cause node to throw and crash.
@ -21,7 +25,6 @@ export function mitmConnect(req, clientSocket, isAllowed) {
server.on("error", (err) => {
ui.writeError(`Safe-chain: HTTPS server error: ${err.message}`);
// @ts-expect-error Property 'headersSent' does not exist on type 'Socket'
if (!clientSocket.headersSent) {
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
} else if (clientSocket.writable) {
@ -51,11 +54,18 @@ function createHttpsServer(hostname, isAllowed) {
* @returns {Promise<void>}
*/
async function handleRequest(req, res) {
// @ts-expect-error req.url might be undefined
if (!req.url) {
ui.writeError("Safe-chain: Request missing URL");
res.writeHead(400, "Bad Request");
res.end("Bad Request: Missing URL");
return;
}
const pathAndQuery = getRequestPathAndQuery(req.url);
const targetUrl = `https://${hostname}${pathAndQuery}`;
if (!(await isAllowed(targetUrl))) {
ui.writeVerbose(`Safe-chain: Blocking request to ${targetUrl}`);
res.writeHead(403, "Forbidden - blocked by safe-chain");
res.end("Blocked by safe-chain");
return;
@ -96,7 +106,10 @@ function getRequestPathAndQuery(url) {
function forwardRequest(req, hostname, res) {
const proxyReq = createProxyRequest(hostname, req, res);
proxyReq.on("error", () => {
proxyReq.on("error", (err) => {
ui.writeVerbose(
`Safe-chain: Error occurred while proxying request: ${err.message}`
);
res.writeHead(502);
res.end("Bad Gateway");
});
@ -111,6 +124,9 @@ function forwardRequest(req, hostname, res) {
});
req.on("end", () => {
ui.writeVerbose(
`Safe-chain: Finished proxying request to ${req.url} for ${hostname}`
);
proxyReq.end();
});
}
@ -152,7 +168,13 @@ function createProxyRequest(hostname, req, res) {
}
});
// @ts-expect-error statusCode might be undefined
if (!proxyRes.statusCode) {
ui.writeError("Safe-chain: Proxy response missing status code");
res.writeHead(500);
res.end("Internal Server Error");
return;
}
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res);
});

View file

@ -1,5 +1,6 @@
import * as http from "http";
import * as https from "https";
import { ui } from "../environment/userInteraction.js";
/**
* @param {import("http").IncomingMessage} req
@ -8,7 +9,13 @@ import * as https from "https";
* @returns {void}
*/
export function handleHttpProxyRequest(req, res) {
// @ts-expect-error req.url might be undefined
if (!req.url) {
ui.writeError("Safe-chain: Request missing URL");
res.writeHead(400, "Bad Request");
res.end("Bad Request: Missing URL");
return;
}
const url = new URL(req.url);
// The protocol for the plainHttpProxy should usually only be http:
@ -27,11 +34,16 @@ export function handleHttpProxyRequest(req, res) {
const proxyRequest = protocol
.request(
// @ts-expect-error req.url might be undefined
req.url,
{ method: req.method, headers: req.headers },
(proxyRes) => {
// @ts-expect-error statusCode might be undefined
if (!proxyRes.statusCode) {
ui.writeError("Safe-chain: Proxy response missing status code");
res.writeHead(500);
res.end("Internal Server Error");
return;
}
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res);

View file

@ -44,7 +44,7 @@ function getSafeChainProxyEnvironmentVariables() {
}
/**
* @param {Record<string, string>} env
* @param {Record<string, string | undefined>} env
*
* @returns {Record<string, string>}
*/
@ -57,7 +57,7 @@ export function mergeSafeChainProxyEnvironmentVariables(env) {
// So we only copy the variable if it's not already set in a different case
const upperKey = key.toUpperCase();
if (!proxyEnv[upperKey]) {
if (!proxyEnv[upperKey] && env[key]) {
proxyEnv[key] = env[key];
}
}
@ -123,7 +123,7 @@ function stopServer(server) {
/**
* @param {import("http").IncomingMessage} req
* @param {import("net").Socket} clientSocket
* @param {import("http").ServerResponse} clientSocket
* @param {Buffer} head
*
* @returns {void}
@ -146,6 +146,7 @@ function handleConnect(req, clientSocket, head) {
mitmConnect(req, clientSocket, isAllowedUrl);
} 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);
}
}

View file

@ -3,7 +3,7 @@ import { ui } from "../environment/userInteraction.js";
/**
* @param {import("http").IncomingMessage} req
* @param {import("net").Socket} clientSocket
* @param {import("http").ServerResponse} clientSocket
* @param {Buffer} head
*
* @returns {void}
@ -30,7 +30,7 @@ export function tunnelRequest(req, clientSocket, head) {
/**
* @param {import("http").IncomingMessage} req
* @param {import("net").Socket} clientSocket
* @param {import("http").ServerResponse} clientSocket
* @param {Buffer} head
*
* @returns {void}
@ -38,13 +38,16 @@ export function tunnelRequest(req, clientSocket, head) {
function tunnelRequestToDestination(req, clientSocket, head) {
const { port, hostname } = new URL(`http://${req.url}`);
// @ts-expect-error port from URL is a string but net.connect accepts number
const serverSocket = net.connect(port || 443, hostname, () => {
const serverSocket = net.connect(
Number.parseInt(port) || 443,
hostname,
() => {
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
serverSocket.write(head);
serverSocket.pipe(clientSocket);
clientSocket.pipe(serverSocket);
});
}
);
clientSocket.on("error", () => {
// This can happen if the client TCP socket sends RST instead of FIN.
@ -66,7 +69,7 @@ function tunnelRequestToDestination(req, clientSocket, head) {
/**
* @param {import("http").IncomingMessage} req
* @param {import("net").Socket} clientSocket
* @param {import("http").ServerResponse} clientSocket
* @param {Buffer} head
* @param {string} proxyUrl
*/
@ -75,10 +78,9 @@ function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) {
const proxy = new URL(proxyUrl);
// Connect to proxy server
// @ts-expect-error net.connect wants port as number but proxy.port is string
const proxySocket = net.connect({
host: proxy.hostname,
port: proxy.port,
port: Number.parseInt(proxy.port) || 80,
});
proxySocket.on("connect", () => {

View file

@ -1,3 +1,4 @@
import { ui } from "../../environment/userInteraction.js";
import {
MALWARE_STATUS_MALWARE,
openMalwareDatabase,
@ -40,8 +41,14 @@ export async function auditChanges(changes) {
);
if (malwarePackage) {
ui.writeVerbose(
`Safe-chain: Package ${change.name}@${change.version} is marked as malware: ${malwarePackage.status}`
);
disallowedChanges.push({ ...change, reason: malwarePackage.status });
} else {
ui.writeVerbose(
`Safe-chain: Package ${change.name}@${change.version} is clean`
);
allowedChanges.push(change);
}
}

View file

@ -21,11 +21,11 @@ export function shouldScanCommand(args) {
/**
* @param {string[]} args
*
* @returns {Promise<number | never[]>}
* @returns {Promise<number>}
*/
export async function scanCommand(args) {
if (!shouldScanCommand(args)) {
return [];
return 0;
}
let timedOut = false;

View file

@ -91,10 +91,19 @@ async function getMalwareDatabase() {
}
const { malwareDatabase, version } = await fetchMalwareDatabase();
// @ts-expect-error version can be undefined
writeDatabaseToLocalCache(malwareDatabase, version);
if (version) {
// Only cache the malware database when we have a version.
writeDatabaseToLocalCache(malwareDatabase, version);
return malwareDatabase;
} else {
// We received a valid malware database, but the response
// did not contain an etag header with the version
ui.writeWarning(
"The malware database was downloaded, but could not be cached due to a missing version."
);
return malwareDatabase;
}
} catch (/** @type any */ error) {
if (cachedDatabase) {
ui.writeWarning(

View file

@ -67,8 +67,6 @@ function resolveCommandPath(command) {
// Use 'command -v' to find the full path
const fullPath = execSync(`command -v ${command}`, {
encoding: "utf8",
// @ts-expect-error shell is a string option
shell: true,
}).trim();
if (!fullPath) {
@ -120,8 +118,12 @@ export async function safeSpawn(command, args, options = {}) {
});
child.on("close", (code) => {
// Code is null if it terminated by a signal. This should never
// happen in our code. If this happens, return 1 error code.
code = code ?? 1;
resolve({
// @ts-expect-error code can be null
status: code,
stdout: stdout,
stderr: stderr,