mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Remove too new packages from npm response
This commit is contained in:
parent
3bf7279195
commit
8bd2ace3db
9 changed files with 582 additions and 119 deletions
|
|
@ -1,5 +1,9 @@
|
||||||
import * as cliArguments from "./cliArguments.js";
|
import * as cliArguments from "./cliArguments.js";
|
||||||
|
|
||||||
|
export const LOGGING_SILENT = "silent";
|
||||||
|
export const LOGGING_NORMAL = "normal";
|
||||||
|
export const LOGGING_VERBOSE = "verbose";
|
||||||
|
|
||||||
export function getLoggingLevel() {
|
export function getLoggingLevel() {
|
||||||
const level = cliArguments.getLoggingLevel();
|
const level = cliArguments.getLoggingLevel();
|
||||||
|
|
||||||
|
|
@ -14,9 +18,6 @@ export function getLoggingLevel() {
|
||||||
return LOGGING_NORMAL;
|
return LOGGING_NORMAL;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MALWARE_ACTION_BLOCK = "block";
|
|
||||||
export const MALWARE_ACTION_PROMPT = "prompt";
|
|
||||||
|
|
||||||
export const ECOSYSTEM_JS = "js";
|
export const ECOSYSTEM_JS = "js";
|
||||||
export const ECOSYSTEM_PY = "py";
|
export const ECOSYSTEM_PY = "py";
|
||||||
|
|
||||||
|
|
@ -36,6 +37,6 @@ export function setEcoSystem(setting) {
|
||||||
ecosystemSettings.ecoSystem = setting;
|
ecosystemSettings.ecoSystem = setting;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LOGGING_SILENT = "silent";
|
export function getMinimumPackageAgeHours() {
|
||||||
export const LOGGING_NORMAL = "normal";
|
return 24 * 6;
|
||||||
export const LOGGING_VERBOSE = "verbose";
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import {
|
||||||
ECOSYSTEM_PY,
|
ECOSYSTEM_PY,
|
||||||
getEcoSystem,
|
getEcoSystem,
|
||||||
} from "../../config/settings.js";
|
} from "../../config/settings.js";
|
||||||
import { npmInterceptorForUrl } from "./npmInterceptor.js";
|
import { npmInterceptorForUrl } from "./npm/npmInterceptor.js";
|
||||||
import { pipInterceptorForUrl } from "./pipInterceptor.js";
|
import { pipInterceptorForUrl } from "./pipInterceptor.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,231 @@
|
||||||
|
import chalk from "chalk";
|
||||||
|
import { getMinimumPackageAgeHours } from "../../../config/settings.js";
|
||||||
|
import { isMalwarePackage } from "../../../scanning/audit/index.js";
|
||||||
|
import { createInterceptorBuilder } from "../interceptorBuilder.js";
|
||||||
|
import { ui } from "../../../environment/userInteraction.js";
|
||||||
|
import { writeFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const knownJsRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} url
|
||||||
|
* @returns {import("../interceptorBuilder.js").Interceptor | undefined}
|
||||||
|
*/
|
||||||
|
export function npmInterceptorForUrl(url) {
|
||||||
|
const registry = knownJsRegistries.find((reg) => url.includes(reg));
|
||||||
|
|
||||||
|
if (registry) {
|
||||||
|
return buildNpmInterceptor(registry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} registry
|
||||||
|
* @returns {import("../interceptorBuilder.js").Interceptor | undefined}
|
||||||
|
*/
|
||||||
|
function buildNpmInterceptor(registry) {
|
||||||
|
const builder = createInterceptorBuilder();
|
||||||
|
|
||||||
|
builder.onRequest(async (req) => {
|
||||||
|
const { packageName, version } = parseNpmPackageUrl(
|
||||||
|
req.targetUrl,
|
||||||
|
registry
|
||||||
|
);
|
||||||
|
if (await isMalwarePackage(packageName, version)) {
|
||||||
|
req.blockMalware(packageName, version, req.targetUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPackageInfoUrl(req.targetUrl)) {
|
||||||
|
req.modifyRequestHeaders((headers) => {
|
||||||
|
if (
|
||||||
|
headers["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";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
req.modifyResponse((res) => {
|
||||||
|
res.modifyBody(modifyNpmInfoRequestBody);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} url
|
||||||
|
* @param {string} registry
|
||||||
|
* @returns {{packageName: string | undefined, version: string | undefined}}
|
||||||
|
*/
|
||||||
|
function parseNpmPackageUrl(url, registry) {
|
||||||
|
let packageName, version;
|
||||||
|
if (!registry || !url.endsWith(".tgz")) {
|
||||||
|
return { packageName, version };
|
||||||
|
}
|
||||||
|
|
||||||
|
const registryIndex = url.indexOf(registry);
|
||||||
|
const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash
|
||||||
|
|
||||||
|
const separatorIndex = afterRegistry.indexOf("/-/");
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
return { packageName, version };
|
||||||
|
}
|
||||||
|
|
||||||
|
packageName = afterRegistry.substring(0, separatorIndex);
|
||||||
|
const filename = afterRegistry.substring(
|
||||||
|
separatorIndex + 3,
|
||||||
|
afterRegistry.length - 4
|
||||||
|
); // Remove /-/ and .tgz
|
||||||
|
|
||||||
|
// Extract version from filename
|
||||||
|
// For scoped packages like @babel/core, the filename is core-7.21.4.tgz
|
||||||
|
// For regular packages like lodash, the filename is lodash-4.17.21.tgz
|
||||||
|
if (packageName.startsWith("@")) {
|
||||||
|
const scopedPackageName = packageName.substring(
|
||||||
|
packageName.lastIndexOf("/") + 1
|
||||||
|
);
|
||||||
|
if (filename.startsWith(scopedPackageName + "-")) {
|
||||||
|
version = filename.substring(scopedPackageName.length + 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (filename.startsWith(packageName + "-")) {
|
||||||
|
version = filename.substring(packageName.length + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { packageName, version };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} url
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
* @returns Buffer
|
||||||
|
*/
|
||||||
|
function modifyNpmInfoRequestBody(body) {
|
||||||
|
try {
|
||||||
|
const bodyContent = body.toString("utf8");
|
||||||
|
const bodyJson = JSON.parse(bodyContent);
|
||||||
|
|
||||||
|
if (!bodyJson.time || !bodyJson["dist-tags"] || !bodyJson.versions) {
|
||||||
|
// Just return the body if the
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cutOff = new Date(
|
||||||
|
new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000
|
||||||
|
).toISOString();
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
for (const { version, timestamp } of versions) {
|
||||||
|
if (version === "created" || version === "modified") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timestamp > cutOff) {
|
||||||
|
deleteVersionFromJson(bodyJson, version);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(JSON.stringify(bodyJson));
|
||||||
|
} catch (err) {
|
||||||
|
// TODO: better error handling
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteVersionFromJson(json, version) {
|
||||||
|
ui.writeVerbose(
|
||||||
|
`Safe-chain: Deleting ${version} from npm info request, it's newer than the minimumPackageAgeInHours`
|
||||||
|
);
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateLatestTag(json) {
|
||||||
|
if (!json.time) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let latest, preview, latestDate, previewDate;
|
||||||
|
|
||||||
|
for (const [version, timestamp] of Object.entries(json.time)) {
|
||||||
|
if (version == "created" || version == "modified") continue;
|
||||||
|
|
||||||
|
if (version.includes("-")) {
|
||||||
|
// preview versions include "-" in the name
|
||||||
|
[preview, previewDate] = getLatest(
|
||||||
|
preview,
|
||||||
|
previewDate,
|
||||||
|
version,
|
||||||
|
timestamp
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
[latest, latestDate] = getLatest(latest, latestDate, version, timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latest) {
|
||||||
|
return latest;
|
||||||
|
} else {
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatest(currentLatest, currentLatestDate, version, timestamp) {
|
||||||
|
if (!currentLatest) {
|
||||||
|
return [version, timestamp];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timestamp > currentLatestDate) {
|
||||||
|
return [version, timestamp];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [currentLatest, currentLatestDate];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,237 @@
|
||||||
|
import { describe, it, mock } from "node:test";
|
||||||
|
import assert from "node:assert";
|
||||||
|
|
||||||
|
describe("npmInterceptor minimum package age", async () => {
|
||||||
|
let minimumPackageAgeSettings = 48;
|
||||||
|
|
||||||
|
mock.module("../../../config/settings.js", {
|
||||||
|
namedExports: {
|
||||||
|
getMinimumPackageAgeHours: () => minimumPackageAgeSettings,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("../../../scanning/audit/index.js", {
|
||||||
|
namedExports: {
|
||||||
|
isMalwarePackage: async () => {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { npmInterceptorForUrl } = await import("./npmInterceptor.js");
|
||||||
|
|
||||||
|
for (const packageInfoUrl of [
|
||||||
|
// Basic package metadata
|
||||||
|
"https://registry.npmjs.org/lodash",
|
||||||
|
"https://registry.npmjs.org/express",
|
||||||
|
// Scoped packages
|
||||||
|
"https://registry.npmjs.org/@vercel/functions",
|
||||||
|
"https://registry.npmjs.org/@babel/core",
|
||||||
|
"https://registry.npmjs.org/@types/node",
|
||||||
|
// With query parameters
|
||||||
|
"https://registry.npmjs.org/lodash?write=true",
|
||||||
|
"https://registry.npmjs.org/@babel/core?param=value&other=test",
|
||||||
|
// With fragments
|
||||||
|
"https://registry.npmjs.org/lodash#readme",
|
||||||
|
"https://registry.npmjs.org/@babel/core#installation",
|
||||||
|
// Version-specific metadata
|
||||||
|
"https://registry.npmjs.org/lodash/4.17.21",
|
||||||
|
"https://registry.npmjs.org/lodash/latest",
|
||||||
|
"https://registry.npmjs.org/@babel/core/7.21.4",
|
||||||
|
// URL-encoded scoped packages
|
||||||
|
"https://registry.npmjs.org/@types%2Fnode",
|
||||||
|
"https://registry.npmjs.org/@babel%2Fcore",
|
||||||
|
// With trailing slashes
|
||||||
|
"https://registry.npmjs.org/lodash/",
|
||||||
|
"https://registry.npmjs.org/@babel/core/",
|
||||||
|
]) {
|
||||||
|
it(`modifyResponse should be true for package info requests: ${packageInfoUrl}`, async () => {
|
||||||
|
const interceptor = npmInterceptorForUrl(packageInfoUrl);
|
||||||
|
const requestInterceptor = await interceptor.handleRequest(
|
||||||
|
packageInfoUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(requestInterceptor.modifiesResponse(), true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const packageUrl of [
|
||||||
|
// Regular package tarballs
|
||||||
|
"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
"https://registry.npmjs.org/express/-/express-4.18.2.tgz",
|
||||||
|
// Scoped package tarballs
|
||||||
|
"https://registry.npmjs.org/@babel/core/-/core-8.0.0-alpha.1.tgz",
|
||||||
|
"https://registry.npmjs.org/@types/node/-/node-20.10.5.tgz",
|
||||||
|
// Tarballs with query parameters (integrity checks)
|
||||||
|
"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz?integrity=sha512-abc123",
|
||||||
|
"https://registry.npmjs.org/@babel/core/-/core-7.21.4.tgz?integrity=sha512-def456&cache=false",
|
||||||
|
// Tarballs with fragments
|
||||||
|
"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz#sha512-abc123",
|
||||||
|
"https://registry.npmjs.org/@babel/core/-/core-7.21.4.tgz#hash",
|
||||||
|
// Prerelease versions
|
||||||
|
"https://registry.npmjs.org/react/-/react-18.3.0-canary-abc123.tgz",
|
||||||
|
"https://registry.npmjs.org/lodash/-/lodash-5.0.0-beta.1.tgz",
|
||||||
|
]) {
|
||||||
|
it(`modifyResponse should be false for package downloads: ${packageUrl}`, async () => {
|
||||||
|
const interceptor = npmInterceptorForUrl(packageUrl);
|
||||||
|
const requestInterceptor = await interceptor.handleRequest(packageUrl);
|
||||||
|
|
||||||
|
assert.equal(requestInterceptor.modifiesResponse(), false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const specialEndpoint of [
|
||||||
|
// Security advisory endpoints
|
||||||
|
"https://registry.npmjs.org/-/npm/v1/security/advisories/bulk",
|
||||||
|
"https://registry.npmjs.org/-/npm/v1/security/audits",
|
||||||
|
"https://registry.npmjs.org/-/npm/v1/security/audits/quick",
|
||||||
|
// Search endpoints
|
||||||
|
"https://registry.npmjs.org/-/v1/search?text=lodash&size=20",
|
||||||
|
"https://registry.npmjs.org/-/v1/search?text=react&from=0",
|
||||||
|
// Package access/collaboration endpoints
|
||||||
|
"https://registry.npmjs.org/-/package/lodash/access",
|
||||||
|
"https://registry.npmjs.org/-/package/@babel/core/collaborators",
|
||||||
|
"https://registry.npmjs.org/-/package/lodash/dist-tags",
|
||||||
|
"https://registry.npmjs.org/-/package/@babel/core/dist-tags/latest",
|
||||||
|
// User/organization endpoints
|
||||||
|
"https://registry.npmjs.org/-/user/org.couchdb.user:username",
|
||||||
|
"https://registry.npmjs.org/-/org/myorg/package",
|
||||||
|
// Anonymous metrics
|
||||||
|
"https://registry.npmjs.org/-/npm/anon-metrics/v1/",
|
||||||
|
// Ping/health check
|
||||||
|
"https://registry.npmjs.org/-/ping",
|
||||||
|
]) {
|
||||||
|
it(`modifyResponse should be false for special endpoints: ${specialEndpoint}`, async () => {
|
||||||
|
const interceptor = npmInterceptorForUrl(specialEndpoint);
|
||||||
|
const requestInterceptor = await interceptor.handleRequest(
|
||||||
|
specialEndpoint
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(requestInterceptor.modifiesResponse(), false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("Should remove packages older than the treshold", async () => {
|
||||||
|
minimumPackageAgeSettings = 5;
|
||||||
|
const packageUrl = "https://registry.npmjs.org/lodash";
|
||||||
|
|
||||||
|
const modifiedBody = await runModifyNpmInfoRequest(
|
||||||
|
packageUrl,
|
||||||
|
JSON.stringify({
|
||||||
|
name: "lodash",
|
||||||
|
["dist-tags"]: {
|
||||||
|
latest: "3.0.0",
|
||||||
|
},
|
||||||
|
versions: {
|
||||||
|
["1.0.0"]: {},
|
||||||
|
["2.0.0"]: {},
|
||||||
|
["3.0.0"]: {},
|
||||||
|
},
|
||||||
|
time: {
|
||||||
|
created: getDate(-365 * 24),
|
||||||
|
["1.0.0"]: getDate(-7),
|
||||||
|
// cutoff-date here
|
||||||
|
["2.0.0"]: getDate(-4),
|
||||||
|
["3.0.0"]: getDate(-3),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const modifiedJson = JSON.parse(modifiedBody);
|
||||||
|
|
||||||
|
assert.equal(Object.keys(modifiedJson.time).length, 2);
|
||||||
|
assert.equal(Object.keys(modifiedJson.versions).length, 1);
|
||||||
|
assert.ok(Object.keys(modifiedJson.time).includes("1.0.0"));
|
||||||
|
assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0"));
|
||||||
|
assert.ok(!Object.keys(modifiedJson.time).includes("2.0.0"));
|
||||||
|
assert.ok(!Object.keys(modifiedJson.versions).includes("2.0.0"));
|
||||||
|
assert.ok(!Object.keys(modifiedJson.time).includes("3.0.0"));
|
||||||
|
assert.ok(!Object.keys(modifiedJson.versions).includes("3.0.0"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Should set the package to the new latest non-preview release", async () => {
|
||||||
|
minimumPackageAgeSettings = 5;
|
||||||
|
const packageUrl = "https://registry.npmjs.org/lodash";
|
||||||
|
|
||||||
|
const modifiedBody = await runModifyNpmInfoRequest(
|
||||||
|
packageUrl,
|
||||||
|
JSON.stringify({
|
||||||
|
name: "lodash",
|
||||||
|
["dist-tags"]: {
|
||||||
|
latest: "3.0.0",
|
||||||
|
},
|
||||||
|
versions: {
|
||||||
|
["1.0.0"]: {},
|
||||||
|
["2.0.0"]: {},
|
||||||
|
["3.0.0"]: {},
|
||||||
|
},
|
||||||
|
time: {
|
||||||
|
created: getDate(-365 * 24),
|
||||||
|
["1.0.0"]: getDate(-7),
|
||||||
|
["0.0.1"]: getDate(-8), // package order: this package is older than 1.0.0, it should not be considered latest
|
||||||
|
["2.0.0-alpha"]: getDate(-6), //package is a pre-release, it should not be latest
|
||||||
|
// cutoff-date here
|
||||||
|
["2.0.0"]: getDate(-4),
|
||||||
|
["3.0.0"]: getDate(-3),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const modifiedJson = JSON.parse(modifiedBody);
|
||||||
|
|
||||||
|
assert.equal(modifiedJson["dist-tags"]["latest"], "1.0.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Should remove dist-tags if version was removed", async () => {
|
||||||
|
minimumPackageAgeSettings = 5;
|
||||||
|
const packageUrl = "https://registry.npmjs.org/lodash";
|
||||||
|
|
||||||
|
const modifiedBody = await runModifyNpmInfoRequest(
|
||||||
|
packageUrl,
|
||||||
|
JSON.stringify({
|
||||||
|
name: "lodash",
|
||||||
|
["dist-tags"]: {
|
||||||
|
latest: "3.0.0",
|
||||||
|
alpha: "2.0.0-alpha",
|
||||||
|
},
|
||||||
|
versions: {
|
||||||
|
["1.0.0"]: {},
|
||||||
|
["2.0.0"]: {},
|
||||||
|
["3.0.0"]: {},
|
||||||
|
},
|
||||||
|
time: {
|
||||||
|
created: getDate(-365 * 24),
|
||||||
|
["1.0.0"]: getDate(-7),
|
||||||
|
// cutoff-date here
|
||||||
|
["2.0.0-alpha"]: getDate(-4),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const modifiedJson = JSON.parse(modifiedBody);
|
||||||
|
console.log(modifiedJson);
|
||||||
|
|
||||||
|
assert.equal(modifiedJson["dist-tags"]["alpha"], undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import("../interceptorBuilder.js").Interceptor} interceptor
|
||||||
|
* @param {string} body
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async function runModifyNpmInfoRequest(url, body) {
|
||||||
|
const interceptor = npmInterceptorForUrl(url);
|
||||||
|
const requestInterceptor = await interceptor.handleRequest(url);
|
||||||
|
const responseInterceptor = requestInterceptor.handleResponse();
|
||||||
|
|
||||||
|
const modifiedBuffer = responseInterceptor.modifyBody(Buffer.from(body));
|
||||||
|
|
||||||
|
return modifiedBuffer.toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDate(plusHours) {
|
||||||
|
const date = new Date();
|
||||||
|
date.setHours(date.getHours() + plusHours);
|
||||||
|
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -5,7 +5,7 @@ describe("npmInterceptor", async () => {
|
||||||
let lastPackage;
|
let lastPackage;
|
||||||
let malwareResponse = false;
|
let malwareResponse = false;
|
||||||
|
|
||||||
mock.module("../../scanning/audit/index.js", {
|
mock.module("../../../scanning/audit/index.js", {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
isMalwarePackage: async (packageName, version) => {
|
isMalwarePackage: async (packageName, version) => {
|
||||||
lastPackage = { packageName, version };
|
lastPackage = { packageName, version };
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
import chalk from "chalk";
|
|
||||||
import { isMalwarePackage } from "../../scanning/audit/index.js";
|
|
||||||
import { createInterceptorBuilder } from "./interceptorBuilder.js";
|
|
||||||
|
|
||||||
const knownJsRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} url
|
|
||||||
* @returns {import("./interceptorBuilder.js").Interceptor | undefined}
|
|
||||||
*/
|
|
||||||
export function npmInterceptorForUrl(url) {
|
|
||||||
const registry = knownJsRegistries.find((reg) => url.includes(reg));
|
|
||||||
|
|
||||||
if (registry) {
|
|
||||||
return buildNpmInterceptor(registry);
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} registry
|
|
||||||
* @returns {import("./interceptorBuilder.js").Interceptor | undefined}
|
|
||||||
*/
|
|
||||||
function buildNpmInterceptor(registry) {
|
|
||||||
const builder = createInterceptorBuilder();
|
|
||||||
|
|
||||||
builder.onRequest(async (req) => {
|
|
||||||
const { packageName, version } = parseNpmPackageUrl(
|
|
||||||
req.targetUrl,
|
|
||||||
registry
|
|
||||||
);
|
|
||||||
if (await isMalwarePackage(packageName, version)) {
|
|
||||||
req.blockMalware(packageName, version, req.targetUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
req.modifyRequestHeaders((headers) => {
|
|
||||||
if (headers["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 builder.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} url
|
|
||||||
* @param {string} registry
|
|
||||||
* @returns {{packageName: string | undefined, version: string | undefined}}
|
|
||||||
*/
|
|
||||||
function parseNpmPackageUrl(url, registry) {
|
|
||||||
let packageName, version;
|
|
||||||
if (!registry || !url.endsWith(".tgz")) {
|
|
||||||
return { packageName, version };
|
|
||||||
}
|
|
||||||
|
|
||||||
const registryIndex = url.indexOf(registry);
|
|
||||||
const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash
|
|
||||||
|
|
||||||
const separatorIndex = afterRegistry.indexOf("/-/");
|
|
||||||
if (separatorIndex === -1) {
|
|
||||||
return { packageName, version };
|
|
||||||
}
|
|
||||||
|
|
||||||
packageName = afterRegistry.substring(0, separatorIndex);
|
|
||||||
const filename = afterRegistry.substring(
|
|
||||||
separatorIndex + 3,
|
|
||||||
afterRegistry.length - 4
|
|
||||||
); // Remove /-/ and .tgz
|
|
||||||
|
|
||||||
// Extract version from filename
|
|
||||||
// For scoped packages like @babel/core, the filename is core-7.21.4.tgz
|
|
||||||
// For regular packages like lodash, the filename is lodash-4.17.21.tgz
|
|
||||||
if (packageName.startsWith("@")) {
|
|
||||||
const scopedPackageName = packageName.substring(
|
|
||||||
packageName.lastIndexOf("/") + 1
|
|
||||||
);
|
|
||||||
if (filename.startsWith(scopedPackageName + "-")) {
|
|
||||||
version = filename.substring(scopedPackageName.length + 1);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (filename.startsWith(packageName + "-")) {
|
|
||||||
version = filename.substring(packageName.length + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { packageName, version };
|
|
||||||
}
|
|
||||||
|
|
@ -4,14 +4,18 @@
|
||||||
* @property {(statusCode: number, message: string) => void} blockRequest
|
* @property {(statusCode: number, message: string) => void} blockRequest
|
||||||
* @property {(packageName: string | undefined, version: string | undefined, url: string) => void} blockMalware
|
* @property {(packageName: string | undefined, version: string | undefined, url: string) => void} blockMalware
|
||||||
* @property {(modificationFunc: (headers: NodeJS.Dict<string | string[]>) => void) => void} modifyRequestHeaders
|
* @property {(modificationFunc: (headers: NodeJS.Dict<string | string[]>) => void) => void} modifyRequestHeaders
|
||||||
|
* @property {(requestFunc: (responseInterceptorBuilder: import('./responseInterceptorBuilder.js').ResponseInterceptorBuilder) => void) => void} modifyResponse
|
||||||
* @property {() => RequestInterceptor} build
|
* @property {() => RequestInterceptor} build
|
||||||
*
|
*
|
||||||
* @typedef {Object} RequestInterceptor
|
* @typedef {Object} RequestInterceptor
|
||||||
* @property {{statusCode: number, message: string} | undefined} blockResponse
|
* @property {{statusCode: number, message: string} | undefined} blockResponse
|
||||||
* @property {(headers: NodeJS.Dict<string | string[]> | undefined) => void} modifyRequestHeaders
|
* @property {(headers: NodeJS.Dict<string | string[]> | undefined) => void} modifyRequestHeaders
|
||||||
|
* @property {() => import("./responseInterceptorBuilder.js").ResponseInterceptor} handleResponse
|
||||||
* @property {() => boolean} modifiesResponse
|
* @property {() => boolean} modifiesResponse
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { createResponseInterceptorBuilder } from "./responseInterceptorBuilder.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} targetUrl
|
* @param {string} targetUrl
|
||||||
* @param {import('events').EventEmitter} eventEmitter
|
* @param {import('events').EventEmitter} eventEmitter
|
||||||
|
|
@ -20,15 +24,10 @@
|
||||||
export function createRequestInterceptorBuilder(targetUrl, eventEmitter) {
|
export function createRequestInterceptorBuilder(targetUrl, eventEmitter) {
|
||||||
/** @type {{statusCode: number, message: string} | undefined} */
|
/** @type {{statusCode: number, message: string} | undefined} */
|
||||||
let blockResponse = undefined;
|
let blockResponse = undefined;
|
||||||
|
/** @type {Array<(headers: NodeJS.Dict<string | string[]>) => void>} */
|
||||||
/**
|
let requestHeaderFuncs = [];
|
||||||
* @type {{
|
/** @type {Array<(requestFunc: import('./responseInterceptorBuilder.js').ResponseInterceptorBuilder) => void>} */
|
||||||
* requestHeaders: Array<(headers: NodeJS.Dict<string | string[]>) => void>
|
let responseModifierFuncs = [];
|
||||||
* }}
|
|
||||||
*/
|
|
||||||
let modificationFuncs = {
|
|
||||||
requestHeaders: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {number} statusCode
|
* @param {number} statusCode
|
||||||
|
|
@ -60,12 +59,16 @@ export function createRequestInterceptorBuilder(targetUrl, eventEmitter) {
|
||||||
blockRequest,
|
blockRequest,
|
||||||
blockMalware,
|
blockMalware,
|
||||||
modifyRequestHeaders(modificationFunc) {
|
modifyRequestHeaders(modificationFunc) {
|
||||||
modificationFuncs.requestHeaders.push(modificationFunc);
|
requestHeaderFuncs.push(modificationFunc);
|
||||||
|
},
|
||||||
|
modifyResponse(modificationFunc) {
|
||||||
|
responseModifierFuncs.push(modificationFunc);
|
||||||
},
|
},
|
||||||
build() {
|
build() {
|
||||||
return createRequestInterceptor(
|
return createRequestInterceptor(
|
||||||
blockResponse,
|
blockResponse,
|
||||||
modificationFuncs.requestHeaders
|
requestHeaderFuncs,
|
||||||
|
responseModifierFuncs
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -74,11 +77,13 @@ export function createRequestInterceptorBuilder(targetUrl, eventEmitter) {
|
||||||
/**
|
/**
|
||||||
* @param {{statusCode: number, message: string} | undefined} blockResponse
|
* @param {{statusCode: number, message: string} | undefined} blockResponse
|
||||||
* @param {Array<(headers: NodeJS.Dict<string | string[]>) => void>} requestHeadersModficationFuncs
|
* @param {Array<(headers: NodeJS.Dict<string | string[]>) => void>} requestHeadersModficationFuncs
|
||||||
|
* @param {Array<(requestFunc: import('./responseInterceptorBuilder.js').ResponseInterceptorBuilder) => void>} responseModifierFuncs
|
||||||
* @returns {RequestInterceptor}
|
* @returns {RequestInterceptor}
|
||||||
*/
|
*/
|
||||||
function createRequestInterceptor(
|
function createRequestInterceptor(
|
||||||
blockResponse,
|
blockResponse,
|
||||||
requestHeadersModficationFuncs
|
requestHeadersModficationFuncs,
|
||||||
|
responseModifierFuncs
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
||||||
|
|
@ -94,8 +99,23 @@ function createRequestInterceptor(
|
||||||
}
|
}
|
||||||
|
|
||||||
function modifiesResponse() {
|
function modifiesResponse() {
|
||||||
return false;
|
return responseModifierFuncs.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { blockResponse, modifyRequestHeaders, modifiesResponse };
|
function handleResponse() {
|
||||||
|
const responseInterceptorBuilder = createResponseInterceptorBuilder();
|
||||||
|
|
||||||
|
for (const func of responseModifierFuncs) {
|
||||||
|
func(responseInterceptorBuilder);
|
||||||
|
}
|
||||||
|
|
||||||
|
return responseInterceptorBuilder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
blockResponse,
|
||||||
|
modifyRequestHeaders,
|
||||||
|
modifiesResponse,
|
||||||
|
handleResponse,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ResponseInterceptorBuilder
|
||||||
|
* @property {() => ResponseInterceptor} build
|
||||||
|
* @property {(modificationFunc: (body: Buffer) => Buffer) => void} modifyBody
|
||||||
|
*
|
||||||
|
* @typedef {Object} ResponseInterceptor
|
||||||
|
* @property {(buffer: Buffer) => Buffer} modifyBody
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {ResponseInterceptorBuilder}
|
||||||
|
*/
|
||||||
|
export function createResponseInterceptorBuilder() {
|
||||||
|
/** @type {Array<(body: Buffer) => Buffer>} */
|
||||||
|
let modifyBodyFuncs = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
modifyBody: (func) => modifyBodyFuncs.push(func),
|
||||||
|
build: () => createResponseInterceptor(modifyBodyFuncs),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {ResponseInterceptor}
|
||||||
|
* @param {Array<(body: Buffer) => Buffer>} modifyBodyFuncs
|
||||||
|
*/
|
||||||
|
function createResponseInterceptor(modifyBodyFuncs) {
|
||||||
|
/**
|
||||||
|
* @param {Buffer} body
|
||||||
|
* @returns {Buffer}
|
||||||
|
*/
|
||||||
|
function modifyBody(body) {
|
||||||
|
let modifiedBody = body;
|
||||||
|
|
||||||
|
for (var func of modifyBodyFuncs) {
|
||||||
|
modifiedBody = func(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return modifiedBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { modifyBody };
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import https from "https";
|
||||||
import { generateCertForHost } from "./certUtils.js";
|
import { generateCertForHost } from "./certUtils.js";
|
||||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||||
import { ui } from "../environment/userInteraction.js";
|
import { ui } from "../environment/userInteraction.js";
|
||||||
|
import { gunzipSync, gzipSync } from "zlib";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {import("./interceptors/interceptorBuilder.js").Interceptor} Interceptor
|
* @typedef {import("./interceptors/interceptorBuilder.js").Interceptor} Interceptor
|
||||||
|
|
@ -188,14 +189,36 @@ function createProxyRequest(hostname, req, res, requestInterceptor) {
|
||||||
res.end("Internal Server Error");
|
res.end("Internal Server Error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||||
|
|
||||||
if (!requestInterceptor.modifiesResponse) {
|
if (requestInterceptor.modifiesResponse()) {
|
||||||
// If the response is not being modified, we can
|
const responseInterceptor = requestInterceptor.handleResponse();
|
||||||
// just pipe without the need for
|
|
||||||
proxyRes.pipe(res);
|
/** @type {Array<any>} */
|
||||||
|
let chunks = [];
|
||||||
|
|
||||||
|
proxyRes.on("data", (chunk) => chunks.push(chunk));
|
||||||
|
|
||||||
|
proxyRes.on("end", () => {
|
||||||
|
/** @type {Buffer} */
|
||||||
|
let buffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
|
if (proxyRes.headers["content-encoding"] === "gzip") {
|
||||||
|
buffer = gunzipSync(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer = responseInterceptor.modifyBody(buffer);
|
||||||
|
|
||||||
|
if (proxyRes.headers["content-encoding"] === "gzip") {
|
||||||
|
buffer = gzipSync(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.end(buffer);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
|
// If the response is not being modified, we can
|
||||||
|
// just pipe without the need for buffering the output
|
||||||
|
proxyRes.pipe(res);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue