mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Merge pull request #332 from AikidoSec/rama-min-package-age-reporting
Rama min package age reporting
This commit is contained in:
commit
eedbac7e28
11 changed files with 188 additions and 101 deletions
|
|
@ -24,10 +24,15 @@ export async function main(args) {
|
||||||
let malwareBlockedEvents = [];
|
let malwareBlockedEvents = [];
|
||||||
/** @type {import("./registryProxy/registryProxy.js").PackageBlockedEvent[]} */
|
/** @type {import("./registryProxy/registryProxy.js").PackageBlockedEvent[]} */
|
||||||
let minPackageAgeBlocks = [];
|
let minPackageAgeBlocks = [];
|
||||||
|
/** @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),
|
||||||
|
);
|
||||||
proxy.addListener("minimumAgeRequestBlocked", (ev) =>
|
proxy.addListener("minimumAgeRequestBlocked", (ev) =>
|
||||||
minPackageAgeBlocks.push(ev),
|
minPackageAgeBlocks.push(ev),
|
||||||
);
|
);
|
||||||
|
|
@ -92,17 +97,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 during package metadata resolution due to minimum package age.`,
|
|
||||||
);
|
|
||||||
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
|
||||||
|
|
@ -184,3 +180,25 @@ function printMinPackageAgeBlocks(minPackageAgeBlocks) {
|
||||||
);
|
);
|
||||||
ui.emptyLine();
|
ui.emptyLine();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {import("./registryProxy/registryProxy.js").MinPackageAgeSuppressionEvent[]} minPackageAgeSuppressionEvents
|
||||||
|
*/
|
||||||
|
function printSuppressedVersions(minPackageAgeSuppressionEvents) {
|
||||||
|
ui.writeVerbose(
|
||||||
|
`${chalk.yellow(
|
||||||
|
"ℹ",
|
||||||
|
)} Safe-chain: Some package versions were suppressed during package metadata resolution due to minimum package age:`,
|
||||||
|
);
|
||||||
|
|
||||||
|
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",
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,9 @@ import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoS
|
||||||
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";
|
||||||
|
import { modifyPipResponseEventEmitter } from "./interceptors/pip/modifyPipInfo.js";
|
||||||
import { cleanupCertBundle } from "../certBundle.js";
|
import { cleanupCertBundle } from "../certBundle.js";
|
||||||
import { getHasSuppressedVersions } from "./interceptors/suppressedVersionsState.js";
|
|
||||||
|
|
||||||
/** *
|
/** *
|
||||||
* @returns {import("../registryProxy.js").SafeChainProxy} */
|
* @returns {import("../registryProxy.js").SafeChainProxy} */
|
||||||
|
|
@ -23,6 +24,14 @@ 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
modifyPipResponseEventEmitter.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
|
||||||
|
|
@ -36,7 +45,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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ const mockMitmConnect = mock.fn();
|
||||||
const mockTunnelRequest = mock.fn();
|
const mockTunnelRequest = mock.fn();
|
||||||
const mockUi = { writeVerbose: mock.fn() };
|
const mockUi = { writeVerbose: mock.fn() };
|
||||||
const mockGetCaCertPath = mock.fn(() => "/fake/cert/path");
|
const mockGetCaCertPath = mock.fn(() => "/fake/cert/path");
|
||||||
const mockGetHasSuppressedVersions = mock.fn(() => false);
|
const mockModifyResponseEventEmitter = new EventEmitter();
|
||||||
|
|
||||||
/** @type {import("./interceptors/interceptorBuilder.js").Interceptor | undefined} */
|
/** @type {import("./interceptors/interceptorBuilder.js").Interceptor | undefined} */
|
||||||
let mockInterceptor;
|
let mockInterceptor;
|
||||||
|
|
@ -30,7 +30,7 @@ mock.module("./interceptors/createInterceptorForEcoSystem.js", {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
mock.module("./interceptors/npm/modifyNpmInfo.js", {
|
mock.module("./interceptors/npm/modifyNpmInfo.js", {
|
||||||
namedExports: { getHasSuppressedVersions: mockGetHasSuppressedVersions },
|
namedExports: { modifyResponseEventEmitter: mockModifyResponseEventEmitter },
|
||||||
});
|
});
|
||||||
mock.module("./certUtils.js", {
|
mock.module("./certUtils.js", {
|
||||||
namedExports: { getCaCertPath: mockGetCaCertPath },
|
namedExports: { getCaCertPath: mockGetCaCertPath },
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
|
import { EventEmitter } from "events";
|
||||||
import { getMinimumPackageAgeHours } from "../../../../config/settings.js";
|
import { getMinimumPackageAgeHours } from "../../../../config/settings.js";
|
||||||
import { ui } from "../../../../environment/userInteraction.js";
|
import { ui } from "../../../../environment/userInteraction.js";
|
||||||
import { clearCachingHeaders, getHeaderValueAsString } from "../../http-utils.js";
|
import {
|
||||||
import { recordSuppressedVersion } from "../suppressedVersionsState.js";
|
clearCachingHeaders,
|
||||||
|
getHeaderValueAsString,
|
||||||
|
} from "../../http-utils.js";
|
||||||
|
|
||||||
|
/** @type {EventEmitter<{ versionsRemoved: [{packageName: string, packageVersions: string[]}] }>} */
|
||||||
|
export const modifyResponseEventEmitter = new EventEmitter();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {NodeJS.Dict<string | string[]>} headers
|
* @param {NodeJS.Dict<string | string[]>} headers
|
||||||
|
|
@ -62,8 +68,10 @@ export function modifyNpmInfoResponse(body, headers) {
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const packageName = bodyJson.name;
|
||||||
|
|
||||||
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"];
|
||||||
|
|
@ -75,9 +83,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);
|
||||||
clearCachingHeaders(headers);
|
clearCachingHeaders(headers);
|
||||||
}
|
}
|
||||||
|
|
@ -89,10 +101,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;
|
||||||
}
|
}
|
||||||
|
|
@ -103,12 +122,10 @@ export function modifyNpmInfoResponse(body, headers) {
|
||||||
* @param {string} version
|
* @param {string} version
|
||||||
*/
|
*/
|
||||||
function deleteVersionFromJson(json, version) {
|
function deleteVersionFromJson(json, version) {
|
||||||
recordSuppressedVersion();
|
|
||||||
|
|
||||||
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];
|
||||||
|
|
@ -127,18 +144,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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { EventEmitter } from "events";
|
||||||
import { ui } from "../../../../environment/userInteraction.js";
|
import { ui } from "../../../../environment/userInteraction.js";
|
||||||
import { clearCachingHeaders } from "../../http-utils.js";
|
import { clearCachingHeaders } from "../../http-utils.js";
|
||||||
import { normalizePipPackageName } from "../../../../scanning/packageNameVariants.js";
|
import { normalizePipPackageName } from "../../../../scanning/packageNameVariants.js";
|
||||||
|
|
@ -6,6 +7,9 @@ export { parsePipMetadataUrl, isPipPackageInfoUrl } from "./parsePipPackageUrl.j
|
||||||
import { getPipMetadataContentType, logSuppressedVersion } from "./pipMetadataResponseUtils.js";
|
import { getPipMetadataContentType, logSuppressedVersion } from "./pipMetadataResponseUtils.js";
|
||||||
import { modifyPipJsonResponse } from "./modifyPipJsonResponse.js";
|
import { modifyPipJsonResponse } from "./modifyPipJsonResponse.js";
|
||||||
|
|
||||||
|
/** @type {EventEmitter<{ versionsRemoved: [{packageName: string, packageVersions: string[]}] }>} */
|
||||||
|
export const modifyPipResponseEventEmitter = new EventEmitter();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strip conditional GET headers so PyPI always returns a full 200 response
|
* Strip conditional GET headers so PyPI always returns a full 200 response
|
||||||
* with a body we can rewrite. Without this, pip sends If-None-Match /
|
* with a body we can rewrite. Without this, pip sends If-None-Match /
|
||||||
|
|
@ -50,33 +54,42 @@ export function modifyPipInfoResponse(
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {{ buffer: Buffer, suppressedVersions: string[] } | undefined} */
|
||||||
|
let result;
|
||||||
if (
|
if (
|
||||||
contentType.includes("html") ||
|
contentType.includes("html") ||
|
||||||
contentType.includes("application/vnd.pypi.simple.v1+html")
|
contentType.includes("application/vnd.pypi.simple.v1+html")
|
||||||
) {
|
) {
|
||||||
return modifyHtmlSimpleResponse(
|
result = modifyHtmlSimpleResponse(
|
||||||
body,
|
body,
|
||||||
headers,
|
headers,
|
||||||
metadataUrl,
|
metadataUrl,
|
||||||
isNewlyReleasedPackage,
|
isNewlyReleasedPackage,
|
||||||
packageName
|
packageName
|
||||||
);
|
);
|
||||||
}
|
} else if (
|
||||||
|
|
||||||
if (
|
|
||||||
contentType.includes("json") ||
|
contentType.includes("json") ||
|
||||||
contentType.includes("application/vnd.pypi.simple.v1+json")
|
contentType.includes("application/vnd.pypi.simple.v1+json")
|
||||||
) {
|
) {
|
||||||
return modifyJsonResponse(
|
result = modifyJsonResponse(
|
||||||
body,
|
body,
|
||||||
headers,
|
headers,
|
||||||
metadataUrl,
|
metadataUrl,
|
||||||
isNewlyReleasedPackage,
|
isNewlyReleasedPackage,
|
||||||
packageName
|
packageName
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
return body;
|
if (result.suppressedVersions.length > 0) {
|
||||||
|
modifyPipResponseEventEmitter.emit("versionsRemoved", {
|
||||||
|
packageName,
|
||||||
|
packageVersions: result.suppressedVersions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.buffer;
|
||||||
} catch (/** @type {any} */ err) {
|
} catch (/** @type {any} */ err) {
|
||||||
ui.writeVerbose(
|
ui.writeVerbose(
|
||||||
`Safe-chain: PyPI package metadata not in expected format - bypassing modification. Error: ${err.message}`
|
`Safe-chain: PyPI package metadata not in expected format - bypassing modification. Error: ${err.message}`
|
||||||
|
|
@ -91,7 +104,7 @@ export function modifyPipInfoResponse(
|
||||||
* @param {string} metadataUrl
|
* @param {string} metadataUrl
|
||||||
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
||||||
* @param {string} packageName
|
* @param {string} packageName
|
||||||
* @returns {Buffer}
|
* @returns {{ buffer: Buffer, suppressedVersions: string[] }}
|
||||||
*/
|
*/
|
||||||
function modifyHtmlSimpleResponse(
|
function modifyHtmlSimpleResponse(
|
||||||
body,
|
body,
|
||||||
|
|
@ -101,35 +114,35 @@ function modifyHtmlSimpleResponse(
|
||||||
packageName
|
packageName
|
||||||
) {
|
) {
|
||||||
const html = body.toString("utf8");
|
const html = body.toString("utf8");
|
||||||
let modified = false;
|
const suppressedVersions = /** @type {string[]} */ ([]);
|
||||||
const rewriteHtmlAnchor = createHtmlAnchorRewriter(
|
const rewriteHtmlAnchor = createHtmlAnchorRewriter(
|
||||||
metadataUrl,
|
metadataUrl,
|
||||||
isNewlyReleasedPackage,
|
isNewlyReleasedPackage,
|
||||||
packageName,
|
packageName,
|
||||||
() => {
|
(version) => {
|
||||||
modified = true;
|
suppressedVersions.push(version);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const updatedHtml = html.replace(HTML_ANCHOR_HREF_RE, rewriteHtmlAnchor);
|
const updatedHtml = html.replace(HTML_ANCHOR_HREF_RE, rewriteHtmlAnchor);
|
||||||
|
|
||||||
if (!modified) return body;
|
if (suppressedVersions.length === 0) return { buffer: body, suppressedVersions: [] };
|
||||||
const modifiedBuffer = Buffer.from(updatedHtml);
|
const modifiedBuffer = Buffer.from(updatedHtml);
|
||||||
clearCachingHeaders(headers);
|
clearCachingHeaders(headers);
|
||||||
return modifiedBuffer;
|
return { buffer: modifiedBuffer, suppressedVersions: [...new Set(suppressedVersions)] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} metadataUrl
|
* @param {string} metadataUrl
|
||||||
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
||||||
* @param {string} packageName
|
* @param {string} packageName
|
||||||
* @param {() => void} onModified
|
* @param {(version: string) => void} onVersionSuppressed
|
||||||
* @returns {(anchor: string, quote: string, href: string) => string}
|
* @returns {(anchor: string, quote: string, href: string) => string}
|
||||||
*/
|
*/
|
||||||
function createHtmlAnchorRewriter(
|
function createHtmlAnchorRewriter(
|
||||||
metadataUrl,
|
metadataUrl,
|
||||||
isNewlyReleasedPackage,
|
isNewlyReleasedPackage,
|
||||||
packageName,
|
packageName,
|
||||||
onModified
|
onVersionSuppressed
|
||||||
) {
|
) {
|
||||||
return (anchor, _quote, href) => {
|
return (anchor, _quote, href) => {
|
||||||
const resolvedHref = new URL(href, metadataUrl).toString();
|
const resolvedHref = new URL(href, metadataUrl).toString();
|
||||||
|
|
@ -145,8 +158,8 @@ function createHtmlAnchorRewriter(
|
||||||
version &&
|
version &&
|
||||||
isNewlyReleasedPackage(packageName, version)
|
isNewlyReleasedPackage(packageName, version)
|
||||||
) {
|
) {
|
||||||
onModified();
|
|
||||||
logSuppressedVersion(packageName, version);
|
logSuppressedVersion(packageName, version);
|
||||||
|
onVersionSuppressed(version);
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,7 +173,7 @@ function createHtmlAnchorRewriter(
|
||||||
* @param {string} metadataUrl
|
* @param {string} metadataUrl
|
||||||
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
||||||
* @param {string} packageName
|
* @param {string} packageName
|
||||||
* @returns {Buffer}
|
* @returns {{ buffer: Buffer, suppressedVersions: string[] }}
|
||||||
*/
|
*/
|
||||||
function modifyJsonResponse(
|
function modifyJsonResponse(
|
||||||
body,
|
body,
|
||||||
|
|
@ -170,15 +183,15 @@ function modifyJsonResponse(
|
||||||
packageName
|
packageName
|
||||||
) {
|
) {
|
||||||
const json = JSON.parse(body.toString("utf8"));
|
const json = JSON.parse(body.toString("utf8"));
|
||||||
const modified = modifyPipJsonResponse(
|
const { suppressedVersions, wasModified } = modifyPipJsonResponse(
|
||||||
json,
|
json,
|
||||||
metadataUrl,
|
metadataUrl,
|
||||||
isNewlyReleasedPackage,
|
isNewlyReleasedPackage,
|
||||||
packageName
|
packageName
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!modified) return body;
|
if (!wasModified) return { buffer: body, suppressedVersions: [] };
|
||||||
const modifiedBuffer = Buffer.from(JSON.stringify(json));
|
const modifiedBuffer = Buffer.from(JSON.stringify(json));
|
||||||
clearCachingHeaders(headers);
|
clearCachingHeaders(headers);
|
||||||
return modifiedBuffer;
|
return { buffer: modifiedBuffer, suppressedVersions };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { logSuppressedVersion } from "./pipMetadataResponseUtils.js";
|
||||||
* @param {string} metadataUrl
|
* @param {string} metadataUrl
|
||||||
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
||||||
* @param {string} packageName
|
* @param {string} packageName
|
||||||
* @returns {boolean}
|
* @returns {{ suppressedVersions: string[], wasModified: boolean }}
|
||||||
*/
|
*/
|
||||||
export function modifyPipJsonResponse(
|
export function modifyPipJsonResponse(
|
||||||
json,
|
json,
|
||||||
|
|
@ -18,18 +18,18 @@ export function modifyPipJsonResponse(
|
||||||
isNewlyReleasedPackage,
|
isNewlyReleasedPackage,
|
||||||
packageName
|
packageName
|
||||||
) {
|
) {
|
||||||
const filesModified = filterJsonMetadataFiles(
|
const filesSuppressed = filterJsonMetadataFiles(
|
||||||
json,
|
json,
|
||||||
metadataUrl,
|
metadataUrl,
|
||||||
isNewlyReleasedPackage,
|
isNewlyReleasedPackage,
|
||||||
packageName
|
packageName
|
||||||
);
|
);
|
||||||
const releasesModified = removeJsonMetadataReleases(
|
const releasesSuppressed = removeJsonMetadataReleases(
|
||||||
json,
|
json,
|
||||||
isNewlyReleasedPackage,
|
isNewlyReleasedPackage,
|
||||||
packageName
|
packageName
|
||||||
);
|
);
|
||||||
const urlsModified = filterJsonMetadataUrls(
|
const urlsSuppressed = filterJsonMetadataUrls(
|
||||||
json,
|
json,
|
||||||
metadataUrl,
|
metadataUrl,
|
||||||
isNewlyReleasedPackage,
|
isNewlyReleasedPackage,
|
||||||
|
|
@ -37,7 +37,11 @@ export function modifyPipJsonResponse(
|
||||||
);
|
);
|
||||||
const versionModified = updateJsonInfoVersion(json, metadataUrl);
|
const versionModified = updateJsonInfoVersion(json, metadataUrl);
|
||||||
|
|
||||||
return filesModified || releasesModified || urlsModified || versionModified;
|
const suppressedVersions = [
|
||||||
|
...new Set([...filesSuppressed, ...releasesSuppressed, ...urlsSuppressed]),
|
||||||
|
];
|
||||||
|
|
||||||
|
return { suppressedVersions, wasModified: suppressedVersions.length > 0 || versionModified };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -45,7 +49,7 @@ export function modifyPipJsonResponse(
|
||||||
* @param {string} metadataUrl
|
* @param {string} metadataUrl
|
||||||
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
||||||
* @param {string} packageName
|
* @param {string} packageName
|
||||||
* @returns {boolean}
|
* @returns {string[]}
|
||||||
*/
|
*/
|
||||||
function filterJsonMetadataFiles(
|
function filterJsonMetadataFiles(
|
||||||
json,
|
json,
|
||||||
|
|
@ -54,19 +58,17 @@ function filterJsonMetadataFiles(
|
||||||
packageName
|
packageName
|
||||||
) {
|
) {
|
||||||
if (!Array.isArray(json.files)) {
|
if (!Array.isArray(json.files)) {
|
||||||
return false;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
let modified = false;
|
const suppressed = new Set();
|
||||||
const loggedVersions = new Set();
|
|
||||||
json.files = json.files.filter((/** @type {any} */ file) => {
|
json.files = json.files.filter((/** @type {any} */ file) => {
|
||||||
const version = getPackageVersionFromMetadataFile(file, metadataUrl);
|
const version = getPackageVersionFromMetadataFile(file, metadataUrl);
|
||||||
|
|
||||||
if (version && isNewlyReleasedPackage(packageName, version)) {
|
if (version && isNewlyReleasedPackage(packageName, version)) {
|
||||||
modified = true;
|
if (!suppressed.has(version)) {
|
||||||
if (!loggedVersions.has(version)) {
|
|
||||||
logSuppressedVersion(packageName, version);
|
logSuppressedVersion(packageName, version);
|
||||||
loggedVersions.add(version);
|
suppressed.add(version);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -74,21 +76,21 @@ function filterJsonMetadataFiles(
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
return modified;
|
return [...suppressed];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {any} json
|
* @param {any} json
|
||||||
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
||||||
* @param {string} packageName
|
* @param {string} packageName
|
||||||
* @returns {boolean}
|
* @returns {string[]}
|
||||||
*/
|
*/
|
||||||
function removeJsonMetadataReleases(json, isNewlyReleasedPackage, packageName) {
|
function removeJsonMetadataReleases(json, isNewlyReleasedPackage, packageName) {
|
||||||
if (!json.releases || typeof json.releases !== "object") {
|
if (!json.releases || typeof json.releases !== "object") {
|
||||||
return false;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
let modified = false;
|
const suppressed = [];
|
||||||
|
|
||||||
for (const [version, files] of Object.entries(json.releases)) {
|
for (const [version, files] of Object.entries(json.releases)) {
|
||||||
if (
|
if (
|
||||||
|
|
@ -96,12 +98,12 @@ function removeJsonMetadataReleases(json, isNewlyReleasedPackage, packageName) {
|
||||||
isNewlyReleasedPackage(packageName, version)
|
isNewlyReleasedPackage(packageName, version)
|
||||||
) {
|
) {
|
||||||
delete json.releases[version];
|
delete json.releases[version];
|
||||||
modified = true;
|
|
||||||
logSuppressedVersion(packageName, version);
|
logSuppressedVersion(packageName, version);
|
||||||
|
suppressed.push(version);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return modified;
|
return suppressed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -109,7 +111,7 @@ function removeJsonMetadataReleases(json, isNewlyReleasedPackage, packageName) {
|
||||||
* @param {string} metadataUrl
|
* @param {string} metadataUrl
|
||||||
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
||||||
* @param {string} packageName
|
* @param {string} packageName
|
||||||
* @returns {boolean}
|
* @returns {string[]}
|
||||||
*/
|
*/
|
||||||
function filterJsonMetadataUrls(
|
function filterJsonMetadataUrls(
|
||||||
json,
|
json,
|
||||||
|
|
@ -118,19 +120,17 @@ function filterJsonMetadataUrls(
|
||||||
packageName
|
packageName
|
||||||
) {
|
) {
|
||||||
if (!Array.isArray(json.urls)) {
|
if (!Array.isArray(json.urls)) {
|
||||||
return false;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
let modified = false;
|
const suppressed = new Set();
|
||||||
const loggedVersions = new Set();
|
|
||||||
json.urls = json.urls.filter((/** @type {any} */ file) => {
|
json.urls = json.urls.filter((/** @type {any} */ file) => {
|
||||||
const version = getPackageVersionFromMetadataFile(file, metadataUrl);
|
const version = getPackageVersionFromMetadataFile(file, metadataUrl);
|
||||||
|
|
||||||
if (version && isNewlyReleasedPackage(packageName, version)) {
|
if (version && isNewlyReleasedPackage(packageName, version)) {
|
||||||
modified = true;
|
if (!suppressed.has(version)) {
|
||||||
if (!loggedVersions.has(version)) {
|
|
||||||
logSuppressedVersion(packageName, version);
|
logSuppressedVersion(packageName, version);
|
||||||
loggedVersions.add(version);
|
suppressed.add(version);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -138,7 +138,7 @@ function filterJsonMetadataUrls(
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
return modified;
|
return [...suppressed];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { getMinimumPackageAgeHours } from "../../../../config/settings.js";
|
import { getMinimumPackageAgeHours } from "../../../../config/settings.js";
|
||||||
import { ui } from "../../../../environment/userInteraction.js";
|
import { ui } from "../../../../environment/userInteraction.js";
|
||||||
import { getHeaderValueAsString } from "../../http-utils.js";
|
import { getHeaderValueAsString } from "../../http-utils.js";
|
||||||
import { recordSuppressedVersion } from "../suppressedVersionsState.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
||||||
|
|
@ -20,7 +19,6 @@ export function getPipMetadataContentType(headers) {
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
export function logSuppressedVersion(packageName, version) {
|
export function logSuppressedVersion(packageName, version) {
|
||||||
recordSuppressedVersion();
|
|
||||||
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).`
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
const state = {
|
|
||||||
hasSuppressedVersions: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tracks whether any rewritten metadata response suppressed versions during the
|
|
||||||
* current process lifetime. This is intentional shared state used only for the
|
|
||||||
* end-of-run summary message exposed through the proxy API.
|
|
||||||
*
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
export function recordSuppressedVersion() {
|
|
||||||
state.hasSuppressedVersions = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
export function getHasSuppressedVersions() {
|
|
||||||
return state.hasSuppressedVersions;
|
|
||||||
}
|
|
||||||
|
|
@ -48,10 +48,24 @@ export function createRamaProxy(ramaPath) {
|
||||||
return Object.assign(emitter, {
|
return Object.assign(emitter, {
|
||||||
startServer: async () => {
|
startServer: async () => {
|
||||||
await reportingServer.start();
|
await reportingServer.start();
|
||||||
reportingServer.addListener("blockReceived", (ev) =>
|
reportingServer.addListener("blockReceived", (ev) => {
|
||||||
emitter.emit("malwareBlocked", {
|
if (ev.block_reason === "new_package") {
|
||||||
|
emitter.emit("minimumAgeRequestBlocked", {
|
||||||
|
packageName: ev.artifact.identifier,
|
||||||
|
packageVersion: ev.artifact.version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
emitter.emit("malwareBlocked", {
|
||||||
|
packageName: ev.artifact.identifier,
|
||||||
|
packageVersion: ev.artifact.version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
reportingServer.addListener("minPackageAgeSuppressionReceived", (ev) =>
|
||||||
|
emitter.emit("minPackageAgeVersionsSuppressed", {
|
||||||
packageName: ev.artifact.identifier,
|
packageName: ev.artifact.identifier,
|
||||||
packageVersion: ev.artifact.version,
|
packageVersions: ev.suppressed_versions,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
ui.writeVerbose(
|
ui.writeVerbose(
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,18 @@ const SERVER_STOP_TIMEOUT_MS = 1000;
|
||||||
* @typedef {Object} BlockEvent
|
* @typedef {Object} BlockEvent
|
||||||
* @property {number} ts_ms
|
* @property {number} ts_ms
|
||||||
* @property {{ product: string, identifier: string, version: string }} artifact
|
* @property {{ product: string, identifier: string, version: string }} artifact
|
||||||
|
* @property {string} block_reason
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {{ blockReceived: [BlockEvent] }} ReportingServerEvents
|
* @typedef {Object} MinPackageAgeEvent
|
||||||
|
* @property {number} ts_ms
|
||||||
|
* @property {{ product: string, identifier: string }} artifact
|
||||||
|
* @property {string[]} suppressed_versions
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{ blockReceived: [BlockEvent], minPackageAgeSuppressionReceived: [MinPackageAgeEvent] }} ReportingServerEvents
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -38,6 +46,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 +88,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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,15 @@ import { getCombinedCaBundlePath } from "./certBundle.js";
|
||||||
* @prop {string} packageName
|
* @prop {string} packageName
|
||||||
* @prop {string} packageVersion
|
* @prop {string} packageVersion
|
||||||
*
|
*
|
||||||
* @typedef {{ malwareBlocked: [PackageBlockedEvent], minimumAgeRequestBlocked: [PackageBlockedEvent] }} ProxyServerEvents
|
* @typedef {Object} MinPackageAgeSuppressionEvent
|
||||||
|
* @prop {string} packageName
|
||||||
|
* @prop {string[]} packageVersions
|
||||||
|
*
|
||||||
|
* @typedef {{
|
||||||
|
* malwareBlocked: [PackageBlockedEvent],
|
||||||
|
* minimumAgeRequestBlocked: [PackageBlockedEvent]
|
||||||
|
* minPackageAgeVersionsSuppressed: [MinPackageAgeSuppressionEvent]
|
||||||
|
* }} ProxyServerEvents
|
||||||
*
|
*
|
||||||
* @import { EventEmitter } from "node:stream"
|
* @import { EventEmitter } from "node:stream"
|
||||||
* @typedef {EventEmitter<ProxyServerEvents> & {
|
* @typedef {EventEmitter<ProxyServerEvents> & {
|
||||||
|
|
@ -16,7 +24,6 @@ import { getCombinedCaBundlePath } from "./certBundle.js";
|
||||||
* 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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue