mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 20:20:49 +00:00
Consume the safe chain proxy min package age reporting webhook
This commit is contained in:
parent
127447d425
commit
ceefaabe57
6 changed files with 115 additions and 43 deletions
|
|
@ -23,9 +23,15 @@ export async function main(args) {
|
|||
/** @type {import("./registryProxy/registryProxy.js").MalwareBlockedEvent[]} */
|
||||
let malwareBlockedEvents = [];
|
||||
|
||||
/** @type {import("./registryProxy/registryProxy.js").MinPackageAgeSuppressionEvent[]} */
|
||||
let suppressedVersionEvents = [];
|
||||
|
||||
const proxy = createSafeChainProxy();
|
||||
await proxy.startServer();
|
||||
proxy.addListener("malwareBlocked", (ev) => malwareBlockedEvents.push(ev));
|
||||
proxy.addListener("minPackageAgeVersionsSuppressed", (ev) =>
|
||||
suppressedVersionEvents.push(ev),
|
||||
);
|
||||
|
||||
// Global error handlers to log unhandled errors
|
||||
process.on("uncaughtException", (error) => {
|
||||
|
|
@ -82,17 +88,8 @@ export async function main(args) {
|
|||
);
|
||||
}
|
||||
|
||||
if (proxy.hasSuppressedVersions()) {
|
||||
ui.writeInformation(
|
||||
`${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",
|
||||
)}`,
|
||||
);
|
||||
if (suppressedVersionEvents.length > 0) {
|
||||
printSuppressedVersions(suppressedVersionEvents);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
ui.emptyLine();
|
||||
|
|
@ -144,3 +141,25 @@ function printBlockedMalware(malwareBlockedEvents) {
|
|||
ui.writeExitWithoutInstallingMaliciousPackages();
|
||||
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",
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import { mitmConnect } from "./mitmRequestHandler.js";
|
|||
import { handleHttpProxyRequest } from "./plainHttpProxy.js";
|
||||
import { ui } from "../../environment/userInteraction.js";
|
||||
import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js";
|
||||
import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js";
|
||||
import { getCaCertPath } from "./certUtils.js";
|
||||
import { readFileSync } from "fs";
|
||||
import EventEmitter from "events";
|
||||
import { modifyResponseEventEmitter } from "./interceptors/npm/modifyNpmInfo.js";
|
||||
|
||||
/** *
|
||||
* @returns {import("../registryProxy.js").SafeChainProxy} */
|
||||
|
|
@ -22,6 +22,10 @@ export function createBuiltInProxyServer() {
|
|||
/** @type {EventEmitter<import("../registryProxy.js").ProxyServerEvents>} */
|
||||
const emitter = new EventEmitter();
|
||||
|
||||
modifyResponseEventEmitter.addListener("versionsRemoved", (ev) => {
|
||||
emitter.emit("minPackageAgeVersionsSuppressed", ev);
|
||||
});
|
||||
|
||||
const server = http.createServer(
|
||||
// This handles direct HTTP requests (non-CONNECT requests)
|
||||
// This is normally http-only traffic, but we also handle
|
||||
|
|
@ -35,7 +39,6 @@ export function createBuiltInProxyServer() {
|
|||
return Object.assign(emitter, {
|
||||
startServer: () => startServer(server),
|
||||
stopServer: () => stopServer(server),
|
||||
hasSuppressedVersions: getHasSuppressedVersions,
|
||||
getServerPort: () => state.port,
|
||||
getCaCert,
|
||||
});
|
||||
|
|
@ -104,7 +107,7 @@ export function createBuiltInProxyServer() {
|
|||
) => {
|
||||
emitter.emit("malwareBlocked", {
|
||||
packageName: event.packageName,
|
||||
packageVersion: event.version
|
||||
packageVersion: event.version,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { EventEmitter } from "stream";
|
||||
import {
|
||||
getMinimumPackageAgeHours,
|
||||
getNpmMinimumPackageAgeExclusions,
|
||||
|
|
@ -5,9 +6,8 @@ import {
|
|||
import { ui } from "../../../../environment/userInteraction.js";
|
||||
import { getHeaderValueAsString } from "../../http-utils.js";
|
||||
|
||||
const state = {
|
||||
hasSuppressedVersions: false,
|
||||
};
|
||||
/** @type {EventEmitter<{ versionsRemoved: [{packageName: string, packageVersions: string[]}] }>} */
|
||||
export const modifyResponseEventEmitter = new EventEmitter();
|
||||
|
||||
/**
|
||||
* @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
|
||||
const packageName = bodyJson.name;
|
||||
const exclusions = getNpmMinimumPackageAgeExclusions();
|
||||
if (packageName && exclusions.some((pattern) => matchesExclusionPattern(packageName, pattern))) {
|
||||
if (
|
||||
packageName &&
|
||||
exclusions.some((pattern) =>
|
||||
matchesExclusionPattern(packageName, pattern),
|
||||
)
|
||||
) {
|
||||
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;
|
||||
}
|
||||
|
||||
const cutOff = new Date(
|
||||
new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000
|
||||
new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000,
|
||||
);
|
||||
|
||||
const hasLatestTag = !!bodyJson["dist-tags"]["latest"];
|
||||
|
|
@ -91,9 +96,13 @@ export function modifyNpmInfoResponse(body, headers) {
|
|||
}))
|
||||
.filter((x) => x.version !== "created" && x.version !== "modified");
|
||||
|
||||
const removedVersions = [];
|
||||
|
||||
for (const { version, timestamp } of versions) {
|
||||
const timestampValue = new Date(timestamp);
|
||||
if (timestampValue > cutOff) {
|
||||
removedVersions.push(version);
|
||||
|
||||
deleteVersionFromJson(bodyJson, version);
|
||||
if (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);
|
||||
}
|
||||
|
||||
if (removedVersions.length > 0) {
|
||||
modifyResponseEventEmitter.emit("versionsRemoved", {
|
||||
packageName: packageName,
|
||||
packageVersions: removedVersions,
|
||||
});
|
||||
}
|
||||
|
||||
return Buffer.from(JSON.stringify(bodyJson));
|
||||
} catch (/** @type {any} */ err) {
|
||||
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;
|
||||
}
|
||||
|
|
@ -127,12 +143,10 @@ export function modifyNpmInfoResponse(body, headers) {
|
|||
* @param {string} version
|
||||
*/
|
||||
function deleteVersionFromJson(json, version) {
|
||||
state.hasSuppressedVersions = true;
|
||||
|
||||
const packageName = typeof json?.name === "string" ? json.name : "(unknown)";
|
||||
|
||||
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];
|
||||
|
|
@ -151,18 +165,20 @@ function deleteVersionFromJson(json, version) {
|
|||
*/
|
||||
function calculateLatestTag(tagList) {
|
||||
const entries = Object.entries(tagList).filter(
|
||||
([version, _]) => version !== "created" && version !== "modified"
|
||||
([version, _]) => version !== "created" && version !== "modified",
|
||||
);
|
||||
|
||||
const latestFullRelease = getMostRecentTag(
|
||||
Object.fromEntries(entries.filter(([version, _]) => !version.includes("-")))
|
||||
Object.fromEntries(
|
||||
entries.filter(([version, _]) => !version.includes("-")),
|
||||
),
|
||||
);
|
||||
if (latestFullRelease) {
|
||||
return latestFullRelease;
|
||||
}
|
||||
|
||||
const latestPrerelease = getMostRecentTag(
|
||||
Object.fromEntries(entries.filter(([version, _]) => version.includes("-")))
|
||||
Object.fromEntries(entries.filter(([version, _]) => version.includes("-"))),
|
||||
);
|
||||
return latestPrerelease;
|
||||
}
|
||||
|
|
@ -184,13 +200,6 @@ function getMostRecentTag(tagList) {
|
|||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function getHasSuppressedVersions() {
|
||||
return state.hasSuppressedVersions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a package name matches an exclusion pattern.
|
||||
* Supports trailing wildcard (*) for prefix matching.
|
||||
|
|
|
|||
|
|
@ -54,6 +54,12 @@ export function createRamaProxy(ramaPath) {
|
|||
packageVersion: ev.artifact.version,
|
||||
}),
|
||||
);
|
||||
reportingServer.addListener("minPackageAgeSuppressionReceived", (ev) =>
|
||||
emitter.emit("minPackageAgeVersionsSuppressed", {
|
||||
packageName: ev.artifact.identifier,
|
||||
packageVersions: ev.artifact.suppressed_versions,
|
||||
})
|
||||
)
|
||||
ui.writeVerbose(
|
||||
`Started reporting server at ${reportingServer.getAddress()}`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
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.end();
|
||||
}
|
||||
|
|
@ -75,12 +86,30 @@ export function getReportingServer() {
|
|||
* @param {http.IncomingMessage} req
|
||||
* @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) => {
|
||||
/** @type {Buffer[]} */
|
||||
const chunks = [];
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,16 +7,22 @@ import { getCombinedCaBundlePath } from "./certBundle.js";
|
|||
* @typedef {Object} MalwareBlockedEvent
|
||||
* @prop {string} packageName
|
||||
* @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"
|
||||
* @typedef {EventEmitter<ProxyServerEvents> & {
|
||||
* startServer: () => Promise<void>
|
||||
* stopServer: () => Promise<void>
|
||||
* getServerPort: () => Number | null
|
||||
* getCaCert: () => string | null
|
||||
* hasSuppressedVersions: () => boolean
|
||||
* }} SafeChainProxy
|
||||
*
|
||||
* @typedef {Object} ProxySettings
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue