Add extra check

This commit is contained in:
Reinier Criel 2026-03-19 15:58:42 -07:00
parent cddcec9ba5
commit 2f4268f1af
10 changed files with 298 additions and 12 deletions

View file

@ -68,6 +68,10 @@ export async function main(args) {
return 1; return 1;
} }
if (!proxy.verifyNoMinimumAgeBlockedRequests()) {
return 1;
}
const auditStats = getAuditStats(); const auditStats = getAuditStats();
if (auditStats.totalPackages > 0) { if (auditStats.totalPackages > 0) {
ui.writeVerbose( ui.writeVerbose(

View file

@ -10,6 +10,7 @@ import { EventEmitter } from "events";
* @typedef {Object} RequestInterceptionContext * @typedef {Object} RequestInterceptionContext
* @property {string} targetUrl * @property {string} targetUrl
* @property {(packageName: string | undefined, version: string | undefined) => void} blockMalware * @property {(packageName: string | undefined, version: string | undefined) => void} blockMalware
* @property {(packageName: string, version: string, message: string) => void} blockMinimumAgeRequest
* @property {(modificationFunc: (headers: NodeJS.Dict<string | string[]>) => NodeJS.Dict<string | string[]>) => void} modifyRequestHeaders * @property {(modificationFunc: (headers: NodeJS.Dict<string | string[]>) => NodeJS.Dict<string | string[]>) => void} modifyRequestHeaders
* @property {(modificationFunc: (body: Buffer, headers: NodeJS.Dict<string | string[]> | undefined) => Buffer) => void} modifyBody * @property {(modificationFunc: (body: Buffer, headers: NodeJS.Dict<string | string[]> | undefined) => Buffer) => void} modifyBody
* @property {() => RequestInterceptionHandler} build * @property {() => RequestInterceptionHandler} build
@ -26,6 +27,12 @@ import { EventEmitter } from "events";
* @property {string} version * @property {string} version
* @property {string} targetUrl * @property {string} targetUrl
* @property {number} timestamp * @property {number} timestamp
*
* @typedef {Object} MinimumAgeRequestBlockedEvent
* @property {string} packageName
* @property {string} version
* @property {string} targetUrl
* @property {number} timestamp
*/ */
/** /**
@ -81,10 +88,7 @@ function createRequestContext(targetUrl, eventEmitter) {
* @param {string | undefined} version * @param {string | undefined} version
*/ */
function blockMalwareSetup(packageName, version) { function blockMalwareSetup(packageName, version) {
blockResponse = { blockResponse = createBlockResponse("Forbidden - blocked by safe-chain");
statusCode: 403,
message: "Forbidden - blocked by safe-chain",
};
// Emit the malwareBlocked event // Emit the malwareBlocked event
eventEmitter.emit("malwareBlocked", { eventEmitter.emit("malwareBlocked", {
@ -95,6 +99,34 @@ function createRequestContext(targetUrl, eventEmitter) {
}); });
} }
/**
* @param {string} message
*/
function blockMinimumAgeRequestSetup(
/** @type {string} */ packageName,
/** @type {string} */ version,
/** @type {string} */ message
) {
blockResponse = createBlockResponse(message);
eventEmitter.emit("minimumAgeRequestBlocked", {
packageName,
version,
targetUrl,
timestamp: Date.now(),
});
}
/**
* @param {string} message
* @returns {{statusCode: number, message: string}}
*/
function createBlockResponse(message) {
return {
statusCode: 403,
message,
};
}
/** @returns {RequestInterceptionHandler} */ /** @returns {RequestInterceptionHandler} */
function build() { function build() {
/** /**
@ -139,6 +171,7 @@ function createRequestContext(targetUrl, eventEmitter) {
return { return {
targetUrl, targetUrl,
blockMalware: blockMalwareSetup, blockMalware: blockMalwareSetup,
blockMinimumAgeRequest: blockMinimumAgeRequestSetup,
modifyRequestHeaders: (func) => reqheaderModificationFuncs.push(func), modifyRequestHeaders: (func) => reqheaderModificationFuncs.push(func),
modifyBody: (func) => modifyBodyFuncs.push(func), modifyBody: (func) => modifyBodyFuncs.push(func),
build, build,

View file

@ -195,7 +195,7 @@ export function getHasSuppressedVersions() {
* @param {string} pattern * @param {string} pattern
* @returns {boolean} * @returns {boolean}
*/ */
function matchesExclusionPattern(packageName, pattern) { export function matchesExclusionPattern(packageName, pattern) {
if (pattern.endsWith("/*")) { if (pattern.endsWith("/*")) {
return packageName.startsWith(pattern.slice(0, -1)); return packageName.startsWith(pattern.slice(0, -1));
} }

View file

@ -1,15 +1,18 @@
import { import {
getNpmCustomRegistries, getNpmCustomRegistries,
getNpmMinimumPackageAgeExclusions,
skipMinimumPackageAge, skipMinimumPackageAge,
} from "../../../config/settings.js"; } from "../../../config/settings.js";
import { isMalwarePackage } from "../../../scanning/audit/index.js"; import { isMalwarePackage } from "../../../scanning/audit/index.js";
import { interceptRequests } from "../interceptorBuilder.js"; import { interceptRequests } from "../interceptorBuilder.js";
import { import {
isPackageInfoUrl, isPackageInfoUrl,
matchesExclusionPattern,
modifyNpmInfoRequestHeaders, modifyNpmInfoRequestHeaders,
modifyNpmInfoResponse, modifyNpmInfoResponse,
} from "./modifyNpmInfo.js"; } from "./modifyNpmInfo.js";
import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js"; import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js";
import { openNewPackagesDatabase } from "../../../scanning/newPackagesDatabase.js";
const knownJsRegistries = [ const knownJsRegistries = [
"registry.npmjs.org", "registry.npmjs.org",
@ -46,11 +49,34 @@ function buildNpmInterceptor(registry) {
if (await isMalwarePackage(packageName, version)) { if (await isMalwarePackage(packageName, version)) {
reqContext.blockMalware(packageName, version); reqContext.blockMalware(packageName, version);
return;
} }
if (!skipMinimumPackageAge() && isPackageInfoUrl(reqContext.targetUrl)) { if (!skipMinimumPackageAge() && isPackageInfoUrl(reqContext.targetUrl)) {
reqContext.modifyRequestHeaders(modifyNpmInfoRequestHeaders); reqContext.modifyRequestHeaders(modifyNpmInfoRequestHeaders);
reqContext.modifyBody(modifyNpmInfoResponse); reqContext.modifyBody(modifyNpmInfoResponse);
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 (!skipMinimumPackageAge() && packageName && version) {
const exclusions = getNpmMinimumPackageAgeExclusions();
const isExcluded = exclusions.some((pattern) =>
matchesExclusionPattern(packageName, pattern)
);
if (!isExcluded) {
const newPackagesDatabase = await openNewPackagesDatabase();
if (newPackagesDatabase.isNewlyReleasedPackage(packageName, version)) {
reqContext.blockMinimumAgeRequest(
packageName,
version,
`Forbidden - blocked by safe-chain minimum package age (${packageName}@${version})`
);
}
}
} }
}); });
} }

View file

@ -5,13 +5,25 @@ describe("npmInterceptor minimum package age", async () => {
let minimumPackageAgeSettings = 48; let minimumPackageAgeSettings = 48;
let skipMinimumPackageAgeSetting = false; let skipMinimumPackageAgeSetting = false;
let minimumPackageAgeExclusionsSetting = []; let minimumPackageAgeExclusionsSetting = [];
let newlyReleasedPackages = new Set();
mock.module("../../../config/settings.js", { mock.module("../../../config/settings.js", {
namedExports: { namedExports: {
ECOSYSTEM_JS: "js",
ECOSYSTEM_PY: "py",
getMinimumPackageAgeHours: () => minimumPackageAgeSettings, getMinimumPackageAgeHours: () => minimumPackageAgeSettings,
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting, skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
getNpmCustomRegistries: () => [], getNpmCustomRegistries: () => [],
getNpmMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting, getNpmMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting,
getEcoSystem: () => "js",
},
});
mock.module("../../../scanning/newPackagesDatabase.js", {
namedExports: {
openNewPackagesDatabase: async () => ({
isNewlyReleasedPackage: (name, version) =>
newlyReleasedPackages.has(`${name}@${version}`),
}),
}, },
}); });
@ -359,6 +371,67 @@ describe("npmInterceptor minimum package age", async () => {
assert.equal(modifiedJson["dist-tags"]["latest"], "2.0.0"); assert.equal(modifiedJson["dist-tags"]["latest"], "2.0.0");
}); });
it("Should suppress too-young versions on metadata requests without directly blocking the request", async () => {
minimumPackageAgeSettings = 5;
skipMinimumPackageAgeSetting = false;
const packageUrl = "https://registry.npmjs.org/lodash";
const interceptor = npmInterceptorForUrl(packageUrl);
const requestHandler = await interceptor.handleRequest(packageUrl);
assert.equal(requestHandler.blockResponse, undefined);
assert.equal(requestHandler.modifiesResponse(), true);
});
it("Should directly block tarball requests when the new packages list marks them as too young", async () => {
minimumPackageAgeSettings = 5;
skipMinimumPackageAgeSetting = false;
newlyReleasedPackages = new Set(["lodash@4.17.21"]);
const packageUrl =
"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz?integrity=sha512-abc123";
const interceptor = npmInterceptorForUrl(packageUrl);
const requestHandler = await interceptor.handleRequest(packageUrl);
assert.ok(requestHandler.blockResponse);
assert.equal(requestHandler.modifiesResponse(), false);
assert.equal(requestHandler.blockResponse.statusCode, 403);
assert.equal(
requestHandler.blockResponse.message,
"Forbidden - blocked by safe-chain minimum package age (lodash@4.17.21)"
);
});
it("Should not block tarball requests when skipMinimumPackageAge is enabled", async () => {
minimumPackageAgeSettings = 5;
skipMinimumPackageAgeSetting = true;
minimumPackageAgeExclusionsSetting = [];
newlyReleasedPackages = new Set(["lodash@4.17.21"]);
const packageUrl =
"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz";
const interceptor = npmInterceptorForUrl(packageUrl);
const requestHandler = await interceptor.handleRequest(packageUrl);
assert.equal(requestHandler.blockResponse, undefined);
assert.equal(requestHandler.modifiesResponse(), false);
});
it("Should not block tarball requests when the package is excluded from minimum age", async () => {
minimumPackageAgeSettings = 5;
skipMinimumPackageAgeSetting = false;
minimumPackageAgeExclusionsSetting = ["lodash"];
newlyReleasedPackages = new Set(["lodash@4.17.21"]);
const packageUrl =
"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz";
const interceptor = npmInterceptorForUrl(packageUrl);
const requestHandler = await interceptor.handleRequest(packageUrl);
assert.equal(requestHandler.blockResponse, undefined);
assert.equal(requestHandler.modifiesResponse(), false);
});
it("Should not filter packages when package is in exclusion list", async () => { it("Should not filter packages when package is in exclusion list", async () => {
minimumPackageAgeSettings = 5; minimumPackageAgeSettings = 5;
skipMinimumPackageAgeSetting = false; skipMinimumPackageAgeSetting = false;
@ -540,6 +613,7 @@ describe("npmInterceptor minimum package age", async () => {
minimumPackageAgeSettings = 5; minimumPackageAgeSettings = 5;
skipMinimumPackageAgeSetting = false; skipMinimumPackageAgeSetting = false;
minimumPackageAgeExclusionsSetting = []; // Reset to empty minimumPackageAgeExclusionsSetting = []; // Reset to empty
newlyReleasedPackages = new Set();
const packageUrl = "https://registry.npmjs.org/lodash"; const packageUrl = "https://registry.npmjs.org/lodash";

View file

@ -1,9 +1,11 @@
import { describe, it, mock } from "node:test"; import { describe, it, mock, beforeEach } from "node:test";
import assert from "node:assert"; import assert from "node:assert";
let lastPackage; let lastPackage;
let malwareResponse = false; let malwareResponse = false;
let customRegistries = []; let customRegistries = [];
let newlyReleasedPackages = new Set();
let skipMinimumPackageAgeSetting = false;
mock.module("../../../scanning/audit/index.js", { mock.module("../../../scanning/audit/index.js", {
namedExports: { namedExports: {
@ -27,13 +29,29 @@ mock.module("../../../config/settings.js", {
getMinimumPackageAgeHours: () => 24, getMinimumPackageAgeHours: () => 24,
getNpmCustomRegistries: () => customRegistries, getNpmCustomRegistries: () => customRegistries,
getNpmMinimumPackageAgeExclusions: () => [], getNpmMinimumPackageAgeExclusions: () => [],
skipMinimumPackageAge: () => false, skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
},
});
mock.module("../../../scanning/newPackagesDatabase.js", {
namedExports: {
openNewPackagesDatabase: async () => ({
isNewlyReleasedPackage: (name, version) =>
newlyReleasedPackages.has(`${name}@${version}`),
}),
}, },
}); });
describe("npmInterceptor", async () => { describe("npmInterceptor", async () => {
const { npmInterceptorForUrl } = await import("./npmInterceptor.js"); const { npmInterceptorForUrl } = await import("./npmInterceptor.js");
beforeEach(() => {
lastPackage = undefined;
malwareResponse = false;
customRegistries = [];
newlyReleasedPackages = new Set();
skipMinimumPackageAgeSetting = false;
});
const parserCases = [ const parserCases = [
// Regular packages // Regular packages
{ {
@ -178,6 +196,36 @@ describe("npmInterceptor", async () => {
"Block response should have correct status message" "Block response should have correct status message"
); );
}); });
it("should block direct tarball downloads for newly released packages", async () => {
const url =
"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz?integrity=sha512-abc123";
malwareResponse = false;
skipMinimumPackageAgeSetting = false;
newlyReleasedPackages = new Set(["lodash@4.17.21"]);
const interceptor = npmInterceptorForUrl(url);
const result = await interceptor.handleRequest(url);
assert.ok(result.blockResponse);
assert.equal(result.blockResponse.statusCode, 403);
assert.equal(
result.blockResponse.message,
"Forbidden - blocked by safe-chain minimum package age (lodash@4.17.21)"
);
});
it("should not block direct tarball downloads when minimum age checks are skipped", async () => {
const url = "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz";
malwareResponse = false;
skipMinimumPackageAgeSetting = true;
newlyReleasedPackages = new Set(["lodash@4.17.21"]);
const interceptor = npmInterceptorForUrl(url);
const result = await interceptor.handleRequest(url);
assert.equal(result.blockResponse, undefined);
});
}); });
describe("npmInterceptor with custom registries", async () => { describe("npmInterceptor with custom registries", async () => {

View file

@ -5,12 +5,16 @@
*/ */
export function parseNpmPackageUrl(url, registry) { export function parseNpmPackageUrl(url, registry) {
let packageName, version; let packageName, version;
if (!registry || !url.endsWith(".tgz")) { const urlWithoutParams = url.split("?")[0].split("#")[0];
if (!registry || !urlWithoutParams.endsWith(".tgz")) {
return { packageName, version }; return { packageName, version };
} }
const registryIndex = url.indexOf(registry); const registryIndex = urlWithoutParams.indexOf(registry);
const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash const afterRegistry = urlWithoutParams.substring(
registryIndex + registry.length + 1
); // +1 to skip the slash
const separatorIndex = afterRegistry.indexOf("/-/"); const separatorIndex = afterRegistry.indexOf("/-/");
if (separatorIndex === -1) { if (separatorIndex === -1) {

View file

@ -10,11 +10,16 @@ import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js";
const SERVER_STOP_TIMEOUT_MS = 1000; const SERVER_STOP_TIMEOUT_MS = 1000;
/** /**
* @type {{port: number | null, blockedRequests: {packageName: string, version: string, url: string}[]}} * @type {{
* port: number | null,
* blockedRequests: {packageName: string, version: string, url: string}[],
* blockedMinimumAgeRequests: {packageName: string, version: string, url: string}[]
* }}
*/ */
const state = { const state = {
port: null, port: null,
blockedRequests: [], blockedRequests: [],
blockedMinimumAgeRequests: [],
}; };
export function createSafeChainProxy() { export function createSafeChainProxy() {
@ -24,6 +29,7 @@ export function createSafeChainProxy() {
startServer: () => startServer(server), startServer: () => startServer(server),
stopServer: () => stopServer(server), stopServer: () => stopServer(server),
verifyNoMaliciousPackages, verifyNoMaliciousPackages,
verifyNoMinimumAgeBlockedRequests,
hasSuppressedVersions: getHasSuppressedVersions, hasSuppressedVersions: getHasSuppressedVersions,
}; };
} }
@ -151,6 +157,18 @@ function handleConnect(req, clientSocket, head) {
onMalwareBlocked(event.packageName, event.version, event.targetUrl); onMalwareBlocked(event.packageName, event.version, event.targetUrl);
} }
); );
interceptor.on(
"minimumAgeRequestBlocked",
(
/** @type {import("./interceptors/interceptorBuilder.js").MinimumAgeRequestBlockedEvent} */ event
) => {
onMinimumAgeRequestBlocked(
event.packageName,
event.version,
event.targetUrl
);
}
);
mitmConnect(req, clientSocket, interceptor); mitmConnect(req, clientSocket, interceptor);
} else { } else {
@ -170,6 +188,16 @@ function onMalwareBlocked(packageName, version, url) {
state.blockedRequests.push({ packageName, version, url }); state.blockedRequests.push({ packageName, version, url });
} }
/**
*
* @param {string} packageName
* @param {string} version
* @param {string} url
*/
function onMinimumAgeRequestBlocked(packageName, version, url) {
state.blockedMinimumAgeRequests.push({ packageName, version, url });
}
function verifyNoMaliciousPackages() { function verifyNoMaliciousPackages() {
if (state.blockedRequests.length === 0) { if (state.blockedRequests.length === 0) {
// No malicious packages were blocked, so nothing to block // No malicious packages were blocked, so nothing to block
@ -194,3 +222,35 @@ function verifyNoMaliciousPackages() {
return false; return false;
} }
function verifyNoMinimumAgeBlockedRequests() {
if (state.blockedMinimumAgeRequests.length === 0) {
return true;
}
ui.emptyLine();
ui.writeInformation(
`Safe-chain: ${chalk.bold(
`blocked ${state.blockedMinimumAgeRequests.length} package downloads due to minimum age`
)}:`
);
for (const req of state.blockedMinimumAgeRequests) {
ui.writeInformation(` - ${req.packageName}@${req.version} (${req.url})`);
}
ui.writeInformation(
` To disable this check, use: ${chalk.cyan(
"--safe-chain-skip-minimum-package-age"
)}`
);
ui.emptyLine();
ui.writeError(
"Safe-chain: Exiting without installing packages blocked by minimum age."
);
ui.emptyLine();
return false;
}

View file

@ -20,6 +20,7 @@ import {
/** @type {NewPackagesDatabase | null} */ /** @type {NewPackagesDatabase | null} */
let cachedNewPackagesDatabase = null; let cachedNewPackagesDatabase = null;
let hasWarnedAboutUnavailableNewPackagesDatabase = false;
/** /**
* Returns the source identifier used in the feed for the current ecosystem. * Returns the source identifier used in the feed for the current ecosystem.
@ -42,7 +43,22 @@ export async function openNewPackagesDatabase() {
return cachedNewPackagesDatabase; return cachedNewPackagesDatabase;
} }
const newPackagesList = await getNewPackagesList(); /** @type {import("../api/aikido.js").NewPackageEntry[]} */
let newPackagesList;
try {
newPackagesList = await getNewPackagesList();
} catch (/** @type {any} */ error) {
if (!hasWarnedAboutUnavailableNewPackagesDatabase) {
ui.writeWarning(
`Failed to load the new packages list. Continuing without tarball minimum age fallback. ${error.message}`
);
hasWarnedAboutUnavailableNewPackagesDatabase = true;
}
cachedNewPackagesDatabase = { isNewlyReleasedPackage: () => false };
return cachedNewPackagesDatabase;
}
/** /**
* @param {string} name * @param {string} name

View file

@ -85,6 +85,10 @@ describe("newPackagesDatabase", async () => {
return module.openNewPackagesDatabase(); return module.openNewPackagesDatabase();
} }
async function loadNewPackagesDatabaseModule() {
return import(`./newPackagesDatabase.js?test_case=${importCounter++}`);
}
function hoursAgo(hours) { function hoursAgo(hours) {
return Math.floor((Date.now() - hours * 3600 * 1000) / 1000); return Math.floor((Date.now() - hours * 3600 * 1000) / 1000);
} }
@ -226,5 +230,22 @@ describe("newPackagesDatabase", async () => {
assert.strictEqual(writeWarningCalls.length, 1); assert.strictEqual(writeWarningCalls.length, 1);
assert.ok(writeWarningCalls[0].includes("could not be cached")); assert.ok(writeWarningCalls[0].includes("could not be cached"));
}); });
it("fails open and only warns once when the new packages list cannot be loaded", async () => {
fetchListError = new Error("feed unavailable");
const module = await loadNewPackagesDatabaseModule();
const db1 = await module.openNewPackagesDatabase();
const db2 = await module.openNewPackagesDatabase();
assert.strictEqual(db1.isNewlyReleasedPackage("foo", "1.0.0"), false);
assert.strictEqual(db2.isNewlyReleasedPackage("foo", "1.0.0"), false);
assert.strictEqual(writeWarningCalls.length, 1);
assert.ok(
writeWarningCalls[0].includes(
"Continuing without tarball minimum age fallback"
)
);
});
}); });
}); });