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
5cf2ffe201
20 changed files with 404 additions and 92 deletions
37
.github/workflows/build-and-release.yml
vendored
37
.github/workflows/build-and-release.yml
vendored
|
|
@ -60,12 +60,43 @@ jobs:
|
|||
mv binaries/safe-chain-win-x64/safe-chain.exe release-artifacts/safe-chain-win-x64.exe
|
||||
mv binaries/safe-chain-win-arm64/safe-chain.exe release-artifacts/safe-chain-win-arm64.exe
|
||||
|
||||
- name: Move install scripts and hard-code version
|
||||
- name: Move install scripts and hard-code version and checksums
|
||||
env:
|
||||
VERSION: ${{ needs.set-version.outputs.version }}
|
||||
run: |
|
||||
sed "s/\$(fetch_latest_version)/${VERSION}/" install-scripts/install-safe-chain.sh > release-artifacts/install-safe-chain.sh
|
||||
sed "s/\$Version = Get-LatestVersion/\$Version = \"${VERSION}\"/" install-scripts/install-safe-chain.ps1 > release-artifacts/install-safe-chain.ps1
|
||||
SHA_MACOS_X64=$(sha256sum release-artifacts/safe-chain-macos-x64 | awk '{print $1}')
|
||||
SHA_MACOS_ARM64=$(sha256sum release-artifacts/safe-chain-macos-arm64 | awk '{print $1}')
|
||||
SHA_LINUX_X64=$(sha256sum release-artifacts/safe-chain-linux-x64 | awk '{print $1}')
|
||||
SHA_LINUX_ARM64=$(sha256sum release-artifacts/safe-chain-linux-arm64 | awk '{print $1}')
|
||||
SHA_LINUXSTATIC_X64=$(sha256sum release-artifacts/safe-chain-linuxstatic-x64 | awk '{print $1}')
|
||||
SHA_LINUXSTATIC_ARM64=$(sha256sum release-artifacts/safe-chain-linuxstatic-arm64 | awk '{print $1}')
|
||||
SHA_WIN_X64=$(sha256sum release-artifacts/safe-chain-win-x64.exe | awk '{print $1}')
|
||||
SHA_WIN_ARM64=$(sha256sum release-artifacts/safe-chain-win-arm64.exe | awk '{print $1}')
|
||||
|
||||
sed \
|
||||
-e "s/\$(fetch_latest_version)/${VERSION}/" \
|
||||
-e "s|^SHA256_MACOS_X64=\"\"|SHA256_MACOS_X64=\"${SHA_MACOS_X64}\"|" \
|
||||
-e "s|^SHA256_MACOS_ARM64=\"\"|SHA256_MACOS_ARM64=\"${SHA_MACOS_ARM64}\"|" \
|
||||
-e "s|^SHA256_LINUX_X64=\"\"|SHA256_LINUX_X64=\"${SHA_LINUX_X64}\"|" \
|
||||
-e "s|^SHA256_LINUX_ARM64=\"\"|SHA256_LINUX_ARM64=\"${SHA_LINUX_ARM64}\"|" \
|
||||
-e "s|^SHA256_LINUXSTATIC_X64=\"\"|SHA256_LINUXSTATIC_X64=\"${SHA_LINUXSTATIC_X64}\"|" \
|
||||
-e "s|^SHA256_LINUXSTATIC_ARM64=\"\"|SHA256_LINUXSTATIC_ARM64=\"${SHA_LINUXSTATIC_ARM64}\"|" \
|
||||
-e "s|^SHA256_WIN_X64=\"\"|SHA256_WIN_X64=\"${SHA_WIN_X64}\"|" \
|
||||
-e "s|^SHA256_WIN_ARM64=\"\"|SHA256_WIN_ARM64=\"${SHA_WIN_ARM64}\"|" \
|
||||
install-scripts/install-safe-chain.sh > release-artifacts/install-safe-chain.sh
|
||||
|
||||
sed \
|
||||
-e "s/\$Version = Get-LatestVersion/\$Version = \"${VERSION}\"/" \
|
||||
-e "s|^\$SHA256_MACOS_X64 = \"\"|\$SHA256_MACOS_X64 = \"${SHA_MACOS_X64}\"|" \
|
||||
-e "s|^\$SHA256_MACOS_ARM64 = \"\"|\$SHA256_MACOS_ARM64 = \"${SHA_MACOS_ARM64}\"|" \
|
||||
-e "s|^\$SHA256_LINUX_X64 = \"\"|\$SHA256_LINUX_X64 = \"${SHA_LINUX_X64}\"|" \
|
||||
-e "s|^\$SHA256_LINUX_ARM64 = \"\"|\$SHA256_LINUX_ARM64 = \"${SHA_LINUX_ARM64}\"|" \
|
||||
-e "s|^\$SHA256_LINUXSTATIC_X64 = \"\"|\$SHA256_LINUXSTATIC_X64 = \"${SHA_LINUXSTATIC_X64}\"|" \
|
||||
-e "s|^\$SHA256_LINUXSTATIC_ARM64 = \"\"|\$SHA256_LINUXSTATIC_ARM64 = \"${SHA_LINUXSTATIC_ARM64}\"|" \
|
||||
-e "s|^\$SHA256_WIN_X64 = \"\"|\$SHA256_WIN_X64 = \"${SHA_WIN_X64}\"|" \
|
||||
-e "s|^\$SHA256_WIN_ARM64 = \"\"|\$SHA256_WIN_ARM64 = \"${SHA_WIN_ARM64}\"|" \
|
||||
install-scripts/install-safe-chain.ps1 > release-artifacts/install-safe-chain.ps1
|
||||
|
||||
cp install-scripts/uninstall-safe-chain.sh release-artifacts/uninstall-safe-chain.sh
|
||||
cp install-scripts/uninstall-safe-chain.ps1 release-artifacts/uninstall-safe-chain.ps1
|
||||
cp install-scripts/install-endpoint-mac.sh release-artifacts/install-endpoint-mac.sh
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -12,7 +12,7 @@
|
|||
|
||||
## Need protection beyond npm & PyPI?
|
||||
|
||||
[Aikido Endpoint](https://www.aikido.dev/protect/endpoint-protection) builds on Safe Chain, extending package and extension security across more ecosystems: **npm**, **PyPI**, **Maven**, **NuGet**, **VS Code**, **Open VSX** - (Cursor, Windsurf, Kiro, Vs Codium, ...), **Chrome extensions**, **Skills.sh AI skills** and more.
|
||||
[Aikido Endpoint](https://www.aikido.dev/protect/endpoint-protection?utm_source=github.com&utm_medium=referral&utm_campaign=safechain) builds on Safe Chain, extending package and extension security across more ecosystems: **npm**, **PyPI**, **Maven**, **NuGet**, **VS Code**, **Open VSX** - (Cursor, Windsurf, Kiro, Vs Codium, ...), **Chrome extensions**, **Skills.sh AI skills** and more.
|
||||
|
||||
Get centralized policy management, request-and-approval workflows, and visibility across every developer workstation in your org. Powered by the same Aikido Intel feed. Deploy it manually or manage it through your MDM tool (Jamf, Fleet, or Iru).
|
||||
|
||||
|
|
@ -291,6 +291,12 @@ You can set custom registries through environment variable or config file. Both
|
|||
}
|
||||
```
|
||||
|
||||
## PYPI Configuration File
|
||||
|
||||
If you rely on a `pip.conf` file for pip configuration you must point pip at it explicitly via the `PIP_CONFIG_FILE` environment variable so Safe Chain can merge it.
|
||||
|
||||
Safe Chain runs pip behind its MITM proxy and writes a temporary pip configuration file to inject its certificate and proxy settings. When `PIP_CONFIG_FILE` is set, Safe Chain merges its settings into a copy of your file (your original file is never modified) so your `index-url`, credentials, and other options are preserved. When `PIP_CONFIG_FILE` is not set, pip's user-level config (e.g. `~/.config/pip/pip.conf`) might be overridden by Safe Chain's temporary file and your settings will not be picked up.
|
||||
|
||||
## 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.
|
||||
|
|
@ -472,7 +478,7 @@ steps:
|
|||
name: Install
|
||||
script:
|
||||
- curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
|
||||
- export PATH=~/.safe-chain/shims:$PATH
|
||||
- export PATH=~/.safe-chain/shims:~/.safe-chain/bin:$PATH
|
||||
- npm ci
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
set -e # Exit on error
|
||||
|
||||
# Configuration
|
||||
INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.20/EndpointProtection.pkg"
|
||||
DOWNLOAD_SHA256="def6c01caac6a4ce93eb68157a5a6b81028c9203fa13a0f5c539cceb92cc7e7b"
|
||||
INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.3.3/EndpointProtection.pkg"
|
||||
DOWNLOAD_SHA256="a025d33ca493a3b7b77c9515fe7f0b2c1f2dd18fb3e60e08549499cafee6f250"
|
||||
TOKEN_FILE="/tmp/aikido_endpoint_token.txt"
|
||||
|
||||
# Colors for output
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ param(
|
|||
)
|
||||
|
||||
# Configuration
|
||||
$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.20/EndpointProtection.msi"
|
||||
$DownloadSha256 = "46fe377a4ce6204e1cc4a031e80f92f85cb8e1ef6b9690b542438c0870937be3"
|
||||
$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.3.1/EndpointProtection.msi"
|
||||
$DownloadSha256 = "6d72170cfd2090c6af8e111a625fa3961f9dc345495117db4f1d7c518d537076"
|
||||
|
||||
# Ensure TLS 1.2 is enabled for downloads
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
|
|
|
|||
|
|
@ -52,6 +52,20 @@ $SafeChainBase = $installDirValidation.Normalized
|
|||
$InstallDir = Join-Path $SafeChainBase "bin"
|
||||
$RepoUrl = "https://github.com/AikidoSec/safe-chain"
|
||||
|
||||
# SHA256 checksums for release binaries.
|
||||
# Empty in source; populated by the release pipeline.
|
||||
# When empty (running from main), checksum verification is skipped.
|
||||
# Non-Windows hashes are unused today (PS script is Windows-only) but baked in
|
||||
# for future cross-platform support.
|
||||
$SHA256_MACOS_X64 = ""
|
||||
$SHA256_MACOS_ARM64 = ""
|
||||
$SHA256_LINUX_X64 = ""
|
||||
$SHA256_LINUX_ARM64 = ""
|
||||
$SHA256_LINUXSTATIC_X64 = ""
|
||||
$SHA256_LINUXSTATIC_ARM64 = ""
|
||||
$SHA256_WIN_X64 = ""
|
||||
$SHA256_WIN_ARM64 = ""
|
||||
|
||||
# Ensure TLS 1.2 is enabled for downloads
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
|
||||
|
|
@ -166,6 +180,38 @@ function Get-BinaryName {
|
|||
return "safe-chain-win-$Architecture.exe"
|
||||
}
|
||||
|
||||
# Returns the expected SHA256 for the given OS+arch, or empty if not baked in.
|
||||
function Get-ExpectedSha256 {
|
||||
param([string]$Os, [string]$Architecture)
|
||||
switch ("$Os-$Architecture") {
|
||||
"macos-x64" { return $SHA256_MACOS_X64 }
|
||||
"macos-arm64" { return $SHA256_MACOS_ARM64 }
|
||||
"linux-x64" { return $SHA256_LINUX_X64 }
|
||||
"linux-arm64" { return $SHA256_LINUX_ARM64 }
|
||||
"linuxstatic-x64" { return $SHA256_LINUXSTATIC_X64 }
|
||||
"linuxstatic-arm64" { return $SHA256_LINUXSTATIC_ARM64 }
|
||||
"win-x64" { return $SHA256_WIN_X64 }
|
||||
"win-arm64" { return $SHA256_WIN_ARM64 }
|
||||
default { return "" }
|
||||
}
|
||||
}
|
||||
|
||||
function Test-Checksum {
|
||||
param([string]$File, [string]$Expected)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Expected)) { return }
|
||||
|
||||
$actual = (Get-FileHash -Path $File -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$expectedLower = $Expected.ToLowerInvariant()
|
||||
|
||||
if ($actual -ne $expectedLower) {
|
||||
Remove-Item -Path $File -Force -ErrorAction SilentlyContinue
|
||||
Write-Error-Custom "Checksum verification failed. Expected: $expectedLower, Got: $actual"
|
||||
}
|
||||
|
||||
Write-Info "Checksum verified."
|
||||
}
|
||||
|
||||
# Runs safe-chain setup or setup-ci after the binary is installed.
|
||||
# Temporarily appends the install directory to PATH and downgrades setup failures to warnings.
|
||||
function Invoke-SafeChainSetup {
|
||||
|
|
@ -305,6 +351,9 @@ function Install-SafeChain {
|
|||
Write-Error-Custom "Failed to download from $downloadUrl : $_"
|
||||
}
|
||||
|
||||
$expectedSha = Get-ExpectedSha256 -Os "win" -Architecture $arch
|
||||
Test-Checksum -File $tempFile -Expected $expectedSha
|
||||
|
||||
# Rename to final location
|
||||
$finalFile = Join-Path $InstallDir "safe-chain.exe"
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,18 @@ SAFE_CHAIN_BASE="${HOME}/.safe-chain"
|
|||
INSTALL_DIR="${SAFE_CHAIN_BASE}/bin"
|
||||
REPO_URL="https://github.com/AikidoSec/safe-chain"
|
||||
|
||||
# SHA256 checksums for release binaries.
|
||||
# Empty in source; populated by the release pipeline via sed.
|
||||
# When empty (running from main), checksum verification is skipped.
|
||||
SHA256_MACOS_X64=""
|
||||
SHA256_MACOS_ARM64=""
|
||||
SHA256_LINUX_X64=""
|
||||
SHA256_LINUX_ARM64=""
|
||||
SHA256_LINUXSTATIC_X64=""
|
||||
SHA256_LINUXSTATIC_ARM64=""
|
||||
SHA256_WIN_X64=""
|
||||
SHA256_WIN_ARM64=""
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
|
|
@ -156,6 +168,57 @@ fetch_latest_version() {
|
|||
echo "$latest_version"
|
||||
}
|
||||
|
||||
# Returns the expected SHA256 for the detected platform, or empty if the
|
||||
# release pipeline has not baked one in (i.e. running the source from main).
|
||||
get_expected_sha256() {
|
||||
os="$1"; arch="$2"
|
||||
case "${os}-${arch}" in
|
||||
macos-x64) echo "$SHA256_MACOS_X64" ;;
|
||||
macos-arm64) echo "$SHA256_MACOS_ARM64" ;;
|
||||
linux-x64) echo "$SHA256_LINUX_X64" ;;
|
||||
linux-arm64) echo "$SHA256_LINUX_ARM64" ;;
|
||||
linuxstatic-x64) echo "$SHA256_LINUXSTATIC_X64" ;;
|
||||
linuxstatic-arm64) echo "$SHA256_LINUXSTATIC_ARM64" ;;
|
||||
win-x64) echo "$SHA256_WIN_X64" ;;
|
||||
win-arm64) echo "$SHA256_WIN_ARM64" ;;
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
compute_sha256() {
|
||||
file="$1"
|
||||
if command_exists sha256sum; then
|
||||
sha256sum "$file" | awk '{print $1}'
|
||||
elif command_exists shasum; then
|
||||
shasum -a 256 "$file" | awk '{print $1}'
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
# Verifies the downloaded binary against the expected hash baked in by the release pipeline.
|
||||
# No-op when no expected hash is set (running the script from main).
|
||||
verify_checksum() {
|
||||
file="$1"; expected="$2"
|
||||
|
||||
if [ -z "$expected" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
actual=$(compute_sha256 "$file")
|
||||
if [ -z "$actual" ]; then
|
||||
rm -f "$file"
|
||||
error "Cannot verify checksum: neither sha256sum nor shasum is available. Install one and re-run."
|
||||
fi
|
||||
|
||||
if [ "$actual" != "$expected" ]; then
|
||||
rm -f "$file"
|
||||
error "Checksum verification failed. Expected: $expected, Got: $actual"
|
||||
fi
|
||||
|
||||
info "Checksum verified."
|
||||
}
|
||||
|
||||
# Download file
|
||||
download() {
|
||||
url="$1"
|
||||
|
|
@ -428,6 +491,9 @@ main() {
|
|||
info "Downloading from: $DOWNLOAD_URL"
|
||||
download "$DOWNLOAD_URL" "$TEMP_FILE"
|
||||
|
||||
EXPECTED_SHA256=$(get_expected_sha256 "$OS" "$ARCH")
|
||||
verify_checksum "$TEMP_FILE" "$EXPECTED_SHA256"
|
||||
|
||||
# Rename and make executable
|
||||
FINAL_FILE=$(get_final_binary_path "$OS")
|
||||
mv "$TEMP_FILE" "$FINAL_FILE" || error "Failed to move binary to $FINAL_FILE"
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
set -e # Exit on error
|
||||
|
||||
# Configuration
|
||||
UNINSTALL_SCRIPT="/Library/Application Support/AikidoSecurity/EndpointProtection/scripts/uninstall"
|
||||
UNINSTALL_SCRIPT="/Applications/Aikido Endpoint Protection.app/Contents/Resources/scripts/uninstall"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ function getSafeChainProxyEnvironmentVariables() {
|
|||
return {};
|
||||
}
|
||||
|
||||
const proxyUrl = `http://localhost:${state.port}`;
|
||||
const proxyUrl = `http://127.0.0.1:${state.port}`;
|
||||
const caCertPath = getCombinedCaBundlePath();
|
||||
|
||||
return {
|
||||
|
|
@ -95,8 +95,11 @@ function createProxyServer() {
|
|||
*/
|
||||
function startServer(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Passing port 0 makes the OS assign an available port
|
||||
server.listen(0, () => {
|
||||
// Bind to loopback only. Without an explicit host, Node listens on every
|
||||
// interface, turning the proxy into an unauthenticated forward proxy that
|
||||
// anyone reachable on the network can use to hit the victim's localhost,
|
||||
// intranet, or cloud metadata endpoints. Port 0 lets the OS pick a port.
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (address && typeof address === "object") {
|
||||
state.port = address.port;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import { before, after, describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import {
|
||||
createSafeChainProxy,
|
||||
mergeSafeChainProxyEnvironmentVariables,
|
||||
} from "./registryProxy.js";
|
||||
|
||||
describe("registryProxy loopback binding", () => {
|
||||
let proxy, proxyPort;
|
||||
|
||||
before(async () => {
|
||||
proxy = createSafeChainProxy();
|
||||
await proxy.startServer();
|
||||
const envVars = mergeSafeChainProxyEnvironmentVariables([]);
|
||||
proxyPort = parseInt(new URL(envVars.HTTPS_PROXY).port, 10);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await proxy.stopServer();
|
||||
});
|
||||
|
||||
it("advertises a loopback HTTPS_PROXY URL", () => {
|
||||
const envVars = mergeSafeChainProxyEnvironmentVariables([]);
|
||||
const hostname = new URL(envVars.HTTPS_PROXY).hostname;
|
||||
assert.ok(
|
||||
hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost",
|
||||
`expected loopback hostname, got ${hostname}`
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses connections on non-loopback interfaces", async () => {
|
||||
const externalAddrs = Object.values(os.networkInterfaces())
|
||||
.flat()
|
||||
.filter((iface) => iface && iface.family === "IPv4" && !iface.internal)
|
||||
.map((iface) => iface.address);
|
||||
|
||||
if (externalAddrs.length === 0) {
|
||||
// No non-loopback interface available (e.g. locked-down CI) - skip.
|
||||
return;
|
||||
}
|
||||
|
||||
for (const addr of externalAddrs) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const sock = net.createConnection({ host: addr, port: proxyPort });
|
||||
const timer = setTimeout(() => {
|
||||
sock.destroy();
|
||||
resolve(); // Filtered / dropped is also fine - we just don't want success.
|
||||
}, 500);
|
||||
sock.once("connect", () => {
|
||||
clearTimeout(timer);
|
||||
sock.destroy();
|
||||
reject(
|
||||
new Error(
|
||||
`proxy accepted a connection on non-loopback ${addr}:${proxyPort}`
|
||||
)
|
||||
);
|
||||
});
|
||||
sock.once("error", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -15,8 +15,12 @@ import { getEcoSystem, ECOSYSTEM_PY } from "../config/settings.js";
|
|||
* @property {function(string, string): boolean} isMalware
|
||||
*/
|
||||
|
||||
/** @type {MalwareDatabase | null} */
|
||||
let cachedMalwareDatabase = null;
|
||||
// Caching the Promise (rather than the resolved database) prevents duplicate fetches. If we cached the resolved
|
||||
// value, multiple callers could pass the null-check before the first fetch completes (because each `await` yields
|
||||
// control back to the event loop, allowing other callers to run). Since the Promise assignment is synchronous, all
|
||||
// concurrent callers see it immediately and share a single fetch.
|
||||
/** @type {Promise<MalwareDatabase> | null} */
|
||||
let cachedMalwareDatabasePromise = null;
|
||||
|
||||
/**
|
||||
* Normalize package name for comparison.
|
||||
|
|
@ -34,13 +38,9 @@ function normalizePackageName(name) {
|
|||
return name;
|
||||
}
|
||||
|
||||
export async function openMalwareDatabase() {
|
||||
if (cachedMalwareDatabase) {
|
||||
return cachedMalwareDatabase;
|
||||
}
|
||||
|
||||
const malwareDatabase = await getMalwareDatabase();
|
||||
|
||||
export function openMalwareDatabase() {
|
||||
if (!cachedMalwareDatabasePromise) {
|
||||
cachedMalwareDatabasePromise = getMalwareDatabase().then((malwareDatabase) => {
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} version
|
||||
|
|
@ -63,16 +63,19 @@ export async function openMalwareDatabase() {
|
|||
return packageData.reason;
|
||||
}
|
||||
|
||||
// This implicitly caches the malware database
|
||||
// that's closed over by the getPackageStatus function
|
||||
cachedMalwareDatabase = {
|
||||
return {
|
||||
getPackageStatus,
|
||||
isMalware: (name, version) => {
|
||||
isMalware: (/** @type {string} */ name, /** @type {string} */ version) => {
|
||||
const status = getPackageStatus(name, version);
|
||||
return isMalwareStatus(status);
|
||||
},
|
||||
};
|
||||
return cachedMalwareDatabase;
|
||||
}).catch((error) => {
|
||||
cachedMalwareDatabasePromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return cachedMalwareDatabasePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -16,30 +16,27 @@ import { warnOnceAboutUnavailableDatabase } from "./newPackagesDatabaseWarnings.
|
|||
*/
|
||||
|
||||
// Shared per-process cache to avoid rebuilding the same feed-backed database on each request.
|
||||
/** @type {NewPackagesDatabase | null} */
|
||||
let cachedNewPackagesDatabase = null;
|
||||
// Caching the Promise (rather than the resolved database) prevents duplicate fetches. If we cached the resolved
|
||||
// value, multiple callers could pass the null-check before the first fetch completes (because each `await` yields
|
||||
// control back to the event loop, allowing other callers to run). Since the Promise assignment is synchronous, all
|
||||
// concurrent callers see it immediately and share a single fetch.
|
||||
/** @type {Promise<NewPackagesDatabase> | null} */
|
||||
let cachedNewPackagesDatabasePromise = null;
|
||||
|
||||
/**
|
||||
* @returns {Promise<NewPackagesDatabase>}
|
||||
*/
|
||||
export async function openNewPackagesDatabase() {
|
||||
if (cachedNewPackagesDatabase) {
|
||||
return cachedNewPackagesDatabase;
|
||||
}
|
||||
|
||||
/** @type {import("../api/aikido.js").NewPackageEntry[]} */
|
||||
let newPackagesList;
|
||||
|
||||
try {
|
||||
newPackagesList = await getNewPackagesList();
|
||||
} catch (/** @type {any} */ error) {
|
||||
export function openNewPackagesDatabase() {
|
||||
if (!cachedNewPackagesDatabasePromise) {
|
||||
cachedNewPackagesDatabasePromise = getNewPackagesList()
|
||||
.then((newPackagesList) => buildNewPackagesDatabase(newPackagesList))
|
||||
.catch((/** @type {any} */ error) => {
|
||||
warnOnceAboutUnavailableDatabase(error);
|
||||
cachedNewPackagesDatabase = { isNewlyReleasedPackage: () => false };
|
||||
return cachedNewPackagesDatabase;
|
||||
cachedNewPackagesDatabasePromise = null;
|
||||
return { isNewlyReleasedPackage: () => false };
|
||||
});
|
||||
}
|
||||
|
||||
cachedNewPackagesDatabase = buildNewPackagesDatabase(newPackagesList);
|
||||
return cachedNewPackagesDatabase;
|
||||
return cachedNewPackagesDatabasePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -58,12 +58,21 @@ export class DockerTestContainer {
|
|||
`docker run -d --name ${this.containerName} ${imageName} sleep infinity`,
|
||||
{ stdio: "ignore" }
|
||||
);
|
||||
|
||||
await this.startMalwareMirror();
|
||||
|
||||
this.isRunning = true;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to start container: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async startMalwareMirror() {
|
||||
const shell = await this.openShell("zsh");
|
||||
await shell.runCommand("node /utils/malwarelistmirror.mjs &");
|
||||
await shell.runCommand("until curl -sf http://127.0.0.1:5555/ready; do sleep 0.2; done");
|
||||
}
|
||||
|
||||
dockerExec(command, daemon = false) {
|
||||
if (!this.isRunning) {
|
||||
throw new Error("Container is not running");
|
||||
|
|
@ -125,7 +134,7 @@ export class DockerTestContainer {
|
|||
const timeout = setTimeout(() => {
|
||||
// Fallback in case the command doesn't finish in a reasonable time
|
||||
// oxlint-disable-next-line no-console - having this log in CI helps diagnose issues
|
||||
console.log("Command timeout reached");
|
||||
console.log(`Command timeout reached for "${command}"`);
|
||||
resolve({ allData, output: parseShellOutput(allData), command });
|
||||
ptyProcess.removeListener("data", handleInput);
|
||||
}, 15000);
|
||||
|
|
|
|||
|
|
@ -84,3 +84,5 @@ RUN npm install -g /pkgs/*.tgz
|
|||
WORKDIR /testapp
|
||||
RUN npm init -y
|
||||
|
||||
COPY test/e2e/utils/malwarelistmirror.mjs /utils/malwarelistmirror.mjs
|
||||
ENV SAFE_CHAIN_MALWARE_LIST_BASE_URL=http://127.0.0.1:5555
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ describe("E2E: pip coverage", () => {
|
|||
it(`safe-chain blocks installation of malicious Python packages`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
const result = await shell.runCommand(
|
||||
"pip3 install --break-system-packages safe-chain-pi-test"
|
||||
"pip3 install --break-system-packages numpy==2.4.4 --safe-chain-logging=verbose"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
|
|
@ -136,7 +136,7 @@ describe("E2E: pip coverage", () => {
|
|||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
assert.ok(
|
||||
result.output.includes("safe_chain_pi_test@0.0.1"),
|
||||
result.output.includes("numpy@2.4.4"),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
assert.ok(
|
||||
|
|
@ -146,7 +146,7 @@ describe("E2E: pip coverage", () => {
|
|||
|
||||
const listResult = await shell.runCommand("pip3 list");
|
||||
assert.ok(
|
||||
!listResult.output.includes("safe-chain-pi-test"),
|
||||
!listResult.output.includes("numpy"),
|
||||
`Malicious package was installed despite safe-chain protection. Output of 'pip3 list' was:\n${listResult.output}`
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ describe("E2E: pipx coverage", () => {
|
|||
const shell = await container.openShell("zsh");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"pipx install safe-chain-pi-test"
|
||||
"pipx install numpy==2.4.4"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
|
|
@ -86,7 +86,7 @@ describe("E2E: pipx coverage", () => {
|
|||
const shell = await container.openShell("zsh");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"pipx run safe-chain-pi-test --version"
|
||||
"pipx run numpy==2.4.4 --version"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
|
|
@ -122,7 +122,7 @@ describe("E2E: pipx coverage", () => {
|
|||
await shell.runCommand("pipx install ruff");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"pipx runpip ruff install safe-chain-pi-test"
|
||||
"pipx runpip ruff install numpy==2.4.4"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
|
|
@ -185,7 +185,7 @@ describe("E2E: pipx coverage", () => {
|
|||
|
||||
await shell.runCommand("pipx install ruff --safe-chain-logging=verbose");
|
||||
const result = await shell.runCommand(
|
||||
"pipx inject ruff safe-chain-pi-test --safe-chain-logging=verbose"
|
||||
"pipx inject ruff numpy==2.4.4 --safe-chain-logging=verbose"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ describe("E2E: poetry coverage", () => {
|
|||
await shell.runCommand("cd /tmp/test-poetry-malware && poetry init --no-interaction");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"cd /tmp/test-poetry-malware && poetry add safe-chain-pi-test"
|
||||
"cd /tmp/test-poetry-malware && poetry add numpy==2.4.4"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
|
|
@ -300,7 +300,7 @@ describe("E2E: poetry coverage", () => {
|
|||
|
||||
// Add malware package - this will create lock file and attempt download
|
||||
const result = await shell.runCommand(
|
||||
"cd /tmp/test-poetry-install-malware && poetry add safe-chain-pi-test 2>&1"
|
||||
"cd /tmp/test-poetry-install-malware && poetry add numpy==2.4.4 2>&1"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
|
|
@ -324,7 +324,7 @@ describe("E2E: poetry coverage", () => {
|
|||
|
||||
// Now try to add malware via add command
|
||||
const result = await shell.runCommand(
|
||||
"cd /tmp/test-poetry-update-add && poetry add safe-chain-pi-test 2>&1"
|
||||
"cd /tmp/test-poetry-update-add && poetry add numpy==2.4.4 2>&1"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
|
|
@ -345,7 +345,7 @@ describe("E2E: poetry coverage", () => {
|
|||
|
||||
// Try to add malware directly - this is the primary vector
|
||||
const result = await shell.runCommand(
|
||||
"cd /tmp/test-poetry-req-malware && poetry add safe-chain-pi-test requests 2>&1"
|
||||
"cd /tmp/test-poetry-req-malware && poetry add numpy==2.4.4 requests 2>&1"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ describe("E2E: safe-chain CLI python/pip support", () => {
|
|||
await shell.runCommand("pip3 cache purge");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"safe-chain pip3 install --break-system-packages safe-chain-pi-test"
|
||||
"safe-chain pip3 install --break-system-packages numpy==2.4.4"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
|
|
|
|||
79
test/e2e/utils/malwarelistmirror.mjs
Normal file
79
test/e2e/utils/malwarelistmirror.mjs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// Test-only mirror of the malware list. Injects known-safe packages as malicious
|
||||
// to simulate blocking behavior in e2e tests without affecting real data.
|
||||
|
||||
import * as http from "node:http";
|
||||
|
||||
const lists = await downloadLists();
|
||||
const server = http.createServer(handleRequest);
|
||||
server.listen(5555, "127.0.0.1");
|
||||
console.log("listening on http://127.0.0.1:5555");
|
||||
|
||||
function handleRequest(req, res) {
|
||||
if (req.method !== "GET" || !req.url) {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url.startsWith("/ready")) {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const list of lists) {
|
||||
if (req.url.startsWith(list.path)) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(list.data));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
|
||||
async function downloadLists() {
|
||||
const lists = [
|
||||
{
|
||||
"path": "/malware_predictions.json",
|
||||
"patchFunc": (data) => data,
|
||||
},
|
||||
{
|
||||
"path": "/malware_pypi.json",
|
||||
"patchFunc": patchPypi,
|
||||
},
|
||||
{
|
||||
"path": "/releases/npm.json",
|
||||
"patchFunc": (data) => data,
|
||||
},
|
||||
{
|
||||
"path": "/releases/pypi.json",
|
||||
"patchFunc": (data) => data,
|
||||
},
|
||||
]
|
||||
|
||||
for (const list of lists) {
|
||||
list.data = list.patchFunc(await downloadList(list.path));
|
||||
}
|
||||
|
||||
return lists;
|
||||
}
|
||||
|
||||
async function downloadList(path) {
|
||||
const baseUrl = "https://malware-list.aikido.dev";
|
||||
const url = `${baseUrl}${path}`;
|
||||
const response = await fetch(url);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
function patchPypi(data) {
|
||||
|
||||
data.push({
|
||||
"package_name": "numpy",
|
||||
"version": "2.4.4",
|
||||
"reason": "MALWARE"
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
|
@ -126,7 +126,7 @@ describe("E2E: uv coverage", () => {
|
|||
const shell = await container.openShell("zsh");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"uv pip install --system --break-system-packages safe-chain-pi-test"
|
||||
"uv pip install --system --break-system-packages numpy==2.4.4"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
|
|
@ -134,7 +134,7 @@ describe("E2E: uv coverage", () => {
|
|||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
assert.ok(
|
||||
result.output.includes("safe_chain_pi_test@0.0.1"),
|
||||
result.output.includes("numpy@2.4.4"),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
assert.ok(
|
||||
|
|
@ -144,7 +144,7 @@ describe("E2E: uv coverage", () => {
|
|||
|
||||
const listResult = await shell.runCommand("uv pip list --system");
|
||||
assert.ok(
|
||||
!listResult.output.includes("safe-chain-pi-test"),
|
||||
!listResult.output.includes("numpy"),
|
||||
`Malicious package was installed despite safe-chain protection. Output of 'uv pip list' was:\n${listResult.output}`
|
||||
);
|
||||
});
|
||||
|
|
@ -413,7 +413,7 @@ describe("E2E: uv coverage", () => {
|
|||
await shell.runCommand("uv init test-project-malware");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"cd test-project-malware && uv add safe-chain-pi-test"
|
||||
"cd test-project-malware && uv add numpy==2.4.4"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
|
|
@ -421,7 +421,7 @@ describe("E2E: uv coverage", () => {
|
|||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
assert.ok(
|
||||
result.output.includes("safe_chain_pi_test@0.0.1"),
|
||||
result.output.includes("numpy@2.4.4"),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
assert.ok(
|
||||
|
|
@ -445,14 +445,14 @@ describe("E2E: uv coverage", () => {
|
|||
|
||||
it(`safe-chain blocks malicious packages via uv tool install`, async () => {
|
||||
const shell = await container.openShell("zsh");
|
||||
const result = await shell.runCommand("uv tool install safe-chain-pi-test");
|
||||
const result = await shell.runCommand("uv tool install numpy==2.4.4");
|
||||
|
||||
assert.ok(
|
||||
result.output.includes("blocked 1 malicious package downloads:"),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
assert.ok(
|
||||
result.output.includes("safe_chain_pi_test@0.0.1"),
|
||||
result.output.includes("numpy@2.4.4"),
|
||||
`Output did not include expected text. Output was:\n${result.output}`
|
||||
);
|
||||
});
|
||||
|
|
@ -482,7 +482,7 @@ describe("E2E: uv coverage", () => {
|
|||
await shell.runCommand("echo 'print(\"test\")' > test_script2.py");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"uv run --with safe-chain-pi-test test_script2.py"
|
||||
"uv run --with numpy==2.4.4 test_script2.py"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ describe("E2E: uvx coverage", () => {
|
|||
const shell = await container.openShell("zsh");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"uvx safe-chain-pi-test"
|
||||
"uvx numpy==2.4.4"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
|
|
@ -74,7 +74,7 @@ describe("E2E: uvx coverage", () => {
|
|||
const shell = await container.openShell("zsh");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"uvx --from safe-chain-pi-test some-command"
|
||||
"uvx --from numpy==2.4.4 some-command"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
|
|
@ -117,7 +117,7 @@ describe("E2E: uvx coverage", () => {
|
|||
const shell = await container.openShell("zsh");
|
||||
|
||||
const result = await shell.runCommand(
|
||||
"uvx --with safe-chain-pi-test ruff --version"
|
||||
"uvx --with numpy==2.4.4 ruff --version"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue