mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
131 lines
3.7 KiB
JavaScript
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;
|
|
}
|
|
}
|