mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 20:20:49 +00:00
Merge pull request #151 from AikidoSec/package-min-age
npm: Minimum package age
This commit is contained in:
commit
f6400e9822
15 changed files with 780 additions and 70 deletions
20
README.md
20
README.md
|
|
@ -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.
|
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.
|
||||||
|
|
||||||

|
Aikido Safe Chain works on Node.js version 16 and above and supports the following package managers:
|
||||||
|
|
||||||
Aikido Safe Chain works on Node.js version 18 and above and supports the following package managers:
|
|
||||||
|
|
||||||
- ✅ **npm**
|
- ✅ **npm**
|
||||||
- ✅ **npx**
|
- ✅ **npx**
|
||||||
|
|
@ -29,25 +27,31 @@ Installing the Aikido Safe Chain is easy. You just need 3 simple steps:
|
||||||
npm install -g @aikidosec/safe-chain
|
npm install -g @aikidosec/safe-chain
|
||||||
```
|
```
|
||||||
2. **Setup the shell integration** by running:
|
2. **Setup the shell integration** by running:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
safe-chain setup
|
safe-chain setup
|
||||||
```
|
```
|
||||||
|
|
||||||
To enable Python (pip/pip3) support (beta), use the `--include-python` flag:
|
To enable Python (pip/pip3) support (beta), use the `--include-python` flag:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
safe-chain setup --include-python
|
safe-chain setup --include-python
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **❗Restart your terminal** to start using the Aikido Safe Chain.
|
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:
|
4. **Verify the installation** by running one of the following commands:
|
||||||
|
|
||||||
For JavaScript/Node.js:
|
For JavaScript/Node.js:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
npm install safe-chain-test
|
npm install safe-chain-test
|
||||||
```
|
```
|
||||||
|
|
||||||
For Python (beta):
|
For Python (beta):
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
pip3 install safe-chain-pi-test
|
pip3 install safe-chain-pi-test
|
||||||
```
|
```
|
||||||
|
|
@ -64,8 +68,18 @@ safe-chain --version
|
||||||
|
|
||||||
## How it works
|
## 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.
|
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:
|
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**
|
- ✅ **Bash**
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
/**
|
/**
|
||||||
* @type {{loggingLevel: string | undefined, includePython: boolean}}
|
* @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, includePython: boolean}}
|
||||||
*/
|
*/
|
||||||
const state = {
|
const state = {
|
||||||
loggingLevel: undefined,
|
loggingLevel: undefined,
|
||||||
|
skipMinimumPackageAge: undefined,
|
||||||
includePython: false,
|
includePython: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -15,6 +16,7 @@ const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-";
|
||||||
export function initializeCliArguments(args) {
|
export function initializeCliArguments(args) {
|
||||||
// Reset state on each call
|
// Reset state on each call
|
||||||
state.loggingLevel = undefined;
|
state.loggingLevel = undefined;
|
||||||
|
state.skipMinimumPackageAge = undefined;
|
||||||
|
|
||||||
const safeChainArgs = [];
|
const safeChainArgs = [];
|
||||||
const remainingArgs = [];
|
const remainingArgs = [];
|
||||||
|
|
@ -28,6 +30,7 @@ export function initializeCliArguments(args) {
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoggingLevel(safeChainArgs);
|
setLoggingLevel(safeChainArgs);
|
||||||
|
setSkipMinimumPackageAge(safeChainArgs);
|
||||||
setIncludePython(args);
|
setIncludePython(args);
|
||||||
|
|
||||||
return remainingArgs;
|
return remainingArgs;
|
||||||
|
|
@ -67,6 +70,22 @@ export function getLoggingLevel() {
|
||||||
return state.loggingLevel;
|
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
|
* @param {string[]} args
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import assert from "node:assert";
|
import assert from "node:assert";
|
||||||
import { initializeCliArguments, getLoggingLevel } from "./cliArguments.js";
|
import {
|
||||||
|
initializeCliArguments,
|
||||||
|
getLoggingLevel,
|
||||||
|
getSkipMinimumPackageAge,
|
||||||
|
} from "./cliArguments.js";
|
||||||
|
|
||||||
describe("initializeCliArguments", () => {
|
describe("initializeCliArguments", () => {
|
||||||
it("should return all args when no safe-chain args are present", () => {
|
it("should return all args when no safe-chain args are present", () => {
|
||||||
|
|
@ -118,4 +122,60 @@ describe("initializeCliArguments", () => {
|
||||||
assert.deepEqual(result, ["install"]);
|
assert.deepEqual(result, ["install"]);
|
||||||
assert.strictEqual(getLoggingLevel(), "silent");
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,18 @@ export function setEcoSystem(setting) {
|
||||||
ecosystemSettings.ecoSystem = setting;
|
ecosystemSettings.ecoSystem = setting;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LOGGING_SILENT = "silent";
|
const defaultMinimumPackageAge = 24;
|
||||||
export const LOGGING_NORMAL = "normal";
|
export function getMinimumPackageAgeHours() {
|
||||||
export const LOGGING_VERBOSE = "verbose";
|
return defaultMinimumPackageAge;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultSkipMinimumPackageAge = false;
|
||||||
|
export function skipMinimumPackageAge() {
|
||||||
|
const cliValue = cliArguments.getSkipMinimumPackageAge();
|
||||||
|
|
||||||
|
if (cliValue === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultSkipMinimumPackageAge;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Returning the exit code back to the caller allows the promise
|
||||||
// to be awaited in the bin files and return the correct exit code
|
// to be awaited in the bin files and return the correct exit code
|
||||||
return packageManagerResult.status;
|
return packageManagerResult.status;
|
||||||
|
|
|
||||||
17
packages/safe-chain/src/registryProxy/http-utils.js
Normal file
17
packages/safe-chain/src/registryProxy/http-utils.js
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,16 @@ 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 {(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
|
* @property {() => RequestInterceptionHandler} build
|
||||||
*
|
*
|
||||||
*
|
*
|
||||||
* @typedef {Object} RequestInterceptionHandler
|
* @typedef {Object} RequestInterceptionHandler
|
||||||
* @property {{statusCode: number, message: string} | undefined} blockResponse
|
* @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) {
|
function createRequestContext(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[]>) => 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} packageName
|
||||||
* @param {string | undefined} version
|
* @param {string | undefined} version
|
||||||
*/
|
*/
|
||||||
function blockMalware(packageName, version) {
|
function blockMalwareSetup(packageName, version) {
|
||||||
blockResponse = {
|
blockResponse = {
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
message: "Forbidden - blocked by safe-chain",
|
message: "Forbidden - blocked by safe-chain",
|
||||||
|
|
@ -80,13 +89,52 @@ function createRequestContext(targetUrl, eventEmitter) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
/** @returns {RequestInterceptionHandler} */
|
||||||
targetUrl,
|
function build() {
|
||||||
blockMalware,
|
/**
|
||||||
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 {
|
return {
|
||||||
blockResponse,
|
blockResponse,
|
||||||
|
modifyRequestHeaders: modifyRequestHeaders,
|
||||||
|
modifiesResponse: () => modifyBodyFuncs.length > 0,
|
||||||
|
modifyBody,
|
||||||
};
|
};
|
||||||
},
|
}
|
||||||
|
|
||||||
|
// These functions are used to setup the modifications
|
||||||
|
return {
|
||||||
|
targetUrl,
|
||||||
|
blockMalware: blockMalwareSetup,
|
||||||
|
modifyRequestHeaders: (func) => reqheaderModificationFuncs.push(func),
|
||||||
|
modifyBody: (func) => modifyBodyFuncs.push(func),
|
||||||
|
build,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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,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} url
|
||||||
* @param {string} registry
|
* @param {string} registry
|
||||||
* @returns {{packageName: string | undefined, version: string | undefined}}
|
* @returns {{packageName: string | undefined, version: string | undefined}}
|
||||||
*/
|
*/
|
||||||
function parseNpmPackageUrl(url, registry) {
|
export function parseNpmPackageUrl(url, registry) {
|
||||||
let packageName, version;
|
let packageName, version;
|
||||||
if (!registry || !url.endsWith(".tgz")) {
|
if (!registry || !url.endsWith(".tgz")) {
|
||||||
return { packageName, version };
|
return { packageName, version };
|
||||||
|
|
@ -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
|
||||||
|
|
@ -68,8 +69,8 @@ function createHttpsServer(hostname, interceptor) {
|
||||||
const pathAndQuery = getRequestPathAndQuery(req.url);
|
const pathAndQuery = getRequestPathAndQuery(req.url);
|
||||||
const targetUrl = `https://${hostname}${pathAndQuery}`;
|
const targetUrl = `https://${hostname}${pathAndQuery}`;
|
||||||
|
|
||||||
const interceptorResult = await interceptor.handleRequest(targetUrl);
|
const requestInterceptor = await interceptor.handleRequest(targetUrl);
|
||||||
const blockResponse = interceptorResult.blockResponse;
|
const blockResponse = requestInterceptor.blockResponse;
|
||||||
|
|
||||||
if (blockResponse) {
|
if (blockResponse) {
|
||||||
ui.writeVerbose(`Safe-chain: Blocking request to ${targetUrl}`);
|
ui.writeVerbose(`Safe-chain: Blocking request to ${targetUrl}`);
|
||||||
|
|
@ -79,7 +80,7 @@ function createHttpsServer(hostname, interceptor) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect request body
|
// Collect request body
|
||||||
forwardRequest(req, hostname, res);
|
forwardRequest(req, hostname, res, requestInterceptor);
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = https.createServer(
|
const server = https.createServer(
|
||||||
|
|
@ -109,9 +110,10 @@ function getRequestPathAndQuery(url) {
|
||||||
* @param {import("http").IncomingMessage} req
|
* @param {import("http").IncomingMessage} req
|
||||||
* @param {string} hostname
|
* @param {string} hostname
|
||||||
* @param {import("http").ServerResponse} res
|
* @param {import("http").ServerResponse} res
|
||||||
|
* @param {import("./interceptors/interceptorBuilder.js").RequestInterceptionHandler} requestHandler
|
||||||
*/
|
*/
|
||||||
function forwardRequest(req, hostname, res) {
|
function forwardRequest(req, hostname, res, requestHandler) {
|
||||||
const proxyReq = createProxyRequest(hostname, req, res);
|
const proxyReq = createProxyRequest(hostname, req, res, requestHandler);
|
||||||
|
|
||||||
proxyReq.on("error", (err) => {
|
proxyReq.on("error", (err) => {
|
||||||
ui.writeVerbose(
|
ui.writeVerbose(
|
||||||
|
|
@ -142,23 +144,29 @@ function forwardRequest(req, hostname, res) {
|
||||||
* @param {string} hostname
|
* @param {string} hostname
|
||||||
* @param {import("http").IncomingMessage} req
|
* @param {import("http").IncomingMessage} req
|
||||||
* @param {import("http").ServerResponse} res
|
* @param {import("http").ServerResponse} res
|
||||||
|
* @param {import("./interceptors/interceptorBuilder.js").RequestInterceptionHandler} requestHandler
|
||||||
*
|
*
|
||||||
* @returns {import("http").ClientRequest}
|
* @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} */
|
/** @type {import("http").RequestOptions} */
|
||||||
const options = {
|
const options = {
|
||||||
hostname: hostname,
|
hostname: hostname,
|
||||||
port: 443,
|
port: 443,
|
||||||
path: req.url,
|
path: req.url,
|
||||||
method: req.method,
|
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;
|
const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy;
|
||||||
if (httpsProxy) {
|
if (httpsProxy) {
|
||||||
options.agent = new HttpsProxyAgent(httpsProxy);
|
options.agent = new HttpsProxyAgent(httpsProxy);
|
||||||
|
|
@ -182,8 +190,37 @@ function createProxyRequest(hostname, req, res) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
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);
|
proxyRes.pipe(res);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return proxyReq;
|
return proxyReq;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { getCaCertPath } from "./certUtils.js";
|
||||||
import { ui } from "../environment/userInteraction.js";
|
import { ui } from "../environment/userInteraction.js";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js";
|
import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js";
|
||||||
|
import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js";
|
||||||
|
|
||||||
const SERVER_STOP_TIMEOUT_MS = 1000;
|
const SERVER_STOP_TIMEOUT_MS = 1000;
|
||||||
/**
|
/**
|
||||||
|
|
@ -23,6 +24,7 @@ export function createSafeChainProxy() {
|
||||||
startServer: () => startServer(server),
|
startServer: () => startServer(server),
|
||||||
stopServer: () => stopServer(server),
|
stopServer: () => stopServer(server),
|
||||||
verifyNoMaliciousPackages,
|
verifyNoMaliciousPackages,
|
||||||
|
hasSuppressedVersions: getHasSuppressedVersions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue