Merge pull request #151 from AikidoSec/package-min-age

npm: Minimum package age
This commit is contained in:
Sander Declerck 2025-11-24 16:14:02 +01:00 committed by GitHub
commit f6400e9822
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 780 additions and 70 deletions

View file

@ -4,9 +4,7 @@ The Aikido Safe Chain **prevents developers from installing malware** on their w
The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), and [pip](https://pip.pypa.io/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, bunx, or pip/pip3 from downloading or running the malware.
![demo](./docs/safe-package-manager-demo.png)
Aikido Safe Chain works on Node.js version 18 and above and supports the following package managers:
Aikido Safe Chain works on Node.js version 16 and above and supports the following package managers:
- ✅ **npm**
- ✅ **npx**
@ -29,25 +27,31 @@ Installing the Aikido Safe Chain is easy. You just need 3 simple steps:
npm install -g @aikidosec/safe-chain
```
2. **Setup the shell integration** by running:
```shell
safe-chain setup
```
To enable Python (pip/pip3) support (beta), use the `--include-python` flag:
```shell
safe-chain setup --include-python
```
3. **❗Restart your terminal** to start using the Aikido Safe Chain.
- This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, and pip/pip3 are loaded correctly. If you do not restart your terminal, the aliases will not be available.
- This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, and pip/pip3 are loaded correctly. If you do not restart your terminal, the aliases will not be available.
4. **Verify the installation** by running one of the following commands:
For JavaScript/Node.js:
```shell
npm install safe-chain-test
```
For Python (beta):
```shell
pip3 install safe-chain-pi-test
```
@ -64,8 +68,18 @@ safe-chain --version
## How it works
### Malware Blocking
The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, `pip`, or `pip3` commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine.
### Minimum package age (npm only)
For npm packages, Safe Chain temporarily suppresses packages published within the last 24 hours until they have been validated against malware. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can bypass this protection for specific installs using the `--safe-chain-skip-minimum-package-age` flag.
⚠️ This feature **only applies to npm-based package managers** (npm, npx, yarn, pnpm, pnpx, bun, bunx) and does not apply to PyPI/pip.
### Shell Integration
The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and pip commands. It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support:
- ✅ **Bash**

View file

@ -1,8 +1,9 @@
/**
* @type {{loggingLevel: string | undefined, includePython: boolean}}
* @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, includePython: boolean}}
*/
const state = {
loggingLevel: undefined,
skipMinimumPackageAge: undefined,
includePython: false,
};
@ -15,6 +16,7 @@ const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-";
export function initializeCliArguments(args) {
// Reset state on each call
state.loggingLevel = undefined;
state.skipMinimumPackageAge = undefined;
const safeChainArgs = [];
const remainingArgs = [];
@ -28,6 +30,7 @@ export function initializeCliArguments(args) {
}
setLoggingLevel(safeChainArgs);
setSkipMinimumPackageAge(safeChainArgs);
setIncludePython(args);
return remainingArgs;
@ -67,6 +70,22 @@ export function getLoggingLevel() {
return state.loggingLevel;
}
/**
* @param {string[]} args
* @returns {void}
*/
function setSkipMinimumPackageAge(args) {
const flagName = SAFE_CHAIN_ARG_PREFIX + "skip-minimum-package-age";
if (hasFlagArg(args, flagName)) {
state.skipMinimumPackageAge = true;
}
}
export function getSkipMinimumPackageAge() {
return state.skipMinimumPackageAge;
}
/**
* @param {string[]} args
*/

View file

@ -1,6 +1,10 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { initializeCliArguments, getLoggingLevel } from "./cliArguments.js";
import {
initializeCliArguments,
getLoggingLevel,
getSkipMinimumPackageAge,
} from "./cliArguments.js";
describe("initializeCliArguments", () => {
it("should return all args when no safe-chain args are present", () => {
@ -118,4 +122,60 @@ describe("initializeCliArguments", () => {
assert.deepEqual(result, ["install"]);
assert.strictEqual(getLoggingLevel(), "silent");
});
it("should not set skipMinimumPackageAge when flag is absent", () => {
const args = ["install", "express", "--save"];
initializeCliArguments(args);
assert.strictEqual(getSkipMinimumPackageAge(), undefined);
});
it("should set skipMinimumPackageAge to true when flag is present", () => {
const args = ["--safe-chain-skip-minimum-package-age", "install", "lodash"];
const result = initializeCliArguments(args);
assert.deepEqual(result, ["install", "lodash"]);
assert.strictEqual(getSkipMinimumPackageAge(), true);
});
it("should handle skip-minimum-package-age flag case-insensitively", () => {
const args = ["--SAFE-CHAIN-SKIP-MINIMUM-PACKAGE-AGE", "install"];
initializeCliArguments(args);
assert.strictEqual(getSkipMinimumPackageAge(), true);
});
it("should filter out skip-minimum-package-age flag from returned args", () => {
const args = [
"install",
"--safe-chain-skip-minimum-package-age",
"express",
"--save",
];
const result = initializeCliArguments(args);
assert.deepEqual(result, ["install", "express", "--save"]);
});
it("should handle skip-minimum-package-age with other safe-chain arguments", () => {
const args = [
"--safe-chain-logging=verbose",
"--safe-chain-skip-minimum-package-age",
"install",
"lodash",
];
const result = initializeCliArguments(args);
assert.deepEqual(result, ["install", "lodash"]);
assert.strictEqual(getLoggingLevel(), "verbose");
assert.strictEqual(getSkipMinimumPackageAge(), true);
});
it("should handle skip-minimum-package-age flag in different positions", () => {
const args = ["install", "lodash", "--safe-chain-skip-minimum-package-age"];
const result = initializeCliArguments(args);
assert.deepEqual(result, ["install", "lodash"]);
assert.strictEqual(getSkipMinimumPackageAge(), true);
});
});

View file

@ -1,5 +1,9 @@
import * as cliArguments from "./cliArguments.js";
export const LOGGING_SILENT = "silent";
export const LOGGING_NORMAL = "normal";
export const LOGGING_VERBOSE = "verbose";
export function getLoggingLevel() {
const level = cliArguments.getLoggingLevel();
@ -14,9 +18,6 @@ export function getLoggingLevel() {
return LOGGING_NORMAL;
}
export const MALWARE_ACTION_BLOCK = "block";
export const MALWARE_ACTION_PROMPT = "prompt";
export const ECOSYSTEM_JS = "js";
export const ECOSYSTEM_PY = "py";
@ -36,6 +37,18 @@ export function setEcoSystem(setting) {
ecosystemSettings.ecoSystem = setting;
}
export const LOGGING_SILENT = "silent";
export const LOGGING_NORMAL = "normal";
export const LOGGING_VERBOSE = "verbose";
const defaultMinimumPackageAge = 24;
export function getMinimumPackageAgeHours() {
return defaultMinimumPackageAge;
}
const defaultSkipMinimumPackageAge = false;
export function skipMinimumPackageAge() {
const cliValue = cliArguments.getSkipMinimumPackageAge();
if (cliValue === true) {
return true;
}
return defaultSkipMinimumPackageAge;
}

View file

@ -72,6 +72,19 @@ export async function main(args) {
);
}
if (proxy.hasSuppressedVersions()) {
ui.writeInformation(
`${chalk.yellow(
""
)} Safe-chain: Some package versions were suppressed due to minimum age requirement.`
);
ui.writeInformation(
` To disable this check, use: ${chalk.cyan(
"--safe-chain-skip-minimum-package-age"
)}`
);
}
// Returning the exit code back to the caller allows the promise
// to be awaited in the bin files and return the correct exit code
return packageManagerResult.status;

View file

@ -0,0 +1,17 @@
/**
* @param {NodeJS.Dict<string | string[]> | undefined} headers
* @param {string} headerName
*/
export function getHeaderValueAsString(headers, headerName) {
if (!headers) {
return undefined;
}
let header = headers[headerName];
if (Array.isArray(header)) {
return header.join(", ");
}
return header;
}

View file

@ -3,7 +3,7 @@ import {
ECOSYSTEM_PY,
getEcoSystem,
} from "../../config/settings.js";
import { npmInterceptorForUrl } from "./npmInterceptor.js";
import { npmInterceptorForUrl } from "./npm/npmInterceptor.js";
import { pipInterceptorForUrl } from "./pipInterceptor.js";
/**

View file

@ -10,11 +10,16 @@ import { EventEmitter } from "events";
* @typedef {Object} RequestInterceptionContext
* @property {string} targetUrl
* @property {(packageName: string | undefined, version: string | undefined) => void} blockMalware
* @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
*
*
* @typedef {Object} RequestInterceptionHandler
* @property {{statusCode: number, message: string} | undefined} blockResponse
* @property {(headers: NodeJS.Dict<string | string[]> | undefined) => NodeJS.Dict<string | string[]> | undefined} modifyRequestHeaders
* @property {() => boolean} modifiesResponse
* @property {(body: Buffer, headers: NodeJS.Dict<string | string[]> | undefined) => Buffer} modifyBody
*/
/**
@ -60,12 +65,16 @@ function buildInterceptor(requestHandlers) {
function createRequestContext(targetUrl, eventEmitter) {
/** @type {{statusCode: number, message: string} | undefined} */
let blockResponse = undefined;
/** @type {Array<(headers: NodeJS.Dict<string | string[]>) => NodeJS.Dict<string | string[]>>} */
let reqheaderModificationFuncs = [];
/** @type {Array<(body: Buffer, headers: NodeJS.Dict<string | string[]> | undefined) => Buffer>} */
let modifyBodyFuncs = [];
/**
* @param {string | undefined} packageName
* @param {string | undefined} version
*/
function blockMalware(packageName, version) {
function blockMalwareSetup(packageName, version) {
blockResponse = {
statusCode: 403,
message: "Forbidden - blocked by safe-chain",
@ -80,13 +89,52 @@ function createRequestContext(targetUrl, eventEmitter) {
});
}
/** @returns {RequestInterceptionHandler} */
function build() {
/**
* @param {NodeJS.Dict<string | string[]> | undefined} headers
* @returns {NodeJS.Dict<string | string[]> | undefined}
*/
function modifyRequestHeaders(headers) {
if (headers) {
for (const func of reqheaderModificationFuncs) {
func(headers);
}
}
return headers;
}
/**
* @param {Buffer} body
* @param {NodeJS.Dict<string | string[]> | undefined} headers
* @returns {Buffer}
*/
function modifyBody(body, headers) {
let modifiedBody = body;
for (var func of modifyBodyFuncs) {
modifiedBody = func(body, headers);
}
return modifiedBody;
}
// These functions are invoked in the proxy, allowing to apply the configured modifications
return {
blockResponse,
modifyRequestHeaders: modifyRequestHeaders,
modifiesResponse: () => modifyBodyFuncs.length > 0,
modifyBody,
};
}
// These functions are used to setup the modifications
return {
targetUrl,
blockMalware,
build() {
return {
blockResponse,
};
},
blockMalware: blockMalwareSetup,
modifyRequestHeaders: (func) => reqheaderModificationFuncs.push(func),
modifyBody: (func) => modifyBodyFuncs.push(func),
build,
};
}

View file

@ -0,0 +1,174 @@
import { getMinimumPackageAgeHours } from "../../../config/settings.js";
import { ui } from "../../../environment/userInteraction.js";
import { getHeaderValueAsString } from "../../http-utils.js";
const state = {
hasSuppressedVersions: false,
};
/**
* @param {NodeJS.Dict<string | string[]>} headers
* @returns {NodeJS.Dict<string | string[]>}
*/
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<string | string[]> | 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;
}
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");
for (const { version, timestamp } of versions) {
const timestampValue = new Date(timestamp);
if (timestampValue > cutOff) {
deleteVersionFromJson(bodyJson, version);
if (headers) {
// When modifying the response, the etag and last-modified headers
// no longer match the content so they needs to be removed before sending the response.
delete headers["etag"];
delete headers["last-modified"];
}
}
}
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);
}
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) {
state.hasSuppressedVersions = true;
ui.writeVerbose(
`Safe-chain: ${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<string, string>} 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<string, string>} 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;
}
/**
* @returns {boolean}
*/
export function getHasSuppressedVersions() {
return state.hasSuppressedVersions;
}

