mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Merge branch 'main' into feature/add-rush-monorepo-support
This commit is contained in:
commit
f26cdab1f6
34 changed files with 1604 additions and 77 deletions
41
.github/workflows/build-and-release.yml
vendored
41
.github/workflows/build-and-release.yml
vendored
|
|
@ -4,6 +4,8 @@ on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "*"
|
- "*"
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|
@ -12,30 +14,19 @@ permissions:
|
||||||
jobs:
|
jobs:
|
||||||
set-version:
|
set-version:
|
||||||
name: Set version number
|
name: Set version number
|
||||||
|
if: github.event_name == 'push'
|
||||||
runs-on: open-source-releaser
|
runs-on: open-source-releaser
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.get_version.outputs.tag }}
|
version: ${{ steps.get_version.outputs.tag }}
|
||||||
is_prerelease: ${{ steps.check_prerelease.outputs.is_prerelease }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Set version number
|
- name: Set version number
|
||||||
id: get_version
|
id: get_version
|
||||||
run: |
|
run: |
|
||||||
version="${{ github.ref_name }}"
|
version="${{ github.ref_name }}"
|
||||||
echo "tag=$version" >> $GITHUB_OUTPUT
|
echo "tag=$version" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Check if pre-release
|
|
||||||
id: check_prerelease
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
IS_PRERELEASE=$(gh release view ${{ steps.get_version.outputs.tag }} --json isPrerelease --jq '.isPrerelease')
|
|
||||||
echo "is_prerelease=$IS_PRERELEASE" >> $GITHUB_OUTPUT
|
|
||||||
echo "Release ${{ steps.get_version.outputs.tag }} is pre-release: $IS_PRERELEASE"
|
|
||||||
|
|
||||||
create-binaries:
|
create-binaries:
|
||||||
|
if: github.event_name == 'push'
|
||||||
needs: set-version
|
needs: set-version
|
||||||
uses: ./.github/workflows/create-artifact.yml
|
uses: ./.github/workflows/create-artifact.yml
|
||||||
with:
|
with:
|
||||||
|
|
@ -43,6 +34,7 @@ jobs:
|
||||||
|
|
||||||
publish-binaries:
|
publish-binaries:
|
||||||
name: Publish to GitHub release
|
name: Publish to GitHub release
|
||||||
|
if: github.event_name == 'push'
|
||||||
needs: [set-version, create-binaries]
|
needs: [set-version, create-binaries]
|
||||||
runs-on: open-source-releaser
|
runs-on: open-source-releaser
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -81,11 +73,15 @@ jobs:
|
||||||
cp install-scripts/uninstall-endpoint-mac.sh release-artifacts/uninstall-endpoint-mac.sh
|
cp install-scripts/uninstall-endpoint-mac.sh release-artifacts/uninstall-endpoint-mac.sh
|
||||||
cp install-scripts/uninstall-endpoint-windows.ps1 release-artifacts/uninstall-endpoint-windows.ps1
|
cp install-scripts/uninstall-endpoint-windows.ps1 release-artifacts/uninstall-endpoint-windows.ps1
|
||||||
|
|
||||||
- name: Upload binaries to existing GitHub Release
|
- name: Create draft release and upload assets
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
VERSION: ${{ needs.set-version.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
gh release upload ${{ needs.set-version.outputs.version }} \
|
if ! gh release view "$VERSION" &>/dev/null; then
|
||||||
|
gh release create "$VERSION" --draft --title "$VERSION" --generate-notes
|
||||||
|
fi
|
||||||
|
gh release upload "$VERSION" --clobber \
|
||||||
release-artifacts/safe-chain-macos-x64 \
|
release-artifacts/safe-chain-macos-x64 \
|
||||||
release-artifacts/safe-chain-macos-arm64 \
|
release-artifacts/safe-chain-macos-arm64 \
|
||||||
release-artifacts/safe-chain-linux-x64 \
|
release-artifacts/safe-chain-linux-x64 \
|
||||||
|
|
@ -105,8 +101,6 @@ jobs:
|
||||||
|
|
||||||
publish-npm:
|
publish-npm:
|
||||||
name: Publish to npm
|
name: Publish to npm
|
||||||
needs: [set-version, create-binaries]
|
|
||||||
if: needs.set-version.outputs.is_prerelease != 'true'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -125,7 +119,7 @@ jobs:
|
||||||
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
|
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
|
||||||
|
|
||||||
- name: Set the version in safe-chain package
|
- name: Set the version in safe-chain package
|
||||||
run: npm --no-git-tag-version version ${{ needs.set-version.outputs.version }} --workspace=packages/safe-chain
|
run: npm --no-git-tag-version version ${{ github.event.release.tag_name }} --workspace=packages/safe-chain
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
@ -138,8 +132,15 @@ jobs:
|
||||||
cp README.md packages/safe-chain/
|
cp README.md packages/safe-chain/
|
||||||
cp LICENSE packages/safe-chain/
|
cp LICENSE packages/safe-chain/
|
||||||
cp -r docs packages/safe-chain/
|
cp -r docs packages/safe-chain/
|
||||||
|
cp npm-shrinkwrap.json packages/safe-chain/
|
||||||
|
|
||||||
- name: Publish to npm
|
- name: Publish to npm
|
||||||
run: |
|
run: |
|
||||||
echo "Publishing version ${{ needs.set-version.outputs.version }} to NPM"
|
VERSION="${{ github.event.release.tag_name }}"
|
||||||
npm publish --workspace=packages/safe-chain --access public --provenance
|
echo "Publishing version $VERSION to NPM"
|
||||||
|
if [[ "$VERSION" == *"-"* ]]; then
|
||||||
|
PRERELEASE_TAG=$(echo "$VERSION" | sed 's/.*-\([^-]*\)$/\1/')
|
||||||
|
npm publish --workspace=packages/safe-chain --access public --provenance --tag "$PRERELEASE_TAG"
|
||||||
|
else
|
||||||
|
npm publish --workspace=packages/safe-chain --access public --provenance
|
||||||
|
fi
|
||||||
|
|
|
||||||
1
.github/workflows/create-artifact.yml
vendored
1
.github/workflows/create-artifact.yml
vendored
|
|
@ -80,6 +80,7 @@ jobs:
|
||||||
if: inputs.version != ''
|
if: inputs.version != ''
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ inputs.version }}
|
VERSION: ${{ inputs.version }}
|
||||||
|
shell: bash
|
||||||
run: npm --no-git-tag-version version $VERSION --workspace=packages/safe-chain --ignore-scripts
|
run: npm --no-git-tag-version version $VERSION --workspace=packages/safe-chain --ignore-scripts
|
||||||
|
|
||||||
- name: Create binary
|
- name: Create binary
|
||||||
|
|
|
||||||
43
README.md
43
README.md
|
|
@ -122,7 +122,8 @@ Current enforcement differs by ecosystem:
|
||||||
- during normal package resolution, Safe Chain suppresses versions that are newer than the configured minimum age from the package metadata returned by the registry
|
- during normal package resolution, Safe Chain suppresses versions that are newer than the configured minimum age from the package metadata returned by the registry
|
||||||
- for direct package download requests that bypass that metadata flow, Safe Chain can block the request itself using a cached list of newly released packages
|
- for direct package download requests that bypass that metadata flow, Safe Chain can block the request itself using a cached list of newly released packages
|
||||||
- Python package managers:
|
- Python package managers:
|
||||||
- Safe Chain blocks direct package download requests using a cached list of newly released packages
|
- during package resolution, Safe Chain suppresses too-young files and releases from PyPI metadata responses
|
||||||
|
- for direct package download requests that bypass that metadata flow, Safe Chain can block the request itself using a cached list of newly released packages
|
||||||
|
|
||||||
By default, the minimum package age is 48 hours. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can configure this threshold or bypass this protection entirely - see the [Minimum Package Age Configuration](#minimum-package-age) section below.
|
By default, the minimum package age is 48 hours. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can configure this threshold or bypass this protection entirely - see the [Minimum Package Age Configuration](#minimum-package-age) section below.
|
||||||
|
|
||||||
|
|
@ -199,7 +200,10 @@ For npm-based package managers, this check currently has two enforcement modes:
|
||||||
- Safe Chain suppresses too-young versions from package metadata during normal dependency resolution.
|
- Safe Chain suppresses too-young versions from package metadata during normal dependency resolution.
|
||||||
- Safe Chain blocks direct package download requests when they are matched against the cached newly released packages list.
|
- Safe Chain blocks direct package download requests when they are matched against the cached newly released packages list.
|
||||||
|
|
||||||
For Python package managers, Safe Chain currently enforces minimum package age by blocking direct package download requests when they are matched against the cached newly released packages list.
|
For Python package managers, this check currently has two enforcement modes:
|
||||||
|
|
||||||
|
- Safe Chain suppresses too-young files and releases from PyPI metadata during dependency resolution.
|
||||||
|
- Safe Chain blocks direct package download requests when they are matched against the cached newly released packages list.
|
||||||
|
|
||||||
### Configuration Options
|
### Configuration Options
|
||||||
|
|
||||||
|
|
@ -278,6 +282,41 @@ You can set custom registries through environment variable or config file. Both
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Malware List Base URL
|
||||||
|
|
||||||
|
Configure Safe Chain to fetch malware databases and new packages lists from a custom mirror URL. This allows you to host your own copy of the Aikido malware database.
|
||||||
|
|
||||||
|
### Configuration Options
|
||||||
|
|
||||||
|
You can set the malware list base URL through multiple sources (in order of priority):
|
||||||
|
|
||||||
|
1. **CLI Argument** (highest priority):
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npm install express --safe-chain-malware-list-base-url=https://your-mirror.com
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Environment Variable**:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
export SAFE_CHAIN_MALWARE_LIST_BASE_URL=https://your-mirror.com
|
||||||
|
npm install express
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Config File** (`~/.safe-chain/config.json`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"malwareListBaseUrl": "https://your-mirror.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The base URL should point to a server that mirrors the structure of `https://malware-list.aikido.dev/`, including the following paths:
|
||||||
|
- `/malware_predictions.json` (JavaScript ecosystem malware database)
|
||||||
|
- `/malware_pypi.json` (Python ecosystem malware database)
|
||||||
|
- `/releases/npm.json` (JavaScript new packages list)
|
||||||
|
- `/releases/pypi.json` (Python new packages list)
|
||||||
|
|
||||||
# Usage in CI/CD
|
# Usage in CI/CD
|
||||||
|
|
||||||
You can protect your CI/CD pipelines from malicious packages by integrating Aikido Safe Chain into your build process. This ensures that any packages installed during your automated builds are checked for malware before installation.
|
You can protect your CI/CD pipelines from malicious packages by integrating Aikido Safe Chain into your build process. This ensures that any packages installed during your automated builds are checked for malware before installation.
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@
|
||||||
set -e # Exit on error
|
set -e # Exit on error
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.11/EndpointProtection.pkg"
|
INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.12/EndpointProtection.pkg"
|
||||||
DOWNLOAD_SHA256="17cbe86a9ca444a900162c833ab5f4974b17509f8fcf93fd6a04e7ec4cc90aed"
|
DOWNLOAD_SHA256="26492f3cbb1094532dc298199842eb97d60cc670552c9c256314960b298ee784"
|
||||||
TOKEN_FILE="/tmp/aikido_endpoint_token.txt"
|
TOKEN_FILE="/tmp/aikido_endpoint_token.txt"
|
||||||
|
|
||||||
# Colors for output
|
# Colors for output
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ param(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.11/EndpointProtection.msi"
|
$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.12/EndpointProtection.msi"
|
||||||
$DownloadSha256 = "cc191b9e5d8817bf8b063c12277d4d6d591b3ea90e83723199c979d3133ce202"
|
$DownloadSha256 = "06308fc06f95f4b2ad9e48bfd978eb8d02c2928f2ee3c8bba2c81ef2fde21e4f"
|
||||||
|
|
||||||
# Ensure TLS 1.2 is enabled for downloads
|
# Ensure TLS 1.2 is enabled for downloads
|
||||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
|
|
||||||
0
package-lock.json → npm-shrinkwrap.json
generated
0
package-lock.json → npm-shrinkwrap.json
generated
|
|
@ -3,17 +3,18 @@ import {
|
||||||
getEcoSystem,
|
getEcoSystem,
|
||||||
ECOSYSTEM_JS,
|
ECOSYSTEM_JS,
|
||||||
ECOSYSTEM_PY,
|
ECOSYSTEM_PY,
|
||||||
|
getMalwareListBaseUrl,
|
||||||
} from "../config/settings.js";
|
} from "../config/settings.js";
|
||||||
import { ui } from "../environment/userInteraction.js";
|
import { ui } from "../environment/userInteraction.js";
|
||||||
|
|
||||||
const malwareDatabaseUrls = {
|
const malwareDatabasePaths = {
|
||||||
[ECOSYSTEM_JS]: "https://malware-list.aikido.dev/malware_predictions.json",
|
[ECOSYSTEM_JS]: "malware_predictions.json",
|
||||||
[ECOSYSTEM_PY]: "https://malware-list.aikido.dev/malware_pypi.json",
|
[ECOSYSTEM_PY]: "malware_pypi.json",
|
||||||
};
|
};
|
||||||
|
|
||||||
const newPackagesListUrls = {
|
const newPackagesListPaths = {
|
||||||
[ECOSYSTEM_JS]: "https://malware-list.aikido.dev/releases/npm.json",
|
[ECOSYSTEM_JS]: "releases/npm.json",
|
||||||
[ECOSYSTEM_PY]: "https://malware-list.aikido.dev/releases/pypi.json",
|
[ECOSYSTEM_PY]: "releases/pypi.json",
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_FETCH_RETRY_ATTEMPTS = 4;
|
const DEFAULT_FETCH_RETRY_ATTEMPTS = 4;
|
||||||
|
|
@ -40,10 +41,11 @@ const DEFAULT_FETCH_RETRY_ATTEMPTS = 4;
|
||||||
export async function fetchMalwareDatabase() {
|
export async function fetchMalwareDatabase() {
|
||||||
return retry(async () => {
|
return retry(async () => {
|
||||||
const ecosystem = getEcoSystem();
|
const ecosystem = getEcoSystem();
|
||||||
const malwareDatabaseUrl =
|
const baseUrl = getMalwareListBaseUrl();
|
||||||
malwareDatabaseUrls[
|
const path = malwareDatabasePaths[
|
||||||
/** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem)
|
/** @type {keyof typeof malwareDatabasePaths} */ (ecosystem)
|
||||||
];
|
];
|
||||||
|
const malwareDatabaseUrl = `${baseUrl}/${path}`;
|
||||||
const response = await fetch(malwareDatabaseUrl);
|
const response = await fetch(malwareDatabaseUrl);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -69,10 +71,11 @@ export async function fetchMalwareDatabase() {
|
||||||
export async function fetchMalwareDatabaseVersion() {
|
export async function fetchMalwareDatabaseVersion() {
|
||||||
return retry(async () => {
|
return retry(async () => {
|
||||||
const ecosystem = getEcoSystem();
|
const ecosystem = getEcoSystem();
|
||||||
const malwareDatabaseUrl =
|
const baseUrl = getMalwareListBaseUrl();
|
||||||
malwareDatabaseUrls[
|
const path = malwareDatabasePaths[
|
||||||
/** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem)
|
/** @type {keyof typeof malwareDatabasePaths} */ (ecosystem)
|
||||||
];
|
];
|
||||||
|
const malwareDatabaseUrl = `${baseUrl}/${path}`;
|
||||||
const response = await fetch(malwareDatabaseUrl, {
|
const response = await fetch(malwareDatabaseUrl, {
|
||||||
method: "HEAD",
|
method: "HEAD",
|
||||||
});
|
});
|
||||||
|
|
@ -92,13 +95,15 @@ export async function fetchMalwareDatabaseVersion() {
|
||||||
export async function fetchNewPackagesList() {
|
export async function fetchNewPackagesList() {
|
||||||
return retry(async () => {
|
return retry(async () => {
|
||||||
const ecosystem = getEcoSystem();
|
const ecosystem = getEcoSystem();
|
||||||
const url =
|
const baseUrl = getMalwareListBaseUrl();
|
||||||
newPackagesListUrls[/** @type {keyof typeof newPackagesListUrls} */ (ecosystem)];
|
const path = newPackagesListPaths[/** @type {keyof typeof newPackagesListPaths} */ (ecosystem)];
|
||||||
|
|
||||||
if (!url) {
|
if (!path) {
|
||||||
return { newPackagesList: [], version: undefined };
|
return { newPackagesList: [], version: undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const url = `${baseUrl}/${path}`;
|
||||||
|
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -124,13 +129,15 @@ export async function fetchNewPackagesList() {
|
||||||
export async function fetchNewPackagesListVersion() {
|
export async function fetchNewPackagesListVersion() {
|
||||||
return retry(async () => {
|
return retry(async () => {
|
||||||
const ecosystem = getEcoSystem();
|
const ecosystem = getEcoSystem();
|
||||||
const url =
|
const baseUrl = getMalwareListBaseUrl();
|
||||||
newPackagesListUrls[/** @type {keyof typeof newPackagesListUrls} */ (ecosystem)];
|
const path = newPackagesListPaths[/** @type {keyof typeof newPackagesListPaths} */ (ecosystem)];
|
||||||
|
|
||||||
if (!url) {
|
if (!path) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const url = `${baseUrl}/${path}`;
|
||||||
|
|
||||||
const response = await fetch(url, { method: "HEAD" });
|
const response = await fetch(url, { method: "HEAD" });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ describe("aikido API", async () => {
|
||||||
getEcoSystem: () => ecosystem,
|
getEcoSystem: () => ecosystem,
|
||||||
ECOSYSTEM_JS: "js",
|
ECOSYSTEM_JS: "js",
|
||||||
ECOSYSTEM_PY: "py",
|
ECOSYSTEM_PY: "py",
|
||||||
|
getMalwareListBaseUrl: () => "https://malware-list.aikido.dev",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -184,6 +185,15 @@ describe("aikido API", async () => {
|
||||||
assert.deepStrictEqual(result.newPackagesList, []);
|
assert.deepStrictEqual(result.newPackagesList, []);
|
||||||
assert.strictEqual(result.version, undefined);
|
assert.strictEqual(result.version, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should return undefined version without fetching for unsupported ecosystems", async () => {
|
||||||
|
ecosystem = "ruby";
|
||||||
|
|
||||||
|
const result = await fetchNewPackagesListVersion();
|
||||||
|
|
||||||
|
assert.strictEqual(mockFetch.mock.calls.length, 0);
|
||||||
|
assert.strictEqual(result, undefined);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("fetchNewPackagesListVersion", () => {
|
describe("fetchNewPackagesListVersion", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { ui } from "../environment/userInteraction.js";
|
import { ui } from "../environment/userInteraction.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, minimumPackageAgeHours: string | undefined}}
|
* @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, minimumPackageAgeHours: string | undefined, malwareListBaseUrl: string | undefined}}
|
||||||
*/
|
*/
|
||||||
const state = {
|
const state = {
|
||||||
loggingLevel: undefined,
|
loggingLevel: undefined,
|
||||||
skipMinimumPackageAge: undefined,
|
skipMinimumPackageAge: undefined,
|
||||||
minimumPackageAgeHours: undefined,
|
minimumPackageAgeHours: undefined,
|
||||||
|
malwareListBaseUrl: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-";
|
const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-";
|
||||||
|
|
@ -20,6 +21,7 @@ export function initializeCliArguments(args) {
|
||||||
state.loggingLevel = undefined;
|
state.loggingLevel = undefined;
|
||||||
state.skipMinimumPackageAge = undefined;
|
state.skipMinimumPackageAge = undefined;
|
||||||
state.minimumPackageAgeHours = undefined;
|
state.minimumPackageAgeHours = undefined;
|
||||||
|
state.malwareListBaseUrl = undefined;
|
||||||
|
|
||||||
const safeChainArgs = [];
|
const safeChainArgs = [];
|
||||||
const remainingArgs = [];
|
const remainingArgs = [];
|
||||||
|
|
@ -35,6 +37,7 @@ export function initializeCliArguments(args) {
|
||||||
setLoggingLevel(safeChainArgs);
|
setLoggingLevel(safeChainArgs);
|
||||||
setSkipMinimumPackageAge(safeChainArgs);
|
setSkipMinimumPackageAge(safeChainArgs);
|
||||||
setMinimumPackageAgeHours(safeChainArgs);
|
setMinimumPackageAgeHours(safeChainArgs);
|
||||||
|
setMalwareListBaseUrl(safeChainArgs);
|
||||||
checkDeprecatedPythonFlag(args);
|
checkDeprecatedPythonFlag(args);
|
||||||
return remainingArgs;
|
return remainingArgs;
|
||||||
}
|
}
|
||||||
|
|
@ -109,6 +112,26 @@ export function getMinimumPackageAgeHours() {
|
||||||
return state.minimumPackageAgeHours;
|
return state.minimumPackageAgeHours;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string[]} args
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function setMalwareListBaseUrl(args) {
|
||||||
|
const argName = SAFE_CHAIN_ARG_PREFIX + "malware-list-base-url=";
|
||||||
|
|
||||||
|
const value = getLastArgEqualsValue(args, argName);
|
||||||
|
if (value) {
|
||||||
|
state.malwareListBaseUrl = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {string | undefined}
|
||||||
|
*/
|
||||||
|
export function getMalwareListBaseUrl() {
|
||||||
|
return state.malwareListBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string[]} args
|
* @param {string[]} args
|
||||||
* @param {string} flagName
|
* @param {string} flagName
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { getEcoSystem } from "./settings.js";
|
||||||
* We cannot trust the input and should add the necessary validations
|
* We cannot trust the input and should add the necessary validations
|
||||||
* @property {unknown | Number} scanTimeout
|
* @property {unknown | Number} scanTimeout
|
||||||
* @property {unknown | Number} minimumPackageAgeHours
|
* @property {unknown | Number} minimumPackageAgeHours
|
||||||
|
* @property {unknown | string} malwareListBaseUrl
|
||||||
* @property {unknown | SafeChainRegistryConfiguration} npm
|
* @property {unknown | SafeChainRegistryConfiguration} npm
|
||||||
* @property {unknown | SafeChainRegistryConfiguration} pip
|
* @property {unknown | SafeChainRegistryConfiguration} pip
|
||||||
*
|
*
|
||||||
|
|
@ -84,6 +85,18 @@ export function getMinimumPackageAgeHours() {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the malware list base URL from config file only
|
||||||
|
* @returns {string | undefined}
|
||||||
|
*/
|
||||||
|
export function getMalwareListBaseUrl() {
|
||||||
|
const config = readConfigFile();
|
||||||
|
if (config.malwareListBaseUrl && typeof config.malwareListBaseUrl === "string") {
|
||||||
|
return config.malwareListBaseUrl;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the custom npm registries from the config file (format parsing only, no validation)
|
* Gets the custom npm registries from the config file (format parsing only, no validation)
|
||||||
* @returns {string[]}
|
* @returns {string[]}
|
||||||
|
|
@ -214,6 +227,7 @@ function readConfigFile() {
|
||||||
const emptyConfig = {
|
const emptyConfig = {
|
||||||
scanTimeout: undefined,
|
scanTimeout: undefined,
|
||||||
minimumPackageAgeHours: undefined,
|
minimumPackageAgeHours: undefined,
|
||||||
|
malwareListBaseUrl: undefined,
|
||||||
npm: {
|
npm: {
|
||||||
customRegistries: undefined,
|
customRegistries: undefined,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -45,3 +45,13 @@ export function getMinimumPackageAgeExclusions() {
|
||||||
return process.env.SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS ||
|
return process.env.SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS ||
|
||||||
process.env.SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS;
|
process.env.SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the malware list base URL from environment variable
|
||||||
|
* Expected format: full URL without trailing slash
|
||||||
|
* Example: "https://malware-list.aikido.dev"
|
||||||
|
* @returns {string | undefined}
|
||||||
|
*/
|
||||||
|
export function getMalwareListBaseUrl() {
|
||||||
|
return process.env.SAFE_CHAIN_MALWARE_LIST_BASE_URL;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import * as cliArguments from "./cliArguments.js";
|
import * as cliArguments from "./cliArguments.js";
|
||||||
import * as configFile from "./configFile.js";
|
import * as configFile from "./configFile.js";
|
||||||
import * as environmentVariables from "./environmentVariables.js";
|
import * as environmentVariables from "./environmentVariables.js";
|
||||||
|
import { ui } from "../environment/userInteraction.js";
|
||||||
|
|
||||||
export const LOGGING_SILENT = "silent";
|
export const LOGGING_SILENT = "silent";
|
||||||
export const LOGGING_NORMAL = "normal";
|
export const LOGGING_NORMAL = "normal";
|
||||||
|
|
@ -198,3 +199,49 @@ export function getMinimumPackageAgeExclusions() {
|
||||||
const allExclusions = [...envExclusions, ...configExclusions];
|
const allExclusions = [...envExclusions, ...configExclusions];
|
||||||
return [...new Set(allExclusions)];
|
return [...new Set(allExclusions)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the malware list base URL with priority: CLI argument > environment variable > config file > default
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function getMalwareListBaseUrl() {
|
||||||
|
// Priority 1: CLI argument
|
||||||
|
const cliValue = cliArguments.getMalwareListBaseUrl();
|
||||||
|
if (cliValue) {
|
||||||
|
const url = removeTrailingSlashes(cliValue);
|
||||||
|
ui.writeVerbose(`Fetching malware lists from ${url} as defined by CLI argument --safe-chain-malware-list-base-url`);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: Environment variable
|
||||||
|
const envValue = environmentVariables.getMalwareListBaseUrl();
|
||||||
|
if (envValue) {
|
||||||
|
const url = removeTrailingSlashes(envValue);
|
||||||
|
ui.writeVerbose(`Fetching malware lists from ${url} as defined by environment variable SAFE_CHAIN_MALWARE_LIST_BASE_URL`);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 3: Config file
|
||||||
|
const configValue = configFile.getMalwareListBaseUrl();
|
||||||
|
if (configValue) {
|
||||||
|
const url = removeTrailingSlashes(configValue);
|
||||||
|
ui.writeVerbose(`Fetching malware lists from ${url} as defined by config file (malwareListBaseUrl)`);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default
|
||||||
|
return removeTrailingSlashes("https://malware-list.aikido.dev");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes trailing slashes from a URL-like string.
|
||||||
|
* @param {string} value
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function removeTrailingSlashes(value) {
|
||||||
|
if (!value || typeof value !== "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ const {
|
||||||
getNpmCustomRegistries,
|
getNpmCustomRegistries,
|
||||||
getPipCustomRegistries,
|
getPipCustomRegistries,
|
||||||
getMinimumPackageAgeExclusions,
|
getMinimumPackageAgeExclusions,
|
||||||
|
getMalwareListBaseUrl,
|
||||||
setEcoSystem,
|
setEcoSystem,
|
||||||
ECOSYSTEM_JS,
|
ECOSYSTEM_JS,
|
||||||
ECOSYSTEM_PY,
|
ECOSYSTEM_PY,
|
||||||
|
|
@ -534,3 +535,113 @@ describe("getMinimumPackageAgeExclusions", () => {
|
||||||
assert.deepStrictEqual(exclusions, ["requests", "urllib3"]);
|
assert.deepStrictEqual(exclusions, ["requests", "urllib3"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getMalwareListBaseUrl", () => {
|
||||||
|
let originalEnv;
|
||||||
|
const envVarName = "SAFE_CHAIN_MALWARE_LIST_BASE_URL";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalEnv = process.env[envVarName];
|
||||||
|
delete process.env[envVarName];
|
||||||
|
// Reset CLI arguments state
|
||||||
|
initializeCliArguments([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalEnv !== undefined) {
|
||||||
|
process.env[envVarName] = originalEnv;
|
||||||
|
} else {
|
||||||
|
delete process.env[envVarName];
|
||||||
|
}
|
||||||
|
configFileContent = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return default URL when nothing is configured", () => {
|
||||||
|
const url = getMalwareListBaseUrl();
|
||||||
|
|
||||||
|
assert.strictEqual(url, "https://malware-list.aikido.dev");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should trim trailing slash from CLI argument", () => {
|
||||||
|
initializeCliArguments(["--safe-chain-malware-list-base-url=https://cli-mirror.com/"]);
|
||||||
|
|
||||||
|
const url = getMalwareListBaseUrl();
|
||||||
|
|
||||||
|
assert.strictEqual(url, "https://cli-mirror.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should trim trailing slash from environment variable", () => {
|
||||||
|
process.env[envVarName] = "https://env-mirror.com/";
|
||||||
|
|
||||||
|
const url = getMalwareListBaseUrl();
|
||||||
|
|
||||||
|
assert.strictEqual(url, "https://env-mirror.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should trim trailing slash from config file value", () => {
|
||||||
|
configFileContent = JSON.stringify({
|
||||||
|
malwareListBaseUrl: "https://config-mirror.com/",
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = getMalwareListBaseUrl();
|
||||||
|
|
||||||
|
assert.strictEqual(url, "https://config-mirror.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return CLI argument value with highest priority", () => {
|
||||||
|
initializeCliArguments(["--safe-chain-malware-list-base-url=https://cli-mirror.com"]);
|
||||||
|
|
||||||
|
const url = getMalwareListBaseUrl();
|
||||||
|
|
||||||
|
assert.strictEqual(url, "https://cli-mirror.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return environment variable value when no CLI argument", () => {
|
||||||
|
process.env[envVarName] = "https://env-mirror.com";
|
||||||
|
|
||||||
|
const url = getMalwareListBaseUrl();
|
||||||
|
|
||||||
|
assert.strictEqual(url, "https://env-mirror.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return config file value when no CLI or env", () => {
|
||||||
|
configFileContent = JSON.stringify({
|
||||||
|
malwareListBaseUrl: "https://config-mirror.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = getMalwareListBaseUrl();
|
||||||
|
|
||||||
|
assert.strictEqual(url, "https://config-mirror.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prioritize CLI over environment variable", () => {
|
||||||
|
process.env[envVarName] = "https://env-mirror.com";
|
||||||
|
initializeCliArguments(["--safe-chain-malware-list-base-url=https://cli-mirror.com"]);
|
||||||
|
|
||||||
|
const url = getMalwareListBaseUrl();
|
||||||
|
|
||||||
|
assert.strictEqual(url, "https://cli-mirror.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prioritize environment variable over config file", () => {
|
||||||
|
process.env[envVarName] = "https://env-mirror.com";
|
||||||
|
configFileContent = JSON.stringify({
|
||||||
|
malwareListBaseUrl: "https://config-mirror.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = getMalwareListBaseUrl();
|
||||||
|
|
||||||
|
assert.strictEqual(url, "https://env-mirror.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prioritize CLI over config file", () => {
|
||||||
|
initializeCliArguments(["--safe-chain-malware-list-base-url=https://cli-mirror.com"]);
|
||||||
|
configFileContent = JSON.stringify({
|
||||||
|
malwareListBaseUrl: "https://config-mirror.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = getMalwareListBaseUrl();
|
||||||
|
|
||||||
|
assert.strictEqual(url, "https://cli-mirror.com");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -15,3 +15,66 @@ export function getHeaderValueAsString(headers, headerName) {
|
||||||
|
|
||||||
return header;
|
return header;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a copy of headers without the provided header names, matched
|
||||||
|
* either exactly or case-insensitively.
|
||||||
|
*
|
||||||
|
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
||||||
|
* @param {string[]} headerNames
|
||||||
|
* @param {{ caseInsensitive?: boolean }} [options]
|
||||||
|
* @returns {NodeJS.Dict<string | string[]> | undefined}
|
||||||
|
*/
|
||||||
|
export function omitHeaders(headers, headerNames, options = {}) {
|
||||||
|
if (!headers) {
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
const omittedHeaderNames = new Set(
|
||||||
|
options.caseInsensitive
|
||||||
|
? headerNames.map((name) => name.toLowerCase())
|
||||||
|
: headerNames
|
||||||
|
);
|
||||||
|
/** @type {NodeJS.Dict<string | string[]>} */
|
||||||
|
const filteredHeaders = {};
|
||||||
|
|
||||||
|
for (const [headerName, value] of Object.entries(headers)) {
|
||||||
|
const comparableHeaderName = options.caseInsensitive
|
||||||
|
? headerName.toLowerCase()
|
||||||
|
: headerName;
|
||||||
|
if (!omittedHeaderNames.has(comparableHeaderName)) {
|
||||||
|
filteredHeaders[headerName] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredHeaders;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove headers that become stale when the response body is modified.
|
||||||
|
*
|
||||||
|
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function clearCachingHeaders(headers) {
|
||||||
|
if (!headers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredHeaders = omitHeaders(headers, [
|
||||||
|
"etag",
|
||||||
|
"last-modified",
|
||||||
|
"cache-control",
|
||||||
|
"content-length",
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!filteredHeaders) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.keys(headers)) {
|
||||||
|
delete headers[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(headers, filteredHeaders);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
import { getMinimumPackageAgeHours } from "../../../config/settings.js";
|
import { getMinimumPackageAgeHours } from "../../../config/settings.js";
|
||||||
import { ui } from "../../../environment/userInteraction.js";
|
import { ui } from "../../../environment/userInteraction.js";
|
||||||
import { getHeaderValueAsString } from "../../http-utils.js";
|
import { clearCachingHeaders, getHeaderValueAsString } from "../../http-utils.js";
|
||||||
|
import { recordSuppressedVersion } from "../suppressedVersionsState.js";
|
||||||
const state = {
|
|
||||||
hasSuppressedVersions: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {NodeJS.Dict<string | string[]>} headers
|
* @param {NodeJS.Dict<string | string[]>} headers
|
||||||
|
|
@ -82,15 +79,7 @@ export function modifyNpmInfoResponse(body, headers) {
|
||||||
const timestampValue = new Date(timestamp);
|
const timestampValue = new Date(timestamp);
|
||||||
if (timestampValue > cutOff) {
|
if (timestampValue > cutOff) {
|
||||||
deleteVersionFromJson(bodyJson, version);
|
deleteVersionFromJson(bodyJson, version);
|
||||||
if (headers) {
|
clearCachingHeaders(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"];
|
|
||||||
// Removing the cache-control header will prevent the package manager from caching
|
|
||||||
// the modified response.
|
|
||||||
delete headers["cache-control"];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,7 +103,7 @@ export function modifyNpmInfoResponse(body, headers) {
|
||||||
* @param {string} version
|
* @param {string} version
|
||||||
*/
|
*/
|
||||||
function deleteVersionFromJson(json, version) {
|
function deleteVersionFromJson(json, version) {
|
||||||
state.hasSuppressedVersions = true;
|
recordSuppressedVersion();
|
||||||
|
|
||||||
const packageName = typeof json?.name === "string" ? json.name : "(unknown)";
|
const packageName = typeof json?.name === "string" ? json.name : "(unknown)";
|
||||||
|
|
||||||
|
|
@ -171,13 +160,6 @@ function getMostRecentTag(tagList) {
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
export function getHasSuppressedVersions() {
|
|
||||||
return state.hasSuppressedVersions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Buffer} body
|
* @param {Buffer} body
|
||||||
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
import { ui } from "../../../environment/userInteraction.js";
|
||||||
|
import { clearCachingHeaders } from "../../http-utils.js";
|
||||||
|
import { normalizePipPackageName } from "../../../scanning/packageNameVariants.js";
|
||||||
|
import { parsePipPackageFromUrl } from "./parsePipPackageUrl.js";
|
||||||
|
export { parsePipMetadataUrl, isPipPackageInfoUrl } from "./parsePipPackageUrl.js";
|
||||||
|
import { getPipMetadataContentType, logSuppressedVersion } from "./pipMetadataResponseUtils.js";
|
||||||
|
import { modifyPipJsonResponse } from "./modifyPipJsonResponse.js";
|
||||||
|
|
||||||
|
// Match simple-index anchor tags and capture their href so we can suppress
|
||||||
|
// individual distribution links from PyPI HTML metadata responses.
|
||||||
|
const HTML_ANCHOR_HREF_RE =
|
||||||
|
/<a\b[^>]*href\s*=\s*(["'])([^"']+)\1[^>]*>[\s\S]*?<\/a>/gi;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Buffer} body
|
||||||
|
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
||||||
|
* @param {string} metadataUrl
|
||||||
|
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
||||||
|
* @param {string} packageName
|
||||||
|
* @returns {Buffer}
|
||||||
|
*/
|
||||||
|
export function modifyPipInfoResponse(
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
metadataUrl,
|
||||||
|
isNewlyReleasedPackage,
|
||||||
|
packageName
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const contentType = getPipMetadataContentType(headers);
|
||||||
|
|
||||||
|
if (!contentType || body.byteLength === 0) {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
contentType.includes("html") ||
|
||||||
|
contentType.includes("application/vnd.pypi.simple.v1+html")
|
||||||
|
) {
|
||||||
|
return modifyHtmlSimpleResponse(
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
metadataUrl,
|
||||||
|
isNewlyReleasedPackage,
|
||||||
|
packageName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
contentType.includes("json") ||
|
||||||
|
contentType.includes("application/vnd.pypi.simple.v1+json")
|
||||||
|
) {
|
||||||
|
return modifyJsonResponse(
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
metadataUrl,
|
||||||
|
isNewlyReleasedPackage,
|
||||||
|
packageName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
} catch (/** @type {any} */ err) {
|
||||||
|
ui.writeVerbose(
|
||||||
|
`Safe-chain: PyPI package metadata not in expected format - bypassing modification. Error: ${err.message}`
|
||||||
|
);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Buffer} body
|
||||||
|
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
||||||
|
* @param {string} metadataUrl
|
||||||
|
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
||||||
|
* @param {string} packageName
|
||||||
|
* @returns {Buffer}
|
||||||
|
*/
|
||||||
|
function modifyHtmlSimpleResponse(
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
metadataUrl,
|
||||||
|
isNewlyReleasedPackage,
|
||||||
|
packageName
|
||||||
|
) {
|
||||||
|
const html = body.toString("utf8");
|
||||||
|
let modified = false;
|
||||||
|
const rewriteHtmlAnchor = createHtmlAnchorRewriter(
|
||||||
|
metadataUrl,
|
||||||
|
isNewlyReleasedPackage,
|
||||||
|
packageName,
|
||||||
|
() => {
|
||||||
|
modified = true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const updatedHtml = html.replace(HTML_ANCHOR_HREF_RE, rewriteHtmlAnchor);
|
||||||
|
|
||||||
|
if (!modified) return body;
|
||||||
|
const modifiedBuffer = Buffer.from(updatedHtml);
|
||||||
|
clearCachingHeaders(headers);
|
||||||
|
return modifiedBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} metadataUrl
|
||||||
|
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
||||||
|
* @param {string} packageName
|
||||||
|
* @param {() => void} onModified
|
||||||
|
* @returns {(anchor: string, quote: string, href: string) => string}
|
||||||
|
*/
|
||||||
|
function createHtmlAnchorRewriter(
|
||||||
|
metadataUrl,
|
||||||
|
isNewlyReleasedPackage,
|
||||||
|
packageName,
|
||||||
|
onModified
|
||||||
|
) {
|
||||||
|
return (anchor, _quote, href) => {
|
||||||
|
const resolvedHref = new URL(href, metadataUrl).toString();
|
||||||
|
const { packageName: hrefPackageName, version } = parsePipPackageFromUrl(
|
||||||
|
resolvedHref,
|
||||||
|
new URL(resolvedHref).host
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
hrefPackageName &&
|
||||||
|
normalizePipPackageName(hrefPackageName) ===
|
||||||
|
normalizePipPackageName(packageName) &&
|
||||||
|
version &&
|
||||||
|
isNewlyReleasedPackage(packageName, version)
|
||||||
|
) {
|
||||||
|
onModified();
|
||||||
|
logSuppressedVersion(packageName, version);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return anchor;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Buffer} body
|
||||||
|
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
||||||
|
* @param {string} metadataUrl
|
||||||
|
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
||||||
|
* @param {string} packageName
|
||||||
|
* @returns {Buffer}
|
||||||
|
*/
|
||||||
|
function modifyJsonResponse(
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
metadataUrl,
|
||||||
|
isNewlyReleasedPackage,
|
||||||
|
packageName
|
||||||
|
) {
|
||||||
|
const json = JSON.parse(body.toString("utf8"));
|
||||||
|
const modified = modifyPipJsonResponse(
|
||||||
|
json,
|
||||||
|
metadataUrl,
|
||||||
|
isNewlyReleasedPackage,
|
||||||
|
packageName
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!modified) return body;
|
||||||
|
const modifiedBuffer = Buffer.from(JSON.stringify(json));
|
||||||
|
clearCachingHeaders(headers);
|
||||||
|
return modifiedBuffer;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,302 @@
|
||||||
|
import { describe, it, mock } from "node:test";
|
||||||
|
import assert from "node:assert";
|
||||||
|
|
||||||
|
describe("modifyPipInfo", async () => {
|
||||||
|
mock.module("../../../config/settings.js", {
|
||||||
|
namedExports: {
|
||||||
|
getMinimumPackageAgeHours: () => 48,
|
||||||
|
ECOSYSTEM_PY: "py",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("../../../environment/userInteraction.js", {
|
||||||
|
namedExports: {
|
||||||
|
ui: {
|
||||||
|
writeVerbose: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
modifyPipInfoResponse,
|
||||||
|
} = await import("./modifyPipInfo.js");
|
||||||
|
|
||||||
|
it("removes too-young files from simple HTML metadata", () => {
|
||||||
|
const headers = {
|
||||||
|
"content-type": "application/vnd.pypi.simple.v1+html",
|
||||||
|
etag: "abc",
|
||||||
|
"cache-control": "public",
|
||||||
|
"content-length": "999",
|
||||||
|
"transfer-encoding": "chunked",
|
||||||
|
};
|
||||||
|
|
||||||
|
const body = Buffer.from(`
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<a href="https://files.pythonhosted.org/packages/source/r/requests/requests-1.0.0.tar.gz">requests-1.0.0.tar.gz</a>
|
||||||
|
<a href="https://files.pythonhosted.org/packages/source/r/requests/requests-2.0.0.tar.gz">requests-2.0.0.tar.gz</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const modified = modifyPipInfoResponse(
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
"https://pypi.org/simple/requests/",
|
||||||
|
(_packageName, version) => version === "2.0.0",
|
||||||
|
"requests"
|
||||||
|
).toString("utf8");
|
||||||
|
|
||||||
|
assert.ok(modified.includes("requests-1.0.0.tar.gz"));
|
||||||
|
assert.ok(!modified.includes("requests-2.0.0.tar.gz"));
|
||||||
|
assert.equal(headers.etag, undefined);
|
||||||
|
assert.equal(headers["cache-control"], undefined);
|
||||||
|
assert.equal(headers["content-length"], undefined);
|
||||||
|
assert.equal(headers["transfer-encoding"], "chunked");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves mixed-case transport headers untouched for MITM layer to normalize", () => {
|
||||||
|
const headers = {
|
||||||
|
"content-type": "application/json",
|
||||||
|
ETag: "abc",
|
||||||
|
"Content-Length": "999",
|
||||||
|
"Last-Modified": "yesterday",
|
||||||
|
"Cache-Control": "public, max-age=60",
|
||||||
|
"Transfer-Encoding": "chunked",
|
||||||
|
};
|
||||||
|
|
||||||
|
const body = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
info: { version: "2.0.0" },
|
||||||
|
releases: {
|
||||||
|
"1.0.0": [{ filename: "requests-1.0.0.tar.gz" }],
|
||||||
|
"2.0.0": [{ filename: "requests-2.0.0.tar.gz" }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
modifyPipInfoResponse(
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
"https://pypi.org/pypi/requests/json",
|
||||||
|
(_packageName, version) => version === "2.0.0",
|
||||||
|
"requests"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(headers.ETag, "abc");
|
||||||
|
assert.equal(headers["Last-Modified"], "yesterday");
|
||||||
|
assert.equal(headers["Cache-Control"], "public, max-age=60");
|
||||||
|
assert.equal(headers["Transfer-Encoding"], "chunked");
|
||||||
|
assert.equal(headers["Content-Length"], "999");
|
||||||
|
assert.equal(headers["content-length"], undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns body unchanged when no HTML versions are suppressed", () => {
|
||||||
|
const headers = {
|
||||||
|
"content-type": "application/vnd.pypi.simple.v1+html",
|
||||||
|
etag: "abc",
|
||||||
|
};
|
||||||
|
|
||||||
|
const body = Buffer.from(
|
||||||
|
`<a href="https://files.pythonhosted.org/packages/source/r/requests/requests-1.0.0.tar.gz">requests-1.0.0.tar.gz</a>`
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = modifyPipInfoResponse(
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
"https://pypi.org/simple/requests/",
|
||||||
|
() => false,
|
||||||
|
"requests"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result, body); // same Buffer reference — no copy made
|
||||||
|
assert.equal(headers.etag, "abc"); // headers untouched
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches HTML anchor hrefs using normalised package name (underscore vs hyphen)", () => {
|
||||||
|
const headers = { "content-type": "application/vnd.pypi.simple.v1+html" };
|
||||||
|
|
||||||
|
const body = Buffer.from(
|
||||||
|
`<a href="https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0.tar.gz">foo_bar-2.0.0.tar.gz</a>` +
|
||||||
|
`<a href="https://files.pythonhosted.org/packages/xx/yy/foo_bar-1.0.0.tar.gz">foo_bar-1.0.0.tar.gz</a>`
|
||||||
|
);
|
||||||
|
|
||||||
|
const modified = modifyPipInfoResponse(
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
"https://pypi.org/simple/foo-bar/",
|
||||||
|
(_packageName, version) => version === "2.0.0",
|
||||||
|
"foo-bar" // hyphenated name, hrefs use underscore
|
||||||
|
).toString("utf8");
|
||||||
|
|
||||||
|
assert.ok(!modified.includes("foo_bar-2.0.0.tar.gz"));
|
||||||
|
assert.ok(modified.includes("foo_bar-1.0.0.tar.gz"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches anchor href regex with single quotes and extra attributes", () => {
|
||||||
|
const headers = { "content-type": "application/vnd.pypi.simple.v1+html" };
|
||||||
|
|
||||||
|
const body = Buffer.from(`
|
||||||
|
<a
|
||||||
|
data-requires-python=">=3.9"
|
||||||
|
class="pkg"
|
||||||
|
href='https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0.tar.gz'
|
||||||
|
>
|
||||||
|
foo_bar-2.0.0.tar.gz
|
||||||
|
</a>
|
||||||
|
<a href="https://files.pythonhosted.org/packages/xx/yy/foo_bar-1.0.0.tar.gz">foo_bar-1.0.0.tar.gz</a>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const modified = modifyPipInfoResponse(
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
"https://pypi.org/simple/foo-bar/",
|
||||||
|
(_packageName, version) => version === "2.0.0",
|
||||||
|
"foo-bar"
|
||||||
|
).toString("utf8");
|
||||||
|
|
||||||
|
assert.ok(!modified.includes("foo_bar-2.0.0.tar.gz"));
|
||||||
|
assert.ok(modified.includes("foo_bar-1.0.0.tar.gz"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes too-young files from simple JSON metadata", () => {
|
||||||
|
const headers = {
|
||||||
|
"content-type": "application/vnd.pypi.simple.v1+json",
|
||||||
|
};
|
||||||
|
|
||||||
|
const body = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
name: "requests",
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
filename: "requests-1.0.0.tar.gz",
|
||||||
|
url: "https://files.pythonhosted.org/packages/source/r/requests/requests-1.0.0.tar.gz",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filename: "requests-2.0.0.tar.gz",
|
||||||
|
url: "https://files.pythonhosted.org/packages/source/r/requests/requests-2.0.0.tar.gz",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const modified = JSON.parse(
|
||||||
|
modifyPipInfoResponse(
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
"https://pypi.org/simple/requests/",
|
||||||
|
(_packageName, version) => version === "2.0.0",
|
||||||
|
"requests"
|
||||||
|
).toString("utf8")
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(modified.files.length, 1);
|
||||||
|
assert.equal(modified.files[0].filename, "requests-1.0.0.tar.gz");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters simple JSON metadata entries that have only filename (no url)", () => {
|
||||||
|
const headers = { "content-type": "application/vnd.pypi.simple.v1+json" };
|
||||||
|
|
||||||
|
const body = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
name: "requests",
|
||||||
|
files: [
|
||||||
|
{ filename: "requests-1.0.0.tar.gz" },
|
||||||
|
{ filename: "requests-2.0.0.tar.gz" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const modified = JSON.parse(
|
||||||
|
modifyPipInfoResponse(
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
"https://pypi.org/simple/requests/",
|
||||||
|
(_packageName, version) => version === "2.0.0",
|
||||||
|
"requests"
|
||||||
|
).toString("utf8")
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(modified.files.length, 1);
|
||||||
|
assert.equal(modified.files[0].filename, "requests-1.0.0.tar.gz");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recalculates JSON API info.version after removing too-young releases", () => {
|
||||||
|
const headers = {
|
||||||
|
"content-type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
const body = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
info: { version: "2.0.0" },
|
||||||
|
releases: {
|
||||||
|
"1.0.0": [
|
||||||
|
{
|
||||||
|
filename: "requests-1.0.0.tar.gz",
|
||||||
|
upload_time_iso_8601: "2024-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"2.0.0": [
|
||||||
|
{
|
||||||
|
filename: "requests-2.0.0.tar.gz",
|
||||||
|
upload_time_iso_8601: "2024-01-02T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"3.0.0rc1": [
|
||||||
|
{
|
||||||
|
filename: "requests-3.0.0rc1.tar.gz",
|
||||||
|
upload_time_iso_8601: "2024-01-03T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
urls: [
|
||||||
|
{ filename: "requests-2.0.0.tar.gz" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const modified = JSON.parse(
|
||||||
|
modifyPipInfoResponse(
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
"https://pypi.org/pypi/requests/json",
|
||||||
|
(_packageName, version) =>
|
||||||
|
version === "2.0.0" || version === "3.0.0rc1",
|
||||||
|
"requests"
|
||||||
|
).toString("utf8")
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(Object.keys(modified.releases), ["1.0.0"]);
|
||||||
|
assert.equal(modified.info.version, "1.0.0");
|
||||||
|
assert.equal(modified.urls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to latest pre-release when all stable versions are removed", () => {
|
||||||
|
const headers = { "content-type": "application/json" };
|
||||||
|
|
||||||
|
const body = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
info: { version: "2.0.0rc2" },
|
||||||
|
releases: {
|
||||||
|
"1.0.0rc1": [{ filename: "requests-1.0.0rc1.tar.gz" }],
|
||||||
|
"2.0.0rc2": [{ filename: "requests-2.0.0rc2.tar.gz" }],
|
||||||
|
},
|
||||||
|
urls: [],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const modified = JSON.parse(
|
||||||
|
modifyPipInfoResponse(
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
"https://pypi.org/pypi/requests/json",
|
||||||
|
(_packageName, version) => version === "2.0.0rc2",
|
||||||
|
"requests"
|
||||||
|
).toString("utf8")
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(Object.keys(modified.releases), ["1.0.0rc1"]);
|
||||||
|
assert.equal(modified.info.version, "1.0.0rc1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
import {
|
||||||
|
calculateLatestVersion,
|
||||||
|
getAvailableVersionsFromJson,
|
||||||
|
getPackageVersionFromMetadataFile,
|
||||||
|
} from "./pipMetadataVersionUtils.js";
|
||||||
|
import { logSuppressedVersion } from "./pipMetadataResponseUtils.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} json
|
||||||
|
* @param {string} metadataUrl
|
||||||
|
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
||||||
|
* @param {string} packageName
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function modifyPipJsonResponse(
|
||||||
|
json,
|
||||||
|
metadataUrl,
|
||||||
|
isNewlyReleasedPackage,
|
||||||
|
packageName
|
||||||
|
) {
|
||||||
|
const filesModified = filterJsonMetadataFiles(
|
||||||
|
json,
|
||||||
|
metadataUrl,
|
||||||
|
isNewlyReleasedPackage,
|
||||||
|
packageName
|
||||||
|
);
|
||||||
|
const releasesModified = removeJsonMetadataReleases(
|
||||||
|
json,
|
||||||
|
isNewlyReleasedPackage,
|
||||||
|
packageName
|
||||||
|
);
|
||||||
|
const urlsModified = filterJsonMetadataUrls(
|
||||||
|
json,
|
||||||
|
metadataUrl,
|
||||||
|
isNewlyReleasedPackage,
|
||||||
|
packageName
|
||||||
|
);
|
||||||
|
const versionModified = updateJsonInfoVersion(json, metadataUrl);
|
||||||
|
|
||||||
|
return filesModified || releasesModified || urlsModified || versionModified;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} json
|
||||||
|
* @param {string} metadataUrl
|
||||||
|
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
||||||
|
* @param {string} packageName
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function filterJsonMetadataFiles(
|
||||||
|
json,
|
||||||
|
metadataUrl,
|
||||||
|
isNewlyReleasedPackage,
|
||||||
|
packageName
|
||||||
|
) {
|
||||||
|
if (!Array.isArray(json.files)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let modified = false;
|
||||||
|
const loggedVersions = new Set();
|
||||||
|
json.files = json.files.filter((/** @type {any} */ file) => {
|
||||||
|
const version = getPackageVersionFromMetadataFile(file, metadataUrl);
|
||||||
|
|
||||||
|
if (version && isNewlyReleasedPackage(packageName, version)) {
|
||||||
|
modified = true;
|
||||||
|
if (!loggedVersions.has(version)) {
|
||||||
|
logSuppressedVersion(packageName, version);
|
||||||
|
loggedVersions.add(version);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} json
|
||||||
|
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
||||||
|
* @param {string} packageName
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function removeJsonMetadataReleases(json, isNewlyReleasedPackage, packageName) {
|
||||||
|
if (!json.releases || typeof json.releases !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let modified = false;
|
||||||
|
|
||||||
|
for (const [version, files] of Object.entries(json.releases)) {
|
||||||
|
if (
|
||||||
|
Array.isArray(/** @type {unknown[]} */ (files)) &&
|
||||||
|
isNewlyReleasedPackage(packageName, version)
|
||||||
|
) {
|
||||||
|
delete json.releases[version];
|
||||||
|
modified = true;
|
||||||
|
logSuppressedVersion(packageName, version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} json
|
||||||
|
* @param {string} metadataUrl
|
||||||
|
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
||||||
|
* @param {string} packageName
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function filterJsonMetadataUrls(
|
||||||
|
json,
|
||||||
|
metadataUrl,
|
||||||
|
isNewlyReleasedPackage,
|
||||||
|
packageName
|
||||||
|
) {
|
||||||
|
if (!Array.isArray(json.urls)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let modified = false;
|
||||||
|
const loggedVersions = new Set();
|
||||||
|
json.urls = json.urls.filter((/** @type {any} */ file) => {
|
||||||
|
const version = getPackageVersionFromMetadataFile(file, metadataUrl);
|
||||||
|
|
||||||
|
if (version && isNewlyReleasedPackage(packageName, version)) {
|
||||||
|
modified = true;
|
||||||
|
if (!loggedVersions.has(version)) {
|
||||||
|
logSuppressedVersion(packageName, version);
|
||||||
|
loggedVersions.add(version);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} json
|
||||||
|
* @param {string} metadataUrl
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function updateJsonInfoVersion(json, metadataUrl) {
|
||||||
|
if (!json.info || typeof json.info !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const replacementVersion = computeReplacementVersion(json, metadataUrl);
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof json.info.version !== "string" ||
|
||||||
|
!replacementVersion ||
|
||||||
|
json.info.version === replacementVersion
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
json.info.version = replacementVersion;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} json
|
||||||
|
* @param {string} metadataUrl
|
||||||
|
* @returns {string | undefined}
|
||||||
|
*/
|
||||||
|
function computeReplacementVersion(json, metadataUrl) {
|
||||||
|
const candidateVersions = getAvailableVersionsFromJson(json, metadataUrl);
|
||||||
|
return calculateLatestVersion(candidateVersions);
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,64 @@
|
||||||
|
/**
|
||||||
|
* Parses a PyPI metadata URL and returns the package name and API type.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* parsePipMetadataUrl("https://pypi.org/simple/requests/")
|
||||||
|
* // => { packageName: "requests", type: "simple" }
|
||||||
|
*
|
||||||
|
* parsePipMetadataUrl("https://pypi.org/pypi/requests/json")
|
||||||
|
* // => { packageName: "requests", type: "json" }
|
||||||
|
*
|
||||||
|
* parsePipMetadataUrl("https://pypi.org/pypi/requests/2.28.1/json")
|
||||||
|
* // => { packageName: "requests", type: "json" }
|
||||||
|
*
|
||||||
|
* parsePipMetadataUrl("https://files.pythonhosted.org/packages/requests-2.28.1.tar.gz")
|
||||||
|
* // => { packageName: undefined, type: undefined }
|
||||||
|
*
|
||||||
|
* @param {string} url
|
||||||
|
* @returns {{ packageName: string | undefined, type: "simple" | "json" | undefined }}
|
||||||
|
*/
|
||||||
|
export function parsePipMetadataUrl(url) {
|
||||||
|
if (typeof url !== "string") {
|
||||||
|
return { packageName: undefined, type: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
let urlObj;
|
||||||
|
try {
|
||||||
|
urlObj = new URL(url);
|
||||||
|
} catch {
|
||||||
|
return { packageName: undefined, type: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathSegments = urlObj.pathname.split("/").filter(Boolean);
|
||||||
|
if (pathSegments[0] === "simple" && pathSegments[1]) {
|
||||||
|
return {
|
||||||
|
packageName: decodeURIComponent(pathSegments[1]),
|
||||||
|
type: "simple",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
pathSegments[0] === "pypi" &&
|
||||||
|
pathSegments[pathSegments.length - 1] === "json" &&
|
||||||
|
pathSegments[1]
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
packageName: decodeURIComponent(pathSegments[1]),
|
||||||
|
type: "json",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { packageName: undefined, type: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} url
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isPipPackageInfoUrl(url) {
|
||||||
|
return !!parsePipMetadataUrl(url).packageName;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse Python package artifact URLs from PyPI-style registries.
|
* Parse Python package artifact URLs from PyPI-style registries.
|
||||||
* Examples:
|
* Examples:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import assert from "node:assert";
|
||||||
|
import {
|
||||||
|
isPipPackageInfoUrl,
|
||||||
|
parsePipMetadataUrl,
|
||||||
|
parsePipPackageFromUrl,
|
||||||
|
} from "./parsePipPackageUrl.js";
|
||||||
|
|
||||||
|
describe("parsePipPackageUrl", () => {
|
||||||
|
it("parses simple metadata URLs", () => {
|
||||||
|
assert.deepEqual(parsePipMetadataUrl("https://pypi.org/simple/requests/"), {
|
||||||
|
packageName: "requests",
|
||||||
|
type: "simple",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses json metadata URLs", () => {
|
||||||
|
assert.deepEqual(parsePipMetadataUrl("https://pypi.org/pypi/requests/json"), {
|
||||||
|
packageName: "requests",
|
||||||
|
type: "json",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses per-version json metadata URLs", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
parsePipMetadataUrl("https://pypi.org/pypi/requests/2.28.1/json"),
|
||||||
|
{ packageName: "requests", type: "json" }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes encoded metadata package names", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
parsePipMetadataUrl("https://pypi.org/simple/foo-bar%5Fbaz/"),
|
||||||
|
{
|
||||||
|
packageName: "foo-bar_baz",
|
||||||
|
type: "simple",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for unrecognized metadata paths", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
parsePipMetadataUrl("https://pypi.org/unknown/requests/"),
|
||||||
|
{
|
||||||
|
packageName: undefined,
|
||||||
|
type: undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for invalid metadata URLs", () => {
|
||||||
|
assert.deepEqual(parsePipMetadataUrl("not a url"), {
|
||||||
|
packageName: undefined,
|
||||||
|
type: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes package info URLs", () => {
|
||||||
|
assert.equal(
|
||||||
|
isPipPackageInfoUrl("https://pypi.org/simple/requests/"),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat artifact URLs as package info URLs", () => {
|
||||||
|
assert.equal(
|
||||||
|
isPipPackageInfoUrl(
|
||||||
|
"https://files.pythonhosted.org/packages/source/r/requests/requests-2.28.1.tar.gz"
|
||||||
|
),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses wheel artifact URLs", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
parsePipPackageFromUrl(
|
||||||
|
"https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl",
|
||||||
|
"files.pythonhosted.org"
|
||||||
|
),
|
||||||
|
{ packageName: "foo_bar", version: "2.0.0" }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses sdist artifact URLs", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
parsePipPackageFromUrl(
|
||||||
|
"https://files.pythonhosted.org/packages/source/r/requests/requests-2.28.1.tar.gz",
|
||||||
|
"files.pythonhosted.org"
|
||||||
|
),
|
||||||
|
{ packageName: "requests", version: "2.28.1" }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for non-artifact URLs", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
parsePipPackageFromUrl("https://pypi.org/simple/requests/", "pypi.org"),
|
||||||
|
{ packageName: undefined, version: undefined }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -10,8 +10,12 @@ describe("pipInterceptor custom registries", async () => {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
ECOSYSTEM_PY: "py",
|
ECOSYSTEM_PY: "py",
|
||||||
getEcoSystem: () => "py",
|
getEcoSystem: () => "py",
|
||||||
|
getLoggingLevel: () => "silent",
|
||||||
|
getMinimumPackageAgeHours: () => 48,
|
||||||
getMinimumPackageAgeExclusions: () => [],
|
getMinimumPackageAgeExclusions: () => [],
|
||||||
getPipCustomRegistries: () => customRegistries,
|
getPipCustomRegistries: () => customRegistries,
|
||||||
|
LOGGING_SILENT: "silent",
|
||||||
|
LOGGING_VERBOSE: "verbose",
|
||||||
skipMinimumPackageAge: () => false,
|
skipMinimumPackageAge: () => false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ import { getEquivalentPackageNames } from "../../../scanning/packageNameVariants
|
||||||
import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache.js";
|
import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache.js";
|
||||||
import { interceptRequests } from "../interceptorBuilder.js";
|
import { interceptRequests } from "../interceptorBuilder.js";
|
||||||
import { isExcludedFromMinimumPackageAge } from "../minimumPackageAgeExclusions.js";
|
import { isExcludedFromMinimumPackageAge } from "../minimumPackageAgeExclusions.js";
|
||||||
|
import {
|
||||||
|
modifyPipInfoResponse,
|
||||||
|
parsePipMetadataUrl,
|
||||||
|
} from "./modifyPipInfo.js";
|
||||||
import { parsePipPackageFromUrl } from "./parsePipPackageUrl.js";
|
import { parsePipPackageFromUrl } from "./parsePipPackageUrl.js";
|
||||||
|
|
||||||
const knownPipRegistries = [
|
const knownPipRegistries = [
|
||||||
|
|
@ -47,6 +51,28 @@ function buildPipInterceptor(registry) {
|
||||||
*/
|
*/
|
||||||
function createPipRequestHandler(registry) {
|
function createPipRequestHandler(registry) {
|
||||||
return async (reqContext) => {
|
return async (reqContext) => {
|
||||||
|
const minimumAgeChecksEnabled = !skipMinimumPackageAge();
|
||||||
|
const metadataInfo = parsePipMetadataUrl(reqContext.targetUrl);
|
||||||
|
const metadataPackageName = metadataInfo.packageName;
|
||||||
|
|
||||||
|
if (
|
||||||
|
minimumAgeChecksEnabled &&
|
||||||
|
metadataPackageName &&
|
||||||
|
!isExcludedFromMinimumPackageAge(metadataPackageName)
|
||||||
|
) {
|
||||||
|
const newPackagesDatabase = await openNewPackagesDatabase();
|
||||||
|
reqContext.modifyBody((body, headers) =>
|
||||||
|
modifyPipInfoResponse(
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
reqContext.targetUrl,
|
||||||
|
newPackagesDatabase.isNewlyReleasedPackage,
|
||||||
|
metadataPackageName
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { packageName, version } = parsePipPackageFromUrl(
|
const { packageName, version } = parsePipPackageFromUrl(
|
||||||
reqContext.targetUrl,
|
reqContext.targetUrl,
|
||||||
registry
|
registry
|
||||||
|
|
@ -75,7 +101,7 @@ function createPipRequestHandler(registry) {
|
||||||
|
|
||||||
if (
|
if (
|
||||||
version &&
|
version &&
|
||||||
!skipMinimumPackageAge() &&
|
minimumAgeChecksEnabled &&
|
||||||
!isExcludedFromMinimumPackageAge(packageName)
|
!isExcludedFromMinimumPackageAge(packageName)
|
||||||
) {
|
) {
|
||||||
const newPackagesDatabase = await openNewPackagesDatabase();
|
const newPackagesDatabase = await openNewPackagesDatabase();
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,12 @@ describe("pipInterceptor minimum package age", async () => {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
ECOSYSTEM_PY: "py",
|
ECOSYSTEM_PY: "py",
|
||||||
getEcoSystem: () => "py",
|
getEcoSystem: () => "py",
|
||||||
|
getLoggingLevel: () => "silent",
|
||||||
|
getMinimumPackageAgeHours: () => 48,
|
||||||
getMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting,
|
getMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting,
|
||||||
getPipCustomRegistries: () => [],
|
getPipCustomRegistries: () => [],
|
||||||
|
LOGGING_SILENT: "silent",
|
||||||
|
LOGGING_VERBOSE: "verbose",
|
||||||
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
|
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -56,6 +60,31 @@ describe("pipInterceptor minimum package age", async () => {
|
||||||
newlyReleasedPackageResponse = false;
|
newlyReleasedPackageResponse = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should modify simple metadata responses to suppress too-young versions", async () => {
|
||||||
|
const url = "https://pypi.org/simple/foo-bar/";
|
||||||
|
newlyReleasedPackageResponse = true;
|
||||||
|
|
||||||
|
const interceptor = pipInterceptorForUrl(url);
|
||||||
|
const result = await interceptor.handleRequest(url);
|
||||||
|
|
||||||
|
assert.equal(result.modifiesResponse(), true);
|
||||||
|
|
||||||
|
const modifiedBody = result.modifyBody(
|
||||||
|
Buffer.from(`
|
||||||
|
<a href="https://files.pythonhosted.org/packages/xx/yy/foo_bar-1.0.0.tar.gz">foo_bar-1.0.0.tar.gz</a>
|
||||||
|
<a href="https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0.tar.gz">foo_bar-2.0.0.tar.gz</a>
|
||||||
|
`),
|
||||||
|
{
|
||||||
|
"content-type": "application/vnd.pypi.simple.v1+html",
|
||||||
|
}
|
||||||
|
).toString("utf8");
|
||||||
|
|
||||||
|
assert.ok(modifiedBody.includes("foo_bar-1.0.0.tar.gz"));
|
||||||
|
assert.ok(!modifiedBody.includes("foo_bar-2.0.0.tar.gz"));
|
||||||
|
|
||||||
|
newlyReleasedPackageResponse = false;
|
||||||
|
});
|
||||||
|
|
||||||
it("should not block newly released package downloads when skipMinimumPackageAge is enabled", async () => {
|
it("should not block newly released package downloads when skipMinimumPackageAge is enabled", async () => {
|
||||||
const url =
|
const url =
|
||||||
"https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl";
|
"https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl";
|
||||||
|
|
@ -86,6 +115,20 @@ describe("pipInterceptor minimum package age", async () => {
|
||||||
newlyReleasedPackageResponse = false;
|
newlyReleasedPackageResponse = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should not modify metadata responses when the package is excluded", async () => {
|
||||||
|
const url = "https://pypi.org/simple/foo-bar/";
|
||||||
|
newlyReleasedPackageResponse = true;
|
||||||
|
minimumPackageAgeExclusionsSetting = ["foo-bar"];
|
||||||
|
|
||||||
|
const interceptor = pipInterceptorForUrl(url);
|
||||||
|
const result = await interceptor.handleRequest(url);
|
||||||
|
|
||||||
|
assert.equal(result.modifiesResponse(), false);
|
||||||
|
|
||||||
|
minimumPackageAgeExclusionsSetting = [];
|
||||||
|
newlyReleasedPackageResponse = false;
|
||||||
|
});
|
||||||
|
|
||||||
it("should not block newly released package downloads when a dot-name package matches a hyphen exclusion", async () => {
|
it("should not block newly released package downloads when a dot-name package matches a hyphen exclusion", async () => {
|
||||||
const url =
|
const url =
|
||||||
"https://files.pythonhosted.org/packages/xx/yy/foo.bar-2.0.0.tar.gz";
|
"https://files.pythonhosted.org/packages/xx/yy/foo.bar-2.0.0.tar.gz";
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,12 @@ describe("pipInterceptor", async () => {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
ECOSYSTEM_PY: "py",
|
ECOSYSTEM_PY: "py",
|
||||||
getEcoSystem: () => "py",
|
getEcoSystem: () => "py",
|
||||||
|
getLoggingLevel: () => "silent",
|
||||||
|
getMinimumPackageAgeHours: () => 48,
|
||||||
getMinimumPackageAgeExclusions: () => [],
|
getMinimumPackageAgeExclusions: () => [],
|
||||||
getPipCustomRegistries: () => [],
|
getPipCustomRegistries: () => [],
|
||||||
|
LOGGING_SILENT: "silent",
|
||||||
|
LOGGING_VERBOSE: "verbose",
|
||||||
skipMinimumPackageAge: () => false,
|
skipMinimumPackageAge: () => false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { getMinimumPackageAgeHours } from "../../../config/settings.js";
|
||||||
|
import { ui } from "../../../environment/userInteraction.js";
|
||||||
|
import { getHeaderValueAsString } from "../../http-utils.js";
|
||||||
|
import { recordSuppressedVersion } from "../suppressedVersionsState.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
||||||
|
* @returns {string | undefined}
|
||||||
|
*/
|
||||||
|
export function getPipMetadataContentType(headers) {
|
||||||
|
return getHeaderValueAsString(headers, "content-type")
|
||||||
|
?.toLowerCase()
|
||||||
|
.split(";")[0]
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} packageName
|
||||||
|
* @param {string} version
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function logSuppressedVersion(packageName, version) {
|
||||||
|
recordSuppressedVersion();
|
||||||
|
ui.writeVerbose(
|
||||||
|
`Safe-chain: ${packageName}@${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { parsePipPackageFromUrl } from "./parsePipPackageUrl.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} file
|
||||||
|
* @param {string} metadataUrl
|
||||||
|
* @returns {string | undefined}
|
||||||
|
*/
|
||||||
|
export function getPackageVersionFromMetadataFile(file, metadataUrl) {
|
||||||
|
const href = typeof file?.url === "string" ? file.url : undefined;
|
||||||
|
const filename = typeof file?.filename === "string" ? file.filename : undefined;
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
const resolvedHref = new URL(href, metadataUrl).toString();
|
||||||
|
return parsePipPackageFromUrl(
|
||||||
|
resolvedHref,
|
||||||
|
new URL(resolvedHref).host
|
||||||
|
).version;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filename) {
|
||||||
|
return parsePipPackageFromUrl(
|
||||||
|
new URL(filename, metadataUrl).toString(),
|
||||||
|
new URL(metadataUrl).host
|
||||||
|
).version;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} json
|
||||||
|
* @param {string} metadataUrl
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function getAvailableVersionsFromJson(json, metadataUrl) {
|
||||||
|
if (json.releases && typeof json.releases === "object") {
|
||||||
|
return Object.keys(json.releases);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(json.files)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
json.files
|
||||||
|
.map((/** @type {any} */ file) =>
|
||||||
|
getPackageVersionFromMetadataFile(file, metadataUrl)
|
||||||
|
)
|
||||||
|
.filter(isDefinedString)
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string | undefined} value
|
||||||
|
* @returns {value is string}
|
||||||
|
*/
|
||||||
|
function isDefinedString(value) {
|
||||||
|
return typeof value === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string[]} versions
|
||||||
|
* @returns {string | undefined}
|
||||||
|
*/
|
||||||
|
export function calculateLatestVersion(versions) {
|
||||||
|
const stableVersions = versions.filter((version) => !isPrerelease(version));
|
||||||
|
if (stableVersions.length > 0) {
|
||||||
|
return stableVersions.sort(comparePep440ishVersions).at(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return versions.sort(comparePep440ishVersions).at(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} left
|
||||||
|
* @param {string} right
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function comparePep440ishVersions(left, right) {
|
||||||
|
const leftParts = tokenizeVersion(left);
|
||||||
|
const rightParts = tokenizeVersion(right);
|
||||||
|
const maxLength = Math.max(leftParts.length, rightParts.length);
|
||||||
|
|
||||||
|
for (let index = 0; index < maxLength; index += 1) {
|
||||||
|
const leftPart = leftParts[index];
|
||||||
|
const rightPart = rightParts[index];
|
||||||
|
|
||||||
|
if (leftPart === undefined) return -1;
|
||||||
|
if (rightPart === undefined) return 1;
|
||||||
|
|
||||||
|
if (leftPart === rightPart) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftNumeric = typeof leftPart === "number";
|
||||||
|
const rightNumeric = typeof rightPart === "number";
|
||||||
|
|
||||||
|
if (leftNumeric && rightNumeric) {
|
||||||
|
return leftPart - rightPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leftNumeric) return 1;
|
||||||
|
if (rightNumeric) return -1;
|
||||||
|
|
||||||
|
return String(leftPart).localeCompare(String(rightPart));
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} version
|
||||||
|
* @returns {(string | number)[]}
|
||||||
|
*/
|
||||||
|
function tokenizeVersion(version) {
|
||||||
|
return version
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[^a-z0-9]+/)
|
||||||
|
.flatMap((part) => part.match(/[a-z]+|\d+/g) || [])
|
||||||
|
.map((part) => (/^\d+$/.test(part) ? Number(part) : part));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} version
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isPrerelease(version) {
|
||||||
|
return /(a|b|rc|dev)\d+/i.test(version);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
const state = {
|
||||||
|
hasSuppressedVersions: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks whether any rewritten metadata response suppressed versions during the
|
||||||
|
* current process lifetime. This is intentional shared state used only for the
|
||||||
|
* end-of-run summary message exposed through the proxy API.
|
||||||
|
*
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function recordSuppressedVersion() {
|
||||||
|
state.hasSuppressedVersions = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function getHasSuppressedVersions() {
|
||||||
|
return state.hasSuppressedVersions;
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,8 @@ 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";
|
import { gunzipSync } from "zlib";
|
||||||
|
import { omitHeaders } from "./http-utils.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {import("./interceptors/interceptorBuilder.js").Interceptor} Interceptor
|
* @typedef {import("./interceptors/interceptorBuilder.js").Interceptor} Interceptor
|
||||||
|
|
@ -215,11 +216,16 @@ function createProxyRequest(hostname, port, req, res, requestHandler) {
|
||||||
|
|
||||||
buffer = requestHandler.modifyBody(buffer, headers);
|
buffer = requestHandler.modifyBody(buffer, headers);
|
||||||
|
|
||||||
if (proxyRes.headers["content-encoding"] === "gzip") {
|
// For rewritten responses, send the final body uncompressed.
|
||||||
buffer = gzipSync(buffer);
|
// This avoids mismatches between upstream compression metadata and the
|
||||||
}
|
// rewritten payload on the wire.
|
||||||
|
const rewrittenHeaders = omitHeaders(
|
||||||
res.writeHead(statusCode, headers);
|
headers,
|
||||||
|
["content-length", "transfer-encoding", "content-encoding"],
|
||||||
|
{ caseInsensitive: true }
|
||||||
|
) || {};
|
||||||
|
rewrittenHeaders["content-length"] = String(buffer.byteLength);
|
||||||
|
res.writeHead(statusCode, rewrittenHeaders);
|
||||||
res.end(buffer);
|
res.end(buffer);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
138
packages/safe-chain/src/registryProxy/mitmRequestHandler.spec.js
Normal file
138
packages/safe-chain/src/registryProxy/mitmRequestHandler.spec.js
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { describe, it, mock } from "node:test";
|
||||||
|
import assert from "node:assert";
|
||||||
|
import zlib from "node:zlib";
|
||||||
|
|
||||||
|
describe("mitmRequestHandler", async () => {
|
||||||
|
let capturedHandler;
|
||||||
|
let capturedOptions;
|
||||||
|
|
||||||
|
mock.module("https", {
|
||||||
|
defaultExport: {
|
||||||
|
createServer: (_options, handler) => {
|
||||||
|
capturedHandler = handler;
|
||||||
|
return {
|
||||||
|
on: () => {},
|
||||||
|
emit: () => {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
request: (options, callback) => {
|
||||||
|
capturedOptions = options;
|
||||||
|
|
||||||
|
const listeners = {};
|
||||||
|
const proxyRes = {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: {
|
||||||
|
"content-encoding": "gzip",
|
||||||
|
"content-length": "999",
|
||||||
|
"transfer-encoding": "chunked",
|
||||||
|
},
|
||||||
|
on: (event, handler) => {
|
||||||
|
listeners[event] = handler;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
callback(proxyRes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
on: () => {},
|
||||||
|
write: () => {},
|
||||||
|
end: () => {
|
||||||
|
const payload = Buffer.from("rewritten body");
|
||||||
|
listeners["data"]?.(zlib.gzipSync(payload));
|
||||||
|
listeners["end"]?.();
|
||||||
|
},
|
||||||
|
destroy: () => {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("./certUtils.js", {
|
||||||
|
namedExports: {
|
||||||
|
generateCertForHost: () => ({
|
||||||
|
privateKey: "key",
|
||||||
|
certificate: "cert",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("https-proxy-agent", {
|
||||||
|
namedExports: {
|
||||||
|
HttpsProxyAgent: class {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("../environment/userInteraction.js", {
|
||||||
|
namedExports: {
|
||||||
|
ui: {
|
||||||
|
writeVerbose: () => {},
|
||||||
|
writeError: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mitmConnect } = await import("./mitmRequestHandler.js");
|
||||||
|
|
||||||
|
it("sets content-length from the final compressed payload after body rewrite", async () => {
|
||||||
|
const interceptor = {
|
||||||
|
handleRequest: async () => ({
|
||||||
|
blockResponse: undefined,
|
||||||
|
modifyRequestHeaders: (headers) => headers,
|
||||||
|
modifiesResponse: () => true,
|
||||||
|
modifyBody: () => Buffer.from("rewritten body"),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = {
|
||||||
|
url: "pypi.org:443",
|
||||||
|
};
|
||||||
|
|
||||||
|
const clientSocket = {
|
||||||
|
on: () => {},
|
||||||
|
write: () => {},
|
||||||
|
headersSent: false,
|
||||||
|
writable: true,
|
||||||
|
end: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
mitmConnect(req, clientSocket, interceptor);
|
||||||
|
|
||||||
|
const resState = {
|
||||||
|
statusCode: undefined,
|
||||||
|
headers: undefined,
|
||||||
|
body: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = {
|
||||||
|
headersSent: false,
|
||||||
|
writeHead: (statusCode, headers) => {
|
||||||
|
resState.statusCode = statusCode;
|
||||||
|
resState.headers = headers;
|
||||||
|
},
|
||||||
|
end: (body) => {
|
||||||
|
resState.body = body;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
url: "/simple/example/",
|
||||||
|
headers: {},
|
||||||
|
method: "GET",
|
||||||
|
on: (event, handler) => {
|
||||||
|
if (event === "end") {
|
||||||
|
handler();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await capturedHandler(request, res);
|
||||||
|
|
||||||
|
assert.equal(capturedOptions.hostname, "pypi.org");
|
||||||
|
assert.equal(resState.statusCode, 200);
|
||||||
|
assert.equal(resState.headers["transfer-encoding"], undefined);
|
||||||
|
assert.equal(
|
||||||
|
resState.headers["content-length"],
|
||||||
|
String(resState.body.byteLength)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,7 +6,7 @@ import { getCombinedCaBundlePath, cleanupCertBundle } from "./certBundle.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";
|
import { getHasSuppressedVersions } from "./interceptors/suppressedVersionsState.js";
|
||||||
|
|
||||||
const SERVER_STOP_TIMEOUT_MS = 1000;
|
const SERVER_STOP_TIMEOUT_MS = 1000;
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ mock.module("../config/settings.js", {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
getMinimumPackageAgeHours: () => minimumPackageAgeHours,
|
getMinimumPackageAgeHours: () => minimumPackageAgeHours,
|
||||||
getEcoSystem: () => ecosystem,
|
getEcoSystem: () => ecosystem,
|
||||||
|
getMalwareListBaseUrl: () => "https://malware-list.aikido.dev",
|
||||||
ECOSYSTEM_JS: "js",
|
ECOSYSTEM_JS: "js",
|
||||||
ECOSYSTEM_PY: "py",
|
ECOSYSTEM_PY: "py",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ mock.module("../config/settings.js", {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
getMinimumPackageAgeHours: () => minimumPackageAgeHours,
|
getMinimumPackageAgeHours: () => minimumPackageAgeHours,
|
||||||
getEcoSystem: () => ecosystem,
|
getEcoSystem: () => ecosystem,
|
||||||
|
getMalwareListBaseUrl: () => "https://malware-list.aikido.dev",
|
||||||
ECOSYSTEM_JS: "js",
|
ECOSYSTEM_JS: "js",
|
||||||
ECOSYSTEM_PY: "py",
|
ECOSYSTEM_PY: "py",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ mock.module("../config/settings.js", {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
getEcoSystem: () => ecosystem,
|
getEcoSystem: () => ecosystem,
|
||||||
getMinimumPackageAgeHours: () => 24,
|
getMinimumPackageAgeHours: () => 24,
|
||||||
|
getMalwareListBaseUrl: () => "https://malware-list.aikido.dev",
|
||||||
ECOSYSTEM_JS: "js",
|
ECOSYSTEM_JS: "js",
|
||||||
ECOSYSTEM_PY: "py",
|
ECOSYSTEM_PY: "py",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,15 @@
|
||||||
import { ECOSYSTEM_PY } from "../config/settings.js";
|
import { ECOSYSTEM_PY } from "../config/settings.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalises a Python package name per PEP 503: lowercase and collapse any
|
||||||
|
* run of `.`, `_`, or `-` into a single hyphen.
|
||||||
|
* @param {string} packageName
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function normalizePipPackageName(packageName) {
|
||||||
|
return packageName.toLowerCase().replace(/[._-]+/g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} packageName
|
* @param {string} packageName
|
||||||
* @param {string} ecosystem
|
* @param {string} ecosystem
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue