Consume the safe chain proxy min package age reporting webhook

This commit is contained in:
Sander Declerck 2026-03-10 11:46:27 +01:00
parent 127447d425
commit ceefaabe57
No known key found for this signature in database
6 changed files with 115 additions and 43 deletions

View file

@ -23,9 +23,15 @@ export async function main(args) {
/** @type {import("./registryProxy/registryProxy.js").MalwareBlockedEvent[]} */ /** @type {import("./registryProxy/registryProxy.js").MalwareBlockedEvent[]} */
let malwareBlockedEvents = []; let malwareBlockedEvents = [];
/** @type {import("./registryProxy/registryProxy.js").MinPackageAgeSuppressionEvent[]} */
let suppressedVersionEvents = [];
const proxy = createSafeChainProxy(); const proxy = createSafeChainProxy();
await proxy.startServer(); await proxy.startServer();
proxy.addListener("malwareBlocked", (ev) => malwareBlockedEvents.push(ev)); proxy.addListener("malwareBlocked", (ev) => malwareBlockedEvents.push(ev));
proxy.addListener("minPackageAgeVersionsSuppressed", (ev) =>
suppressedVersionEvents.push(ev),
);
// Global error handlers to log unhandled errors // Global error handlers to log unhandled errors
process.on("uncaughtException", (error) => { process.on("uncaughtException", (error) => {
@ -82,17 +88,8 @@ export async function main(args) {
); );
} }
if (proxy.hasSuppressedVersions()) { if (suppressedVersionEvents.length > 0) {
ui.writeInformation( printSuppressedVersions(suppressedVersionEvents);
`${chalk.yellow(
"",
)} Safe-chain: Some package versions were suppressed due to minimum age requirement.`,
);
ui.writeInformation(
` To disable this check, use: ${chalk.cyan(
"--safe-chain-skip-minimum-package-age",
)}`,
);
} }
// Returning the exit code back to the caller allows the promise // Returning the exit code back to the caller allows the promise
@ -124,8 +121,8 @@ function isSafeChainVerify(args) {
} }
/** /**
* *
* @param {import("./registryProxy/registryProxy.js").MalwareBlockedEvent[]} malwareBlockedEvents * @param {import("./registryProxy/registryProxy.js").MalwareBlockedEvent[]} malwareBlockedEvents
*/ */
function printBlockedMalware(malwareBlockedEvents) { function printBlockedMalware(malwareBlockedEvents) {
ui.emptyLine(); ui.emptyLine();
@ -144,3 +141,25 @@ function printBlockedMalware(malwareBlockedEvents) {
ui.writeExitWithoutInstallingMaliciousPackages(); ui.writeExitWithoutInstallingMaliciousPackages();
ui.emptyLine(); ui.emptyLine();
} }
/**
*
* @param {import("./registryProxy/registryProxy.js").MinPackageAgeSuppressionEvent[]} minPackageAgeSuppressionEvents
*/
function printSuppressedVersions(minPackageAgeSuppressionEvents) {
ui.writeVerbose(
`${chalk.yellow(
"",
)} Safe-chain: Suppressed package versions due to minimum age requirement:`,
);
for (const ev of minPackageAgeSuppressionEvents) {
ui.writeVerbose(` - ${ev.packageName} (${ev.packageVersions.join(", ")})`);
}
ui.writeVerbose(
` To disable this check, use: ${chalk.cyan(
"--safe-chain-skip-minimum-package-age",
)}`,
);
}

View file

@ -4,10 +4,10 @@ import { mitmConnect } from "./mitmRequestHandler.js";
import { handleHttpProxyRequest } from "./plainHttpProxy.js"; import { handleHttpProxyRequest } from "./plainHttpProxy.js";
import { ui } from "../../environment/userInteraction.js"; import { ui } from "../../environment/userInteraction.js";
import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js"; import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js";
import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js";
import { getCaCertPath } from "./certUtils.js"; import { getCaCertPath } from "./certUtils.js";
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import EventEmitter from "events"; import EventEmitter from "events";
import { modifyResponseEventEmitter } from "./interceptors/npm/modifyNpmInfo.js";
/** * /** *
* @returns {import("../registryProxy.js").SafeChainProxy} */ * @returns {import("../registryProxy.js").SafeChainProxy} */
@ -22,6 +22,10 @@ export function createBuiltInProxyServer() {
/** @type {EventEmitter<import("../registryProxy.js").ProxyServerEvents>} */ /** @type {EventEmitter<import("../registryProxy.js").ProxyServerEvents>} */
const emitter = new EventEmitter(); const emitter = new EventEmitter();
modifyResponseEventEmitter.addListener("versionsRemoved", (ev) => {
emitter.emit("minPackageAgeVersionsSuppressed", ev);
});
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
@ -35,7 +39,6 @@ export function createBuiltInProxyServer() {
return Object.assign(emitter, { return Object.assign(emitter, {
startServer: () => startServer(server), startServer: () => startServer(server),
stopServer: () => stopServer(server), stopServer: () => stopServer(server),
hasSuppressedVersions: getHasSuppressedVersions,
getServerPort: () => state.port, getServerPort: () => state.port,
getCaCert, getCaCert,
}); });
@ -104,7 +107,7 @@ export function createBuiltInProxyServer() {
) => { ) => {
emitter.emit("malwareBlocked", { emitter.emit("malwareBlocked", {
packageName: event.packageName, packageName: event.packageName,
packageVersion: event.version packageVersion: event.version,
}); });
}, },
); );

View file

@ -1,3 +1,4 @@
import { EventEmitter } from "stream";
import { import {
getMinimumPackageAgeHours, getMinimumPackageAgeHours,
getNpmMinimumPackageAgeExclusions, getNpmMinimumPackageAgeExclusions,
@ -5,9 +6,8 @@ import {
import { ui } from "../../../../environment/userInteraction.js"; import { ui } from "../../../../environment/userInteraction.js";
import { getHeaderValueAsString } from "../../http-utils.js"; import { getHeaderValueAsString } from "../../http-utils.js";
const state = { /** @type {EventEmitter<{ versionsRemoved: [{packageName: string, packageVersions: string[]}] }>} */
hasSuppressedVersions: false, export const modifyResponseEventEmitter = new EventEmitter();
};
/** /**
* @param {NodeJS.Dict<string | string[]>} headers * @param {NodeJS.Dict<string | string[]>} headers
@ -71,15 +71,20 @@ export function modifyNpmInfoResponse(body, headers) {
// Check if this package is excluded from minimum age filtering // Check if this package is excluded from minimum age filtering
const packageName = bodyJson.name; const packageName = bodyJson.name;
const exclusions = getNpmMinimumPackageAgeExclusions(); const exclusions = getNpmMinimumPackageAgeExclusions();
if (packageName && exclusions.some((pattern) => matchesExclusionPattern(packageName, pattern))) { if (
packageName &&
exclusions.some((pattern) =>
matchesExclusionPattern(packageName, pattern),
)
) {
ui.writeVerbose( ui.writeVerbose(
`Safe-chain: ${packageName} is excluded from minimum package age filtering (minimumPackageAgeExclusions setting).` `Safe-chain: ${packageName} is excluded from minimum package age filtering (minimumPackageAgeExclusions setting).`,
); );
return body; return body;
} }
const cutOff = new Date( const cutOff = new Date(
new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000 new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000,
); );
const hasLatestTag = !!bodyJson["dist-tags"]["latest"]; const hasLatestTag = !!bodyJson["dist-tags"]["latest"];
@ -91,9 +96,13 @@ export function modifyNpmInfoResponse(body, headers) {
})) }))
.filter((x) => x.version !== "created" && x.version !== "modified"); .filter((x) => x.version !== "created" && x.version !== "modified");
const removedVersions = [];
for (const { version, timestamp } of versions) { for (const { version, timestamp } of versions) {
const timestampValue = new Date(timestamp); const timestampValue = new Date(timestamp);
if (timestampValue > cutOff) { if (timestampValue > cutOff) {
removedVersions.push(version);
deleteVersionFromJson(bodyJson, version); deleteVersionFromJson(bodyJson, version);
if (headers) { if (headers) {
// When modifying the response, the etag and last-modified headers // When modifying the response, the etag and last-modified headers
@ -113,10 +122,17 @@ export function modifyNpmInfoResponse(body, headers) {
bodyJson["dist-tags"]["latest"] = calculateLatestTag(bodyJson.time); bodyJson["dist-tags"]["latest"] = calculateLatestTag(bodyJson.time);
} }
if (removedVersions.length > 0) {
modifyResponseEventEmitter.emit("versionsRemoved", {
packageName: packageName,
packageVersions: removedVersions,
});
}
return Buffer.from(JSON.stringify(bodyJson)); return Buffer.from(JSON.stringify(bodyJson));
} catch (/** @type {any} */ err) { } catch (/** @type {any} */ err) {
ui.writeVerbose( ui.writeVerbose(
`Safe-chain: Package metadata not in expected format - bypassing modification. Error: ${err.message}` `Safe-chain: Package metadata not in expected format - bypassing modification. Error: ${err.message}`,
); );
return body; return body;
} }
@ -127,12 +143,10 @@ export function modifyNpmInfoResponse(body, headers) {
* @param {string} version * @param {string} version
*/ */
function deleteVersionFromJson(json, version) { function deleteVersionFromJson(json, version) {
state.hasSuppressedVersions = true;
const packageName = typeof json?.name === "string" ? json.name : "(unknown)"; const packageName = typeof json?.name === "string" ? json.name : "(unknown)";
ui.writeVerbose( ui.writeVerbose(
`Safe-chain: ${packageName}@${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).` `Safe-chain: ${packageName}@${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).`,
); );
delete json.time[version]; delete json.time[version];
@ -151,18 +165,20 @@ function deleteVersionFromJson(json, version) {
*/ */
function calculateLatestTag(tagList) { function calculateLatestTag(tagList) {
const entries = Object.entries(tagList).filter( const entries = Object.entries(tagList).filter(
([version, _]) => version !== "created" && version !== "modified" ([version, _]) => version !== "created" && version !== "modified",
); );
const latestFullRelease = getMostRecentTag( const latestFullRelease = getMostRecentTag(
Object.fromEntries(entries.filter(([version, _]) => !version.includes("-"))) Object.fromEntries(
entries.filter(([version, _]) => !version.includes("-")),
),
); );
if (latestFullRelease) { if (latestFullRelease) {
return latestFullRelease; return latestFullRelease;
} }
const latestPrerelease = getMostRecentTag( const latestPrerelease = getMostRecentTag(
Object.fromEntries(entries.filter(([version, _]) => version.includes("-"))) Object.fromEntries(entries.filter(([version, _]) => version.includes("-"))),
); );
return latestPrerelease; return latestPrerelease;
} }
@ -184,13 +200,6 @@ function getMostRecentTag(tagList) {
return current; return current;
} }
/**
* @returns {boolean}
*/
export function getHasSuppressedVersions() {
return state.hasSuppressedVersions;
}
/** /**
* Checks if a package name matches an exclusion pattern. * Checks if a package name matches an exclusion pattern.
* Supports trailing wildcard (*) for prefix matching. * Supports trailing wildcard (*) for prefix matching.

View file

@ -54,6 +54,12 @@ export function createRamaProxy(ramaPath) {
packageVersion: ev.artifact.version, packageVersion: ev.artifact.version,
}), }),
); );
reportingServer.addListener("minPackageAgeSuppressionReceived", (ev) =>
emitter.emit("minPackageAgeVersionsSuppressed", {
packageName: ev.artifact.identifier,
packageVersions: ev.artifact.suppressed_versions,
})
)
ui.writeVerbose( ui.writeVerbose(
`Started reporting server at ${reportingServer.getAddress()}`, `Started reporting server at ${reportingServer.getAddress()}`,
); );

View file

@ -10,7 +10,13 @@ const SERVER_STOP_TIMEOUT_MS = 1000;
*/ */
/** /**
* @typedef {{ blockReceived: [BlockEvent] }} ReportingServerEvents * @typedef {Object} MinPackageAgeEvent
* @property {number} ts_ms
* @property {{ product: string, identifier: string, suppressed_versions: string[] }} artifact
*/
/**
* @typedef {{ blockReceived: [BlockEvent], minPackageAgeSuppressionReceived: [MinPackageAgeEvent] }} ReportingServerEvents
*/ */
/** /**
@ -38,6 +44,11 @@ export function getReportingServer() {
emitter.emit("blockReceived", blockEvent); emitter.emit("blockReceived", blockEvent);
}); });
} }
else if (req.method === "POST" && req.url?.startsWith("/events/min-package-age")) {
await parseMinPackageAgeEventFromRequest(req).then((minPackageAgeEvent) => {
emitter.emit("minPackageAgeSuppressionReceived", minPackageAgeEvent);
});
}
res.writeHead(200); res.writeHead(200);
res.end(); res.end();
} }
@ -75,12 +86,30 @@ export function getReportingServer() {
* @param {http.IncomingMessage} req * @param {http.IncomingMessage} req
* @returns {Promise<BlockEvent>} * @returns {Promise<BlockEvent>}
*/ */
function parseBlockEventFromRequest(req) { async function parseBlockEventFromRequest(req) {
const requestData = await getRequestDataAsString(req);
return JSON.parse(requestData);
}
/**
* @param {http.IncomingMessage} req
* @returns {Promise<MinPackageAgeEvent>}
*/
async function parseMinPackageAgeEventFromRequest(req) {
const requestData = await getRequestDataAsString(req);
return JSON.parse(requestData);
}
/**
* @param {http.IncomingMessage} req
* @returns {Promise<string>}
*/
function getRequestDataAsString(req) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
/** @type {Buffer[]} */ /** @type {Buffer[]} */
const chunks = []; const chunks = [];
req.on("data", (chunk) => chunks.push(chunk)); req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => resolve(JSON.parse(Buffer.concat(chunks).toString()))); req.on("end", () => resolve(Buffer.concat(chunks).toString()));
req.on("error", reject); req.on("error", reject);
}); });
} }

View file

@ -7,16 +7,22 @@ import { getCombinedCaBundlePath } from "./certBundle.js";
* @typedef {Object} MalwareBlockedEvent * @typedef {Object} MalwareBlockedEvent
* @prop {string} packageName * @prop {string} packageName
* @prop {string} packageVersion * @prop {string} packageVersion
* *
* @typedef {{ malwareBlocked: [MalwareBlockedEvent] }} ProxyServerEvents * @typedef {Object} MinPackageAgeSuppressionEvent
* * @prop {string} packageName
* @prop {string[]} packageVersions
*
* @typedef {{
* malwareBlocked: [MalwareBlockedEvent],
* minPackageAgeVersionsSuppressed: [MinPackageAgeSuppressionEvent]
* }} ProxyServerEvents
*
* @import { EventEmitter } from "node:stream" * @import { EventEmitter } from "node:stream"
* @typedef {EventEmitter<ProxyServerEvents> & { * @typedef {EventEmitter<ProxyServerEvents> & {
* startServer: () => Promise<void> * startServer: () => Promise<void>
* stopServer: () => Promise<void> * stopServer: () => Promise<void>
* getServerPort: () => Number | null * getServerPort: () => Number | null
* getCaCert: () => string | null * getCaCert: () => string | null
* hasSuppressedVersions: () => boolean
* }} SafeChainProxy * }} SafeChainProxy
* *
* @typedef {Object} ProxySettings * @typedef {Object} ProxySettings