View file

@ -0,0 +1,47 @@
import { skipMinimumPackageAge } from "../../../config/settings.js";
import { isMalwarePackage } from "../../../scanning/audit/index.js";
import { interceptRequests } from "../interceptorBuilder.js";
import {
isPackageInfoUrl,
modifyNpmInfoRequestHeaders,
modifyNpmInfoResponse,
} from "./modifyNpmInfo.js";
import { parseNpmPackageUrl } from "./parseNpmPackageUrl.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}
*/
function buildNpmInterceptor(registry) {
return interceptRequests(async (reqContext) => {
const { packageName, version } = parseNpmPackageUrl(
reqContext.targetUrl,
registry
);
if (await isMalwarePackage(packageName, version)) {
reqContext.blockMalware(packageName, version);
}
if (!skipMinimumPackageAge() && isPackageInfoUrl(reqContext.targetUrl)) {
reqContext.modifyRequestHeaders(modifyNpmInfoRequestHeaders);
reqContext.modifyBody(modifyNpmInfoResponse);
}
});
}

View file

@ -0,0 +1,301 @@
import { describe, it, mock } from "node:test";
import assert from "node:assert";
describe("npmInterceptor minimum package age", async () => {
let minimumPackageAgeSettings = 48;
let skipMinimumPackageAgeSetting = false;
mock.module("../../../config/settings.js", {
namedExports: {
getMinimumPackageAgeHours: () => minimumPackageAgeSettings,
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
},
});
mock.module("../../../scanning/audit/index.js", {
namedExports: {
isMalwarePackage: async () => {
return false;
},
},
});
mock.module("../../../environment/userInteraction.js", {
namedExports: {
ui: {
startProcess: () => {},
writeError: () => {},
writeInformation: () => {},
writeWarning: () => {},
writeVerbose: () => {},
writeExitWithoutInstallingMaliciousPackages: () => {},
emptyLine: () => {},
},
},
});
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),
modified: getDate(-3),
["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, 3);
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),
modified: getDate(-3),
["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),
modified: getDate(-4),
["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);
});
it("Should not filter packages when skipMinimumPackageAge is enabled", async () => {
minimumPackageAgeSettings = 5;
skipMinimumPackageAgeSetting = true;
const packageUrl = "https://registry.npmjs.org/lodash";
const originalBody = 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),
modified: getDate(-3),
["1.0.0"]: getDate(-7),
// cutoff-date here
["2.0.0"]: getDate(-4),
["3.0.0"]: getDate(-3),
},
});
const modifiedBody = await runModifyNpmInfoRequest(
packageUrl,
originalBody
);
const modifiedJson = JSON.parse(modifiedBody);
// All versions should remain unchanged
assert.equal(Object.keys(modifiedJson.versions).length, 3);
assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0"));
assert.ok(Object.keys(modifiedJson.versions).includes("2.0.0"));
assert.ok(Object.keys(modifiedJson.versions).includes("3.0.0"));
// Latest should remain unchanged
assert.equal(modifiedJson["dist-tags"]["latest"], "3.0.0");
});
function getDate(plusHours) {
const date = new Date();
date.setHours(date.getHours() + plusHours);
return date;
}
/**
* @param {import("../interceptorBuilder.js").Interceptor} interceptor
* @param {string} body
* @returns {Promise<string>}
*/
async function runModifyNpmInfoRequest(url, body) {
const interceptor = npmInterceptorForUrl(url);
const requestHandler = await interceptor.handleRequest(url);
if (requestHandler.modifiesResponse()) {
const modifiedBuffer = requestHandler.modifyBody(Buffer.from(body), {
["content-type"]: "application/json",
});
return modifiedBuffer.toString("utf8");
}
return body;
}
});

View file

@ -5,7 +5,7 @@ describe("npmInterceptor", async () => {
let lastPackage;
let malwareResponse = false;
mock.module("../../scanning/audit/index.js", {
mock.module("../../../scanning/audit/index.js", {
namedExports: {
isMalwarePackage: async (packageName, version) => {
lastPackage = { packageName, version };

View file

@ -1,44 +1,9 @@
import { isMalwarePackage } from "../../scanning/audit/index.js";
import { interceptRequests } 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) {
return interceptRequests(async (reqContext) => {
const { packageName, version } = parseNpmPackageUrl(
reqContext.targetUrl,
registry
);
if (await isMalwarePackage(packageName, version)) {
reqContext.blockMalware(packageName, version);
}
});
}
/**
* @param {string} url
* @param {string} registry
* @returns {{packageName: string | undefined, version: string | undefined}}
*/
function parseNpmPackageUrl(url, registry) {
export function parseNpmPackageUrl(url, registry) {
let packageName, version;
if (!registry || !url.endsWith(".tgz")) {
return { packageName, version };

View file

@ -2,6 +2,7 @@ import https from "https";
import { generateCertForHost } from "./certUtils.js";
import { HttpsProxyAgent } from "https-proxy-agent";
import { ui } from "../environment/userInteraction.js";
import { gunzipSync, gzipSync } from "zlib";
/**
* @typedef {import("./interceptors/interceptorBuilder.js").Interceptor} Interceptor
@ -68,8 +69,8 @@ function createHttpsServer(hostname, interceptor) {
const pathAndQuery = getRequestPathAndQuery(req.url);
const targetUrl = `https://${hostname}${pathAndQuery}`;
const interceptorResult = await interceptor.handleRequest(targetUrl);
const blockResponse = interceptorResult.blockResponse;
const requestInterceptor = await interceptor.handleRequest(targetUrl);
const blockResponse = requestInterceptor.blockResponse;
if (blockResponse) {
ui.writeVerbose(`Safe-chain: Blocking request to ${targetUrl}`);
@ -79,7 +80,7 @@ function createHttpsServer(hostname, interceptor) {
}
// Collect request body
forwardRequest(req, hostname, res);
forwardRequest(req, hostname, res, requestInterceptor);
}
const server = https.createServer(
@ -109,9 +110,10 @@ function getRequestPathAndQuery(url) {
* @param {import("http").IncomingMessage} req
* @param {string} hostname
* @param {import("http").ServerResponse} res
* @param {import("./interceptors/interceptorBuilder.js").RequestInterceptionHandler} requestHandler
*/
function forwardRequest(req, hostname, res) {
const proxyReq = createProxyRequest(hostname, req, res);
function forwardRequest(req, hostname, res, requestHandler) {
const proxyReq = createProxyRequest(hostname, req, res, requestHandler);
proxyReq.on("error", (err) => {
ui.writeVerbose(
@ -142,23 +144,29 @@ function forwardRequest(req, hostname, res) {
* @param {string} hostname
* @param {import("http").IncomingMessage} req
* @param {import("http").ServerResponse} res
* @param {import("./interceptors/interceptorBuilder.js").RequestInterceptionHandler} requestHandler
*
* @returns {import("http").ClientRequest}
*/
function createProxyRequest(hostname, req, res) {
function createProxyRequest(hostname, req, res, requestHandler) {
/** @type {NodeJS.Dict<string | string[]> | undefined} */
let headers = { ...req.headers };
// Remove the host header from the incoming request before forwarding.
// Node's http module sets the correct host header for the target hostname automatically.
if (headers.host) {
delete headers.host;
}
headers = requestHandler.modifyRequestHeaders(headers);
/** @type {import("http").RequestOptions} */
const options = {
hostname: hostname,
port: 443,
path: req.url,
method: req.method,
headers: { ...req.headers },
headers: { ...headers },
};
if (options.headers && "host" in options.headers) {
delete options.headers.host;
}
const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy;
if (httpsProxy) {
options.agent = new HttpsProxyAgent(httpsProxy);
@ -182,8 +190,37 @@ function createProxyRequest(hostname, req, res) {
return;
}
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res);
const { statusCode, headers } = proxyRes;
if (requestHandler.modifiesResponse()) {
/** @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 = requestHandler.modifyBody(buffer, headers);
if (proxyRes.headers["content-encoding"] === "gzip") {
buffer = gzipSync(buffer);
}
res.writeHead(statusCode, headers);
res.end(buffer);
});
} else {
// If the response is not being modified, we can
// just pipe without the need for buffering the output
res.writeHead(statusCode, headers);
proxyRes.pipe(res);
}
});
return proxyReq;

View file

@ -6,6 +6,7 @@ import { getCaCertPath } from "./certUtils.js";
import { ui } from "../environment/userInteraction.js";
import chalk from "chalk";
import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js";
import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js";
const SERVER_STOP_TIMEOUT_MS = 1000;
/**
@ -23,6 +24,7 @@ export function createSafeChainProxy() {
startServer: () => startServer(server),
stopServer: () => stopServer(server),
verifyNoMaliciousPackages,
hasSuppressedVersions: getHasSuppressedVersions,
};
}