AikidoSec-safe-chain/packages/safe-chain/src/registryProxy/interceptors/npm/npmInterceptor.js
2026-03-27 14:25:58 -07:00

131 lines
3.7 KiB
JavaScript

import {
getNpmCustomRegistries,
getNpmMinimumPackageAgeExclusions,
skipMinimumPackageAge,
} from "../../../config/settings.js";
import { isMalwarePackage } from "../../../scanning/audit/index.js";
import { interceptRequests } from "../interceptorBuilder.js";
import {
isPackageInfoUrl,
matchesExclusionPattern,
modifyNpmInfoRequestHeaders,
modifyNpmInfoResponse,
} from "./modifyNpmInfo.js";
import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js";
import { openNewPackagesDatabase } from "../../../scanning/newPackagesDatabase.js";
const knownJsRegistries = [
"registry.npmjs.org",
"registry.yarnpkg.com",
"registry.npmjs.com",
];
/**
* @param {string} url
* @returns {import("../interceptorBuilder.js").Interceptor | undefined}
*/
export function npmInterceptorForUrl(url) {
const registry = [...knownJsRegistries, ...getNpmCustomRegistries()].find(
(reg) => url.includes(reg)
);
if (registry) {
return buildNpmInterceptor(registry);
}
return undefined;
}
/**
* @param {string} registry
* @returns {import("../interceptorBuilder.js").Interceptor}
*/
function buildNpmInterceptor(registry) {
return interceptRequests(async (reqContext) => {
const { packageName, version } = parseNpmPackageUrl(
reqContext.targetUrl,
registry
);
const minimumAgeChecksEnabled = !skipMinimumPackageAge();
const packageIsExcludedFromMinimumAgeChecks =
packageName && isExcludedFromMinimumPackageAge(packageName);
if (await isMalwarePackage(packageName, version)) {
reqContext.blockMalware(packageName, version);
return;
}
if (minimumAgeChecksEnabled && isPackageInfoUrl(reqContext.targetUrl)) {
reqContext.modifyRequestHeaders(modifyNpmInfoRequestHeaders);
reqContext.modifyBody((body, headers) => {
const metadataPackageName = getPackageNameFromMetadataResponse(
body,
headers
);
if (
metadataPackageName &&
isExcludedFromMinimumPackageAge(metadataPackageName)
) {
return body;
}
return modifyNpmInfoResponse(body, headers);
});
return;
}
// For tarball requests the metadata check above is skipped, so we check the
// new packages list as a fallback (covers e.g. frozen-lockfile installs).
if (
minimumAgeChecksEnabled &&
packageName &&
version &&
!packageIsExcludedFromMinimumAgeChecks
) {
const newPackagesDatabase = await openNewPackagesDatabase();
if (newPackagesDatabase.isNewlyReleasedPackage(packageName, version)) {
reqContext.blockMinimumAgeRequest(
packageName,
version,
`Forbidden - blocked by safe-chain direct download minimum package age (${packageName}@${version})`
);
}
}
});
}
/**
* @param {string} packageName
* @returns {boolean}
*/
function isExcludedFromMinimumPackageAge(packageName) {
const exclusions = getNpmMinimumPackageAgeExclusions();
return exclusions.some((pattern) =>
matchesExclusionPattern(packageName, pattern)
);
}
/**
* @param {Buffer} body
* @param {NodeJS.Dict<string | string[]> | undefined} headers
* @returns {string | undefined}
*/
function getPackageNameFromMetadataResponse(body, headers) {
try {
const contentType = headers?.["content-type"];
const normalizedContentType = Array.isArray(contentType)
? contentType.join(",")
: contentType;
if (!normalizedContentType?.toLowerCase().includes("application/json")) {
return undefined;
}
const bodyJson = JSON.parse(body.toString("utf8"));
return typeof bodyJson.name === "string" ? bodyJson.name : undefined;
} catch {
return undefined;
}
}