import { EventEmitter } from "events"; import { getMinimumPackageAgeExclusions, getMinimumPackageAgeHours, } from "../../../../config/settings.js"; import { ui } from "../../../../environment/userInteraction.js"; import { clearCachingHeaders, getHeaderValueAsString } from "../../http-utils.js"; /** @type {EventEmitter<{ versionsRemoved: [{packageName: string, packageVersions: string[]}] }>} */ export const modifyResponseEventEmitter = new EventEmitter(); /** * @param {NodeJS.Dict} headers * @returns {NodeJS.Dict} */ export function modifyNpmInfoRequestHeaders(headers) { const accept = getHeaderValueAsString(headers, "accept"); if (accept?.includes("application/vnd.npm.install-v1+json")) { // The npm registry sometimes serves a more compact format that lacks // the time metadata we need to filter out too new packages. // Force the registry to return the full metadata by changing the Accept header. headers["accept"] = "application/json"; } return headers; } /** * @param {string} url * @returns {boolean} */ export function isPackageInfoUrl(url) { // Remove query string and fragment to get the actual path const urlWithoutParams = url.split("?")[0].split("#")[0]; // Tarball downloads end with .tgz if (urlWithoutParams.endsWith(".tgz")) return false; // Special endpoints start with /-/ and should not be modified // Examples: /-/npm/v1/security/advisories/bulk, /-/v1/search, /-/package/foo/access if (urlWithoutParams.includes("/-/")) return false; // Everything else is package metadata that can be modified return true; } /** * * @param {Buffer} body * @param {NodeJS.Dict | undefined} headers * @returns Buffer */ export function modifyNpmInfoResponse(body, headers) { try { const contentType = getHeaderValueAsString(headers, "content-type"); if (!contentType?.toLowerCase().includes("application/json")) { return body; } if (body.byteLength === 0) { return body; } // utf-8 is default encoding for JSON, so we don't check if charset is defined in content-type header const bodyContent = body.toString("utf8"); const bodyJson = JSON.parse(bodyContent); if (!bodyJson.time || !bodyJson["dist-tags"] || !bodyJson.versions) { // Just return the current body if the format is not return body; } // Check if this package is excluded from minimum age filtering const packageName = bodyJson.name; const exclusions = getMinimumPackageAgeExclusions(); if ( packageName && exclusions.some((pattern) => matchesExclusionPattern(packageName, pattern), ) ) { ui.writeVerbose( `Safe-chain: ${packageName} is excluded from minimum package age filtering (minimumPackageAgeExclusions setting).`, ); return body; } const cutOff = new Date( new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000, ); const hasLatestTag = !!bodyJson["dist-tags"]["latest"]; const versions = Object.entries(bodyJson.time) .map(([version, timestamp]) => ({ version, timestamp, })) .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); clearCachingHeaders(headers); } } if (hasLatestTag && !bodyJson["dist-tags"]["latest"]) { // The latest tag was removed because it contained a package younger than the treshold. // A new latest tag needs to be calculated 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}`, ); return body; } } /** * @param {any} json * @param {string} version */ function deleteVersionFromJson(json, version) { 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).`, ); delete json.time[version]; delete json.versions[version]; for (const [tag, distVersion] of Object.entries(json["dist-tags"])) { if (version == distVersion) { delete json["dist-tags"][tag]; } } } /** * @param {Record} tagList * @returns {string | undefined} */ function calculateLatestTag(tagList) { const entries = Object.entries(tagList).filter( ([version, _]) => version !== "created" && version !== "modified", ); const latestFullRelease = getMostRecentTag( Object.fromEntries( entries.filter(([version, _]) => !version.includes("-")), ), ); if (latestFullRelease) { return latestFullRelease; } const latestPrerelease = getMostRecentTag( Object.fromEntries(entries.filter(([version, _]) => version.includes("-"))), ); return latestPrerelease; } /** * @param {Record} tagList * @returns {string | undefined} */ function getMostRecentTag(tagList) { let current, currentDate; for (const [version, timestamp] of Object.entries(tagList)) { if (!currentDate || currentDate < timestamp) { current = version; currentDate = timestamp; } } return current; } /** * Checks if a package name matches an exclusion pattern. * Supports trailing wildcard (*) for prefix matching. * @param {string} packageName * @param {string} pattern * @returns {boolean} */ function matchesExclusionPattern(packageName, pattern) { if (pattern.endsWith("/*")) { return packageName.startsWith(pattern.slice(0, -1)); } return packageName === pattern; } /** * @param {Buffer} body * @param {NodeJS.Dict | undefined} headers * @returns {string | undefined} */ export function getPackageNameFromMetadataResponse(body, headers) { try { const contentType = getHeaderValueAsString(headers, "content-type"); if (!contentType?.toLowerCase().includes("application/json")) { return undefined; } const bodyJson = JSON.parse(body.toString("utf8")); return typeof bodyJson.name === "string" ? bodyJson.name : undefined; } catch { return undefined; } }