mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Add extra check
This commit is contained in:
parent
cddcec9ba5
commit
2f4268f1af
10 changed files with 298 additions and 12 deletions
|
|
@ -68,6 +68,10 @@ export async function main(args) {
|
|||
return 1;
|
||||
}
|
||||
|
||||
if (!proxy.verifyNoMinimumAgeBlockedRequests()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const auditStats = getAuditStats();
|
||||
if (auditStats.totalPackages > 0) {
|
||||
ui.writeVerbose(
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { EventEmitter } from "events";
|
|||
* @typedef {Object} RequestInterceptionContext
|
||||
* @property {string} targetUrl
|
||||
* @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: (body: Buffer, headers: NodeJS.Dict<string | string[]> | undefined) => Buffer) => void} modifyBody
|
||||
* @property {() => RequestInterceptionHandler} build
|
||||
|
|
@ -26,6 +27,12 @@ import { EventEmitter } from "events";
|
|||
* @property {string} version
|
||||
* @property {string} targetUrl
|
||||
* @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
|
||||
*/
|
||||
function blockMalwareSetup(packageName, version) {
|
||||
blockResponse = {
|
||||
statusCode: 403,
|
||||
message: "Forbidden - blocked by safe-chain",
|
||||
};
|
||||
blockResponse = createBlockResponse("Forbidden - blocked by safe-chain");
|
||||
|
||||
// Emit the malwareBlocked event
|
||||
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} */
|
||||
function build() {
|
||||
/**
|
||||
|
|
@ -139,6 +171,7 @@ function createRequestContext(targetUrl, eventEmitter) {
|
|||
return {
|
||||
targetUrl,
|
||||
blockMalware: blockMalwareSetup,
|
||||
blockMinimumAgeRequest: blockMinimumAgeRequestSetup,
|
||||
modifyRequestHeaders: (func) => reqheaderModificationFuncs.push(func),
|
||||
modifyBody: (func) => modifyBodyFuncs.push(func),
|
||||
build,
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ export function getHasSuppressedVersions() {
|
|||
* @param {string} pattern
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function matchesExclusionPattern(packageName, pattern) {
|
||||
export function matchesExclusionPattern(packageName, pattern) {
|
||||
if (pattern.endsWith("/*")) {
|
||||
return packageName.startsWith(pattern.slice(0, -1));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
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",
|
||||
|
|
@ -46,11 +49,34 @@ function buildNpmInterceptor(registry) {
|
|||
|
||||
if (await isMalwarePackage(packageName, version)) {
|
||||
reqContext.blockMalware(packageName, version);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!skipMinimumPackageAge() && isPackageInfoUrl(reqContext.targetUrl)) {
|
||||
reqContext.modifyRequestHeaders(modifyNpmInfoRequestHeaders);
|
||||
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})`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,25 @@ describe("npmInterceptor minimum package age", async () => {
|
|||
let minimumPackageAgeSettings = 48;
|
||||
let skipMinimumPackageAgeSetting = false;
|
||||
let minimumPackageAgeExclusionsSetting = [];
|
||||
let newlyReleasedPackages = new Set();
|
||||
|
||||
mock.module("../../../config/settings.js", {
|
||||
namedExports: {
|
||||
ECOSYSTEM_JS: "js",
|
||||
ECOSYSTEM_PY: "py",
|
||||
getMinimumPackageAgeHours: () => minimumPackageAgeSettings,
|
||||
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
|
||||
getNpmCustomRegistries: () => [],
|
||||
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");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
minimumPackageAgeSettings = 5;
|
||||
skipMinimumPackageAgeSetting = false;
|
||||
|
|
@ -540,6 +613,7 @@ describe("npmInterceptor minimum package age", async () => {
|
|||
minimumPackageAgeSettings = 5;
|
||||
skipMinimumPackageAgeSetting = false;
|
||||
minimumPackageAgeExclusionsSetting = []; // Reset to empty
|
||||
newlyReleasedPackages = new Set();
|
||||
|
||||
const packageUrl = "https://registry.npmjs.org/lodash";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { describe, it, mock } from "node:test";
|
||||
import { describe, it, mock, beforeEach } from "node:test";
|
||||
import assert from "node:assert";
|
||||
|
||||
let lastPackage;
|
||||
let malwareResponse = false;
|
||||
let customRegistries = [];
|
||||
let newlyReleasedPackages = new Set();
|
||||
let skipMinimumPackageAgeSetting = false;
|
||||
|
||||
mock.module("../../../scanning/audit/index.js", {
|
||||
namedExports: {
|
||||
|
|
@ -27,13 +29,29 @@ mock.module("../../../config/settings.js", {
|
|||
getMinimumPackageAgeHours: () => 24,
|
||||
getNpmCustomRegistries: () => customRegistries,
|
||||
getNpmMinimumPackageAgeExclusions: () => [],
|
||||
skipMinimumPackageAge: () => false,
|
||||
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
|
||||
},
|
||||
});
|
||||
mock.module("../../../scanning/newPackagesDatabase.js", {
|
||||
namedExports: {
|
||||
openNewPackagesDatabase: async () => ({
|
||||
isNewlyReleasedPackage: (name, version) =>
|
||||
newlyReleasedPackages.has(`${name}@${version}`),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
describe("npmInterceptor", async () => {
|
||||
const { npmInterceptorForUrl } = await import("./npmInterceptor.js");
|
||||
|
||||
beforeEach(() => {
|
||||
lastPackage = undefined;
|
||||
malwareResponse = false;
|
||||
customRegistries = [];
|
||||
newlyReleasedPackages = new Set();
|
||||
skipMinimumPackageAgeSetting = false;
|
||||
});
|
||||
|
||||
const parserCases = [
|
||||
// Regular packages
|
||||
{
|
||||
|
|
@ -178,6 +196,36 @@ describe("npmInterceptor", async () => {
|
|||
"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 () => {
|
||||
|
|
|
|||
|
|
@ -5,12 +5,16 @@
|
|||
*/
|
||||
export function parseNpmPackageUrl(url, registry) {
|
||||
let packageName, version;
|
||||
if (!registry || !url.endsWith(".tgz")) {
|
||||
const urlWithoutParams = url.split("?")[0].split("#")[0];
|
||||
|
||||
if (!registry || !urlWithoutParams.endsWith(".tgz")) {
|
||||
return { packageName, version };
|
||||
}
|
||||
|
||||
const registryIndex = url.indexOf(registry);
|
||||
const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash
|
||||
const registryIndex = urlWithoutParams.indexOf(registry);
|
||||
const afterRegistry = urlWithoutParams.substring(
|
||||
registryIndex + registry.length + 1
|
||||
); // +1 to skip the slash
|
||||
|
||||
const separatorIndex = afterRegistry.indexOf("/-/");
|
||||
if (separatorIndex === -1) {
|
||||
|
|
|
|||
|
|
@ -10,11 +10,16 @@ import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js";
|
|||
|
||||
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 = {
|
||||
port: null,
|
||||
blockedRequests: [],
|
||||
blockedMinimumAgeRequests: [],
|
||||
};
|
||||
|
||||
export function createSafeChainProxy() {
|
||||
|
|
@ -24,6 +29,7 @@ export function createSafeChainProxy() {
|
|||
startServer: () => startServer(server),
|
||||
stopServer: () => stopServer(server),
|
||||
verifyNoMaliciousPackages,
|
||||
verifyNoMinimumAgeBlockedRequests,
|
||||
hasSuppressedVersions: getHasSuppressedVersions,
|
||||
};
|
||||
}
|
||||
|
|
@ -151,6 +157,18 @@ function handleConnect(req, clientSocket, head) {
|
|||
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);
|
||||
} else {
|
||||
|
|
@ -170,6 +188,16 @@ function onMalwareBlocked(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() {
|
||||
if (state.blockedRequests.length === 0) {
|
||||
// No malicious packages were blocked, so nothing to block
|
||||
|
|
@ -194,3 +222,35 @@ function verifyNoMaliciousPackages() {
|
|||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
|
||||
/** @type {NewPackagesDatabase | null} */
|
||||
let cachedNewPackagesDatabase = null;
|
||||
let hasWarnedAboutUnavailableNewPackagesDatabase = false;
|
||||
|
||||
/**
|
||||
* Returns the source identifier used in the feed for the current ecosystem.
|
||||
|
|
@ -42,7 +43,22 @@ export async function openNewPackagesDatabase() {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -85,6 +85,10 @@ describe("newPackagesDatabase", async () => {
|
|||
return module.openNewPackagesDatabase();
|
||||
}
|
||||
|
||||
async function loadNewPackagesDatabaseModule() {
|
||||
return import(`./newPackagesDatabase.js?test_case=${importCounter++}`);
|
||||
}
|
||||
|
||||
function hoursAgo(hours) {
|
||||
return Math.floor((Date.now() - hours * 3600 * 1000) / 1000);
|
||||
}
|
||||
|
|
@ -226,5 +230,22 @@ describe("newPackagesDatabase", async () => {
|
|||
assert.strictEqual(writeWarningCalls.length, 1);
|
||||
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"
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue