Compare commits

..

104 commits

Author SHA1 Message Date
bitterpanda
9453c8c0c9
Merge pull request #470 from AikidoSec/bump/endpoint-v1.5.4
Bump Endpoint to v1.5.4
2026-05-21 10:41:08 -07:00
github-actions[bot]
2621f6f974 Bump Endpoint to v1.5.4 2026-05-21 17:39:03 +00:00
bitterpanda
fd01d9f31b
Merge pull request #466 from AikidoSec/slack-url-secret
Store the slack url as a secret
2026-05-20 10:10:10 -07:00
Sander Declerck
0414a79982
Merge pull request #467 from AikidoSec/feat/bump-endpoint-1-5-3
Bump Endpoint to 1.5.3
2026-05-20 18:26:42 +02:00
Reinier Criel
70b5e4d012 Bump Endpoint 2026-05-20 08:39:03 -07:00
Sander Declerck
aed0aebdae
Store the slack url as a secret 2026-05-20 09:20:03 +02:00
bitterpanda
6aec1bc474
Merge pull request #464 from AikidoSec/create-bump-endpoint-workflow
Create a bump-endpoint.yml workflow
2026-05-19 15:03:09 -07:00
bitterpanda
f6145d5c20
Update bump-endpoint.yml 2026-05-19 14:58:55 -07:00
bitterpanda
ab058367f1 temp: re-add push trigger for testing 2026-05-19 14:56:46 -07:00
bitterpanda
f2cce7b7e9 temp: skip if branch already exists instead of checking for PR 2026-05-19 14:56:15 -07:00
bitterpanda
0b46c5408b
Update bump-endpoint.yml 2026-05-19 14:55:22 -07:00
bitterpanda
07b8571758 temp: post compare URL to Slack instead of creating PR 2026-05-19 14:52:37 -07:00
bitterpanda
3f0837c65a temp: use open-source-releaser runner 2026-05-19 14:48:23 -07:00
bitterpanda
47e9ed0f6c temp: trigger bump-endpoint on push to test 2026-05-19 14:47:33 -07:00
bitterpanda
cbbbe703d3 Add a slack webhook curl req for endpoint bumps 2026-05-19 14:45:26 -07:00
bitterpanda
9d44eca1d1
Apply suggestion from @bitterpanda63 2026-05-19 14:39:04 -07:00
bitterpanda
b38aba43dd Create a bump-endpoint.yml workflow 2026-05-19 14:37:02 -07:00
bitterpanda
93c264ef84
Merge pull request #463 from AikidoSec/remove-npm-token
Remove obsolete npm token from pipeline
2026-05-18 09:55:47 -07:00
Sander Declerck
34898980d7
Remove obsolete npm token from pipeline 2026-05-18 10:24:37 +02:00
Reinier Criel
a5c29d9e49
Merge pull request #399 from greenpixie/feat/pdm-support
Support for PDM package manager (Python)
2026-05-15 08:43:38 -07:00
Chris Ingram
bf2d37d114
Merge branch 'main' into feat/pdm-support 2026-05-15 08:46:06 +01:00
Sander Declerck
65a8075b0e
Merge pull request #459 from AikidoSec/bug/execpath
unset PKG_EXECPATH before invoking safe-chain binary
2026-05-15 09:11:32 +02:00
Chris Ingram
a1b89a55f8
Make block-count assertions count-agnostic in bun e2e
Bun retries blocked downloads, so the count in "blocked N malicious
package downloads" can be >1. Match on the surrounding text rather than
a fixed count to keep the assertion robust.

Also drops the brittle "pdm update updates dependencies" case.
2026-05-14 17:16:57 +01:00
Chris Ingram
8ab5cebd4f
Match actual block output in pdm e2e assertions
The user-facing message is "Safe-chain: blocked N malicious package
downloads", not "blocked by safe-chain" (which only appears in the
proxy's HTTP response, not the rendered CLI output).
2026-05-14 16:48:18 +01:00
Chris Ingram
ffe7f8de1f
Use numpy==2.4.4 as test malware in pdm e2e tests
The safe-chain-pi-test package no longer exists on PyPI. Aikido now
patches numpy==2.4.4 into the malware list for tests, matching the
pattern already used in the poetry e2e suite.
2026-05-14 16:28:50 +01:00
Chris Ingram
54db058ac7
Use getPackageManagerList in safe-chain setup help text
The install message in `safe-chain setup` help was hardcoding a stale
list of package managers (missing uv, uvx, poetry, pipx, pdm). Use the
existing getPackageManagerList() helper so the list stays in sync with
knownAikidoTools.
2026-05-14 10:04:18 +01:00
Chris Ingram
8453012f7b
Merge remote-tracking branch 'aikido/main' into feat/pdm-support 2026-05-14 09:51:31 +01:00
Reinier Criel
e0e06431d1 Fix tests 2026-05-13 20:28:58 -07:00
Reinier Criel
6cdad3df98 Fix tests 2026-05-13 20:27:27 -07:00
Reinier Criel
d9b7aefd34 unset PKG_EXECPATH before invoking safe-chain binary 2026-05-13 14:33:58 -07:00
Reinier Criel
0c8de1e606
Merge pull request #382 from mcmeeking/feature/add-rush-monorepo-support
Add Rush support (for monorepos)
2026-05-12 10:03:34 -07:00
James McMeeking
fde0003a0a
Fix expected format to account for retries
Count is apparently not deterministic
2026-05-12 17:33:31 +01:00
James McMeeking
c93f1920fb
Skip min safe age to allow brand new PNPM boostrap 2026-05-12 16:53:51 +01:00
James McMeeking
d812231b2f
Merge branch 'main' into feature/add-rush-monorepo-support 2026-05-12 16:43:38 +01:00
Sander Declerck
e5cd9eed91
Merge pull request #453 from AikidoSec/fix-e2e-pnpm
E2E: Use pnpm 10 in node versions that don't support pnpm 11
2026-05-12 17:27:17 +02:00
James McMeeking
25d966bfa9
Switch to using the versions from the CI matrix
Incorporates the actual Rush and PNPM versions instead of pinning an old known-good version of PNPM
2026-05-12 10:52:38 +01:00
James McMeeking
5f0ad7ecfd
Address e2e suite failures 2026-05-12 10:33:26 +01:00
Sander Declerck
6667e5d7b4
E2E: Use pnpm 10 in node versions that don't support pnpm 11 2026-05-11 16:04:27 +02:00
James McMeeking
e891d1a992
Update e2e suite to cover supported package managers 2026-05-08 13:13:37 +01:00
James McMeeking
26f1dfb81a
Use the standard install command for rush 2026-05-08 13:12:57 +01:00
James McMeeking
7ce44b4c62
Remove the unecessary proxy setting 2026-05-08 13:12:40 +01:00
James McMeeking
28132ba3fc
Merge branch 'main' into feature/add-rush-monorepo-support 2026-05-08 11:25:47 +01:00
James McMeeking
55f2123f5c
Remove the normalisation bits added in error 2026-05-08 11:25:07 +01:00
James McMeeking
5f56114185
Add e2e tests
Note: rushx only dispatches package.json scripts, so it's probably not necessary to add it as a distinct manager at all.
2026-05-08 11:24:17 +01:00
James McMeeking
08ae1ef732
Pull parsing logic into distinct file and remove invalid continue 2026-05-08 11:08:58 +01:00
bitterpanda
2eb32d4297
Merge pull request #446 from AikidoSec/troubleshooting-guide
moved troubleshooting from docs to repo
2026-05-06 16:53:43 +08:00
Samuel Vandamme
fbe094802e reverted copy 2026-05-06 10:51:35 +02:00
Samuel Vandamme
bd876275b3 updated troubleshooting guide and linked from readme 2026-05-06 10:51:13 +02:00
Samuel Vandamme
cd5040c3be moved troubleshooting from docs to here 2026-05-06 10:47:37 +02:00
Reinier Criel
7b39239b81
Merge pull request #444 from AikidoSec/feat/bump-endpoint-1-3-4
Bump Endpoint to 1.3.4
2026-05-01 15:20:07 -07:00
Reinier Criel
369a94948a Bump Endpoint to 1.3.4 2026-05-01 14:34:35 -07:00
James McMeeking
98a1ba7d10
Add rushx support too
Co-authored-by: Copilot <copilot@github.com>
2026-05-01 17:04:38 +01:00
James McMeeking
5cf2ffe201
Merge branch 'main' into feature/add-rush-monorepo-support 2026-05-01 16:49:49 +01:00
Reinier Criel
cb8db6c7a2
Merge pull request #443 from AikidoSec/vbump-v1.3.3
Bump Endpoint Protection to latest
2026-05-01 07:26:31 -07:00
Tudor Timcu
f4aa444cd8
Bump Endpoint Protection to latest 2026-05-01 14:43:41 +03:00
bitterpanda
da419a7785
Merge pull request #442 from AikidoSec/feat/readme-pypi-conf
Add PIP_CONFIG_FILE section in readme
2026-05-01 11:53:16 +02:00
Sander Declerck
00be33aa10
Merge pull request #423 from xandervr/security/proxy-loopback-only
Bind registry proxy to loopback only
2026-04-30 23:46:02 -07:00
Reinier Criel
a0f0372e15 Add PIP_CONFIG_FILE section in readme 2026-04-30 15:21:51 -07:00
Xander Van Raemdonck
19d2dee5c9
Bind registry proxy to loopback only
Without an explicit host, `server.listen(0)` binds to every interface,
turning safe-chain's unauthenticated forward proxy into an open relay
while `aikido-*` commands are running. Anyone reachable on the network
can use it to hit the victim's localhost, intranet, or cloud metadata
endpoints. The advertised HTTPS_PROXY URL already used `localhost`
(loopback), but the listener itself was wide open.

Bind to 127.0.0.1 explicitly and update the advertised URL to match.
Add a regression test that verifies the listener refuses connections
on non-loopback interfaces.
2026-04-30 20:37:41 +02:00
Sander Declerck
cbf830a637
Merge pull request #441 from AikidoSec/vbump-v1.3.2
Bump Endpoint Protection to v1.3.2
2026-04-30 08:03:57 -07:00
Tudor Timcu
c8e25f3c21
Bump Endpoint Protection to v1.3.2 2026-04-30 18:02:18 +03:00
Sander Declerck
fe161ba8a4
Merge pull request #438 from AikidoSec/verify-sha256-in-intall-script-beta
Add binary checksum validation in safe-chain install scripts
2026-04-29 17:58:41 +02:00
bitterpanda
8571fc6996
Merge pull request #440 from AikidoSec/endpoint-1-3
Update Aikido Endpoint version to 1.3.1
2026-04-29 15:30:05 +02:00
Sander Declerck
f3fd003303
Update Aikido Endpoint version to 1.3.1 2026-04-29 15:23:09 +02:00
Sander Declerck
d0fc643f23
Verify sha2356 checksum in install scripts 2026-04-29 12:50:17 +02:00
bitterpanda
bf2bf24343
Merge pull request #436 from AikidoSec/mirror-malware-list-in-e2e-tests
Mirror malware list in e2e tests to mock malware in a harmless way
2026-04-28 15:14:08 +02:00
Sander Declerck
ebebe6d6c1
Mirror malware list in e2e tests to mock malware in a harmless way 2026-04-28 14:47:49 +02:00
bitterpanda
222216e22a
Merge pull request #435 from AikidoSec/bitterpanda63-patch-3
Enhance Aikido Endpoint link with UTM parameters
2026-04-28 09:03:55 +02:00
bitterpanda
4ef69d337f
Merge pull request #433 from AikidoSec/feat/update-github-actions-example
Fix Bitbucket Pipelines Example
2026-04-28 08:51:35 +02:00
bitterpanda
6abad2d37f
Enhance Aikido Endpoint link with UTM parameters
Updated the Aikido Endpoint link to include UTM parameters for tracking.
2026-04-28 08:50:54 +02:00
Reinier Criel
ae40140199 Fix Bitbucket Pipelines Example 2026-04-27 12:51:31 -07:00
bitterpanda
725f7c399d
Merge pull request #419 from AikidoSec/concurrency-in-malware-list-fetch 2026-04-27 10:48:31 +02:00
Sander Declerck
dcd926f9d9
Merge pull request #431 from AikidoSec/feat/bump-endpoint-1-2-23
Bump Endpoint Version to 1.2.23
2026-04-27 09:52:26 +02:00
Reinier Criel
d04db58a5e Bump Endpoint Version to 1.2.23 2026-04-26 17:19:34 -07:00
Sander Declerck
9b42755502
Merge pull request #429 from AikidoSec/endpoint-1-2-22
Endpoint 1.2.22
2026-04-24 17:27:27 +02:00
Sander Declerck
e8fb134136
Endpoint 1.2.22 2026-04-24 17:12:48 +02:00
Sander Declerck
fbb856940f
Merge pull request #428 from AikidoSec/endpoint-uninstall-script-location-update
Update endpoint uninstall script location
2026-04-24 12:11:03 +02:00
Sander Declerck
0a230eb64c
Update endpoint uninstall script location 2026-04-24 12:04:31 +02:00
Reinier Criel
dab616163f
Merge pull request #427 from AikidoSec/feat/bump-endpoint-1-2-21
Bump endpoint
2026-04-23 11:05:53 -07:00
Reinier Criel
d81b0f5214 Bump endpoint 2026-04-23 10:32:04 -07:00
James
84346fdea7
Merge branch 'main' into feature/add-rush-monorepo-support 2026-04-23 16:29:15 +01:00
bitterpanda
c68fb2c7ed
Merge pull request #426 from AikidoSec/readme-aikido-endpoint 2026-04-23 11:59:34 +02:00
Samuel Vandamme
c22f36113c moved endpoint up 2026-04-22 17:42:22 +02:00
Chris Ingram
abbe0480b6
Merge branch 'main' into feat/pdm-support 2026-04-22 14:25:32 +01:00
bitterpanda
fff1422b51
Merge pull request #425 from AikidoSec/endpoint-v1-2-20
Endpoint 1.2.20
2026-04-22 13:03:50 +02:00
Sander Declerck
88c969aee0
Endpoint 1.2.20 2026-04-22 13:02:41 +02:00
bitterpanda
f56edf292b
Merge pull request #422 from AikidoSec/feat/bump-endpoint 2026-04-21 20:28:27 +02:00
Reinier Criel
fbabd4e3c6 Bump endpoint versions 2026-04-21 11:05:06 -07:00
Sander Declerck
8dc5389ac9
Merge pull request #420 from AikidoSec/readme-aikido-endpoint
Add Aikido Endpoint paragraph to README.md
2026-04-21 13:35:33 +02:00
Samuel Vandamme
a840a99f1b moved endpoint up 2026-04-21 11:20:43 +02:00
Sander Declerck
21b44eb4a8
Mention cursor, windsurf, ... 2026-04-21 11:13:25 +02:00
Sander Declerck
b8d16c15b9
Add Aikido Endpoint paragraph to README.md 2026-04-21 11:09:18 +02:00
Sander Declerck
9fae225277
Make sure rejected promise is not cached in malware list / new packages cache 2026-04-21 09:31:26 +02:00
Sander Declerck
2930894624
Fix concurrency bug leading to multiple fetches of the malware database 2026-04-21 09:26:07 +02:00
bitterpanda
3e71398430
Merge pull request #418 from AikidoSec/bug/pypi-meta-data-cache-header
Fix PyPI minimum-age fallback when cached metadata bypasses rewrite
2026-04-19 15:30:11 +02:00
Reinier Criel
464847a6fc Add e2e test 2026-04-17 10:50:04 -07:00
Reinier Criel
33c3bec43d Fix PyPI minimum-age fallback when cached metadata bypasses rewrite 2026-04-17 09:37:40 -07:00
James McMeeking
178b8a4423
Merge branch 'main' into feature/add-rush-monorepo-support 2026-04-08 16:24:23 +01:00
Chris Ingram
42102eb067 Merge branch 'main' into feat/pdm-support 2026-04-07 11:27:39 +01:00
Chris Ingram
ced5e26420 File mode on aikido-pdm.js 2026-04-07 11:19:04 +01:00
James
f26cdab1f6
Merge branch 'main' into feature/add-rush-monorepo-support 2026-04-06 18:52:18 +01:00
Chris Ingram
1eb4fe05fd Add pdm package manager support
PDM is a modern Python package manager using pyproject.toml (PEP 621).
Uses the same MITM-only proxy approach as poetry/uv/pipx — all malware
detection and minimum package age enforcement happens at the proxy layer
by intercepting PyPI requests.
2026-04-06 13:01:42 +01:00
James McMeeking
6f976f6a2b
Address PR comments 2026-04-02 13:03:01 +01:00
James McMeeking
5690e55d99
Add rush command wrapper and tests 2026-04-02 12:31:02 +01:00
59 changed files with 2141 additions and 226 deletions

View file

@ -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
@ -113,8 +144,6 @@ jobs:
with:
node-version: "lts/*"
registry-url: "https://registry.npmjs.org/"
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
- name: Setup safe-chain
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci

82
.github/workflows/bump-endpoint.yml vendored Normal file
View file

@ -0,0 +1,82 @@
name: Bump Device Protection Automatically
on:
schedule:
- cron: '0 * * * *' # every hour
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
bump-endpoint:
runs-on: open-source-releaser
steps:
- uses: actions/checkout@v4
- name: Get latest safechain-internals release
id: latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=$(gh api repos/AikidoSec/safechain-internals/releases/latest --jq '.tag_name')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Get current version from install script
id: current
run: |
CURRENT=$(grep -oP '(?<=releases/download/)[^/]+(?=/EndpointProtection\.pkg)' install-scripts/install-endpoint-mac.sh)
echo "version=$CURRENT" >> $GITHUB_OUTPUT
- name: Download assets and compute checksums
if: steps.latest.outputs.version != steps.current.outputs.version
id: checksums
run: |
VERSION="${{ steps.latest.outputs.version }}"
BASE="https://github.com/AikidoSec/safechain-internals/releases/download/${VERSION}"
curl -fsSL "${BASE}/EndpointProtection.pkg" -o /tmp/EndpointProtection.pkg
curl -fsSL "${BASE}/EndpointProtection.msi" -o /tmp/EndpointProtection.msi
echo "mac=$(sha256sum /tmp/EndpointProtection.pkg | cut -d' ' -f1)" >> $GITHUB_OUTPUT
echo "win=$(sha256sum /tmp/EndpointProtection.msi | cut -d' ' -f1)" >> $GITHUB_OUTPUT
- name: Update install scripts
if: steps.latest.outputs.version != steps.current.outputs.version
run: |
NEW="${{ steps.latest.outputs.version }}"
OLD="${{ steps.current.outputs.version }}"
MAC_SHA="${{ steps.checksums.outputs.mac }}"
WIN_SHA="${{ steps.checksums.outputs.win }}"
sed -i "s|${OLD}/EndpointProtection.pkg|${NEW}/EndpointProtection.pkg|" install-scripts/install-endpoint-mac.sh
sed -i "s|^DOWNLOAD_SHA256=\"[^\"]*\"|DOWNLOAD_SHA256=\"${MAC_SHA}\"|" install-scripts/install-endpoint-mac.sh
sed -i "s|${OLD}/EndpointProtection.msi|${NEW}/EndpointProtection.msi|" install-scripts/install-endpoint-windows.ps1
sed -i 's|^\$DownloadSha256 = "[^"]*"|\$DownloadSha256 = "'"${WIN_SHA}"'"|' install-scripts/install-endpoint-windows.ps1
- name: Open PR
if: steps.latest.outputs.version != steps.current.outputs.version
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
run: |
NEW="${{ steps.latest.outputs.version }}"
OLD="${{ steps.current.outputs.version }}"
BRANCH="bump/endpoint-${NEW}"
if git ls-remote --exit-code --heads origin "$BRANCH" &>/dev/null; then
echo "Branch $BRANCH already exists, skipping."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add install-scripts/install-endpoint-mac.sh install-scripts/install-endpoint-windows.ps1
git commit -m "Bump Endpoint to ${NEW}"
git push origin "$BRANCH"
PR_URL="https://github.com/${{ github.repository }}/compare/main...${BRANCH}?expand=1"
curl -s -X POST "$SLACK_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d "{\"text\": \"update to ${NEW} - ${PR_URL}\"}"

View file

@ -77,7 +77,7 @@ jobs:
- node_version: "20"
npm_version: "9.0.0"
yarn_version: "latest"
pnpm_version: "latest"
pnpm_version: "10.0.0"
# Version pinning scenario
- node_version: "22"
npm_version: "10.2.0"
@ -87,7 +87,7 @@ jobs:
- node_version: "18"
npm_version: "latest"
yarn_version: "latest"
pnpm_version: "latest"
pnpm_version: "10.0.0"
# Future compatibility (becomes LTS October 2025)
- node_version: "24"
npm_version: "latest"

View file

@ -10,6 +10,14 @@
- ✅ **Blocks packages newer than 48 hours** without breaking your build
- ✅ **Tokenless, free, no build data shared**
## Need protection beyond npm & PyPI?
[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).
---
Aikido Safe Chain supports the following package managers:
- 📦 **npm**
@ -17,6 +25,8 @@ Aikido Safe Chain supports the following package managers:
- 📦 **yarn**
- 📦 **pnpm**
- 📦 **pnpx**
- 📦 **rush**
- 📦 **rushx**
- 📦 **bun**
- 📦 **bunx**
- 📦 **pip**
@ -25,6 +35,7 @@ Aikido Safe Chain supports the following package managers:
- 📦 **poetry**
- 📦 **uvx**
- 📦 **pipx**
- 📦 **pdm**
# Usage
@ -67,7 +78,7 @@ You can find all available versions on the [releases page](https://github.com/Ai
### Verify the installation
1. **❗Restart your terminal** to start using the Aikido Safe Chain.
- This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, poetry, uv, uvx and pipx are loaded correctly. If you do not restart your terminal, the aliases will not be available.
- This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, pip, pip3, poetry, uv, uvx, pipx and pdm are loaded correctly. If you do not restart your terminal, the aliases will not be available.
2. **Verify the installation** by running the verification command:
@ -98,7 +109,7 @@ You can find all available versions on the [releases page](https://github.com/Ai
- The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware.
When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command.
When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry`, `pipx` and `pdm` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command.
You can check the installed version by running:
@ -110,7 +121,7 @@ safe-chain --version
### Malware Blocking
The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, uv, uvx, poetry or pipx commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine.
The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, pip, pip3, uv, uvx, poetry, pipx or pdm commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine.
### Minimum package age
@ -129,7 +140,7 @@ By default, the minimum package age is 48 hours. This provides an additional sec
### Shell Integration
The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and Python package managers (pip, uv, uvx, poetry, pipx). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support:
The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, and Python package managers (pip, uv, uvx, poetry, pipx, pdm). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support:
- ✅ **Bash**
- ✅ **Zsh**
@ -282,6 +293,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.
@ -463,7 +480,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
```
@ -534,4 +551,16 @@ npm-ci:
# Troubleshooting
Having issues? See the [Troubleshooting Guide](https://help.aikido.dev/code-scanning/aikido-malware-scanning/safe-chain-troubleshooting) for help with common problems.
Having issues? See the [Troubleshooting Guide](./docs/troubleshooting) for help with common problems.
# Report Issues
If you encounter problems:
1. Visit [GitHub Issues](https://github.com/AikidoSec/safe-chain/issues)
2. Include:
* Operating system and version
* Shell type and version
* `safe-chain --version` output
* Output from verification commands
* Verbose logs of the failing command (add the `--safe-chain-logging=verbose` argument)

View file

@ -2,7 +2,7 @@
## Overview
The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry`, `pipx`) with Aikido's security scanning functionality. It also intercepts Python module invocations for pip when available: `python -m pip`, `python -m pip3`, `python3 -m pip`, `python3 -m pip3`. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents.
The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry`, `pipx`) with Aikido's security scanning functionality. It also intercepts Python module invocations for pip when available: `python -m pip`, `python -m pip3`, `python3 -m pip`, `python3 -m pip3`. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents.
## Supported Shells
@ -28,7 +28,7 @@ This command:
- Copies necessary startup scripts to Safe Chain's installation directory (`~/.safe-chain/scripts`)
- Detects all supported shells on your system
- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx`
- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx`
- Adds lightweight interceptors so `python -m pip[...]` and `python3 -m pip[...]` route through Safe Chain when invoked by name
❗ After running this command, **you must restart your terminal** for the changes to take effect. This ensures that the startup scripts are sourced correctly.
@ -78,7 +78,7 @@ The system modifies the following files to source Safe Chain startup scripts:
This means the shell functions are working but the Aikido commands aren't installed or available in your PATH:
- Make sure Aikido Safe Chain is properly installed on your system
- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-bun`, `aikido-bunx`, `aikido-pip`, `aikido-pip3`, `aikido-uv`, `aikido-uvx`, `aikido-poetry` and `aikido-pipx` commands exist
- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-rush`, `aikido-rushx`, `aikido-bun`, `aikido-bunx`, `aikido-pip`, `aikido-pip3`, `aikido-uv`, `aikido-uvx`, `aikido-poetry` and `aikido-pipx` commands exist
- Check that these commands are in your system's PATH
### Manual Verification
@ -121,7 +121,7 @@ npm() {
}
```
Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes.
Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes.
To intercept Python module invocations for pip without altering Python itself, you can add small forwarding functions:

View file

@ -4,49 +4,38 @@ This guide helps you diagnose and resolve common issues with Aikido Safe Chain.
## Verification & Diagnostics
### Check Installation
**Check Installation**
```bash
# Check version
safe-chain --version
```
### Verify Shell Integration
**Verify Shell Integration**
Run the verification command for your package manager:
```bash
npm safe-chain-verify
pnpm safe-chain-verify
pip safe-chain-verify
uv safe-chain-verify
# Any other supported package manager: {packagemanager} safe-chain-verify
```
```
Expected output: `OK: Safe-chain works!`
```
### Test Malware Blocking
**Test Malware Blocking**
Verify that malware detection is working:
**For JavaScript/Node.js:**
```bash
npm install safe-chain-test
```
**For Python:**
```bash
pip3 install safe-chain-pi-test
npm install safe-chain-test
```
These test packages are flagged as malware and should be blocked by Safe Chain.
**If the test package installs successfully instead of being blocked**, see [Malware Not Being Blocked](#malware-not-being-blocked) below.
**If the test package installs successfully instead of being blocked**, see Malware Not Being Blocked below.
### Logging Options
## Logging Options
Use logging flags or environment variables to get more information:
@ -74,41 +63,39 @@ Safe-chain blocks malicious packages by intercepting network requests to package
When a package is already cached locally, the package manager skips downloading it from the registry, which bypasses the proxy.
**Resolution Steps:**
**Resolution Steps**
1. **Clear your package manager's cache:**
1) Clear your package manager's cache
```bash
# For npm
npm cache clean --force
```bash
# For npm
npm cache clean --force
# For pnpm
pnpm store prune
# For pnpm
pnpm store prune
# For yarn (classic)
yarn cache clean
# For yarn (classic)
yarn cache clean
# For yarn (berry/v2+)
yarn cache clean --all
# For yarn (berry/v2+)
yarn cache clean --all
# For bun
bun pm cache rm
```
# For bun
bun pm cache rm
```
> **⚠️ Warning:** Cache clearing is safe but will remove all cached packages. Subsequent installations will need to re-download packages. In CI/CD environments or monorepos, this may affect build times.
2) Clean local installation artifacts:
2. **Clean local installation artifacts:**
```bash
# Remove node_modules if you want a completely fresh install
rm -rf node_modules
```
```bash
# Remove node_modules if you want a completely fresh install
rm -rf node_modules
```
3) Re-test malware blocking:
3. **Re-test malware blocking:**
```bash
npm install safe-chain-test # Should be blocked
```
```bash
npm install safe-chain-test # Should be blocked
```
### Shell Aliases Not Working After Installation
@ -128,10 +115,10 @@ Should show: `npm is a function`
Check that your startup file sources safe-chain scripts from `~/.safe-chain/scripts/`:
- Bash: `~/.bashrc`
- Zsh: `~/.zshrc`
- Fish: `~/.config/fish/config.fish`
- PowerShell: `$PROFILE`
* Bash: `~/.bashrc`
* Zsh: `~/.zshrc`
* Fish: `~/.config/fish/config.fish`
* PowerShell: `$PROFILE`
### "Command Not Found: safe-chain"
@ -162,37 +149,39 @@ FullyQualifiedErrorId : UnauthorizedAccess
**Cause:** Windows PowerShell's default execution policy (`Restricted`) blocks all script execution, including safe-chain's initialization script that's sourced from your PowerShell profile.
**Resolution:**
**Resolution**
1. **Set the execution policy to allow local scripts:**
1) Set the execution policy to allow local scripts
Open PowerShell as Administrator and run:
Open PowerShell as Administrator and run:
```powershell
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned
```
```powershell
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned
```
This allows:
- Local scripts (like safe-chain's) to run without signing
- Downloaded scripts to run only if signed by a trusted publisher
This allows:
2. **Restart PowerShell** and verify the error is resolved.
* Local scripts (like safe-chain's) to run without signing
* Downloaded scripts to run only if signed by a trusted publisher
> **Note:** `RemoteSigned` is Microsoft's recommended execution policy for client computers. It provides a good balance between security and usability.
2) Restart PowerShell and verify the error is resolved.
> [!IMPORTANT]
> `RemoteSigned` is Microsoft's recommended execution policy for client computers. It provides a good balance between security and usability.
### Shell Aliases Persist After Uninstallation
**Symptom:** safe-chain commands still active after running uninstall script
**Steps:**
**Steps**
1. Run `safe-chain teardown` (if binary still exists)
2. Restart your terminal
3. If still present, manually edit shell config files:
- Bash: `~/.bashrc`
- Zsh: `~/.zshrc`
- Fish: `~/.config/fish/config.fish`
- PowerShell: `$PROFILE`
* Bash: `~/.bashrc`
* Zsh: `~/.zshrc`
* Fish: `~/.config/fish/config.fish`
* PowerShell: `$PROFILE`
4. Remove lines that source scripts from `~/.safe-chain/scripts/`
5. Restart terminal again
@ -217,10 +206,10 @@ type pip
**Expected `which` output:**
- Standalone binary (correct): `~/.safe-chain/bin/safe-chain` or `/Users/<username>/.safe-chain/bin/safe-chain`
- npm global (outdated): path containing `node_modules` or nvm version paths
* Standalone binary (correct): `~/.safe-chain/bin/safe-chain` or `/Users/<username>/.safe-chain/bin/safe-chain`
* npm global (outdated): path containing `node_modules` or nvm version paths
If `which` shows an npm installation, see [Check for Conflicting Installations](#check-for-conflicting-installations).
If `which` shows an npm installation, see Check for Conflicting Installations.
### Check Shell Integration
@ -259,23 +248,23 @@ for version in $(nvm list | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+'); do
done
```
## Manual Cleanup
### Manual Cleanup
> **Note:** The install and uninstall scripts automatically handle these cleanup steps. Use these manual commands only if automatic cleanup fails.
### Remove npm Global Installation
#### Remove npm Global Installation
```bash
npm uninstall -g @aikidosec/safe-chain
```
### Remove Volta Installation
#### Remove Volta Installation
```bash
volta uninstall @aikidosec/safe-chain
```
### Remove nvm Installations (All Versions)
#### Remove nvm Installations (All Versions)
```bash
# Automated approach
@ -288,34 +277,22 @@ nvm use <version>
npm uninstall -g @aikidosec/safe-chain
```
### Clean Shell Configuration Files
#### Clean Shell Configuration Files
Manually remove safe-chain entries from:
- Bash: `~/.bashrc`
- Zsh: `~/.zshrc`
- Fish: `~/.config/fish/config.fish`
- PowerShell: `$PROFILE`
* Bash: `~/.bashrc`
* Zsh: `~/.zshrc`
* Fish: `~/.config/fish/config.fish`
* PowerShell: `$PROFILE`
Look for and remove:
- Lines sourcing from `~/.safe-chain/scripts/`
- Any safe-chain related function definitions
* Lines sourcing from `~/.safe-chain/scripts/`
* Any safe-chain related function definitions
### Remove Installation Directory
#### Remove Installation Directory
```bash
rm -rf ~/.safe-chain
```
### Report Issues
If you encounter problems:
1. Visit [GitHub Issues](https://github.com/AikidoSec/safe-chain/issues)
2. Include:
- Operating system and version
- Shell type and version
- `safe-chain --version` output
- Output from verification commands
- Verbose logs of the failing command (add the `--safe-chain-logging=verbose` argument)

View file

@ -7,8 +7,8 @@
set -e # Exit on error
# Configuration
INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.16/EndpointProtection.pkg"
DOWNLOAD_SHA256="6c185d247093533e44c1547c10e32bed899b6313b51d8bf74bcf3ddc08d8d824"
INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.5.4/EndpointProtection.pkg"
DOWNLOAD_SHA256="ad800f9e476b0a75bf32b1c079f060ecb98bc16972a4e8cca29cf165388ea9fe"
TOKEN_FILE="/tmp/aikido_endpoint_token.txt"
# Colors for output

View file

@ -7,8 +7,8 @@ param(
)
# Configuration
$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.16/EndpointProtection.msi"
$DownloadSha256 = "5284c7a8078a02439733b02f66158ac6a7cb09bbb9fba38ec2ff8d98b494e637"
$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.5.4/EndpointProtection.msi"
$DownloadSha256 = "e2750c59124f53456a8f9cdb9e81fd9ce2f2491869f68f01602444ad519be5be"
# Ensure TLS 1.2 is enabled for downloads
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

View file

@ -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 {

View file

@ -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"

View 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'

4
npm-shrinkwrap.json generated
View file

@ -2417,7 +2417,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -3130,6 +3129,7 @@
"aikido-bunx": "bin/aikido-bunx.js",
"aikido-npm": "bin/aikido-npm.js",
"aikido-npx": "bin/aikido-npx.js",
"aikido-pdm": "bin/aikido-pdm.js",
"aikido-pip": "bin/aikido-pip.js",
"aikido-pip3": "bin/aikido-pip3.js",
"aikido-pipx": "bin/aikido-pipx.js",
@ -3138,6 +3138,8 @@
"aikido-poetry": "bin/aikido-poetry.js",
"aikido-python": "bin/aikido-python.js",
"aikido-python3": "bin/aikido-python3.js",
"aikido-rush": "bin/aikido-rush.js",
"aikido-rushx": "bin/aikido-rushx.js",
"aikido-uv": "bin/aikido-uv.js",
"aikido-uvx": "bin/aikido-uvx.js",
"aikido-yarn": "bin/aikido-yarn.js",

View file

@ -0,0 +1,13 @@
#!/usr/bin/env node
import { main } from "../src/main.js";
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js";
setEcoSystem(ECOSYSTEM_PY);
initializePackageManager("pdm");
(async () => {
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

View file

@ -0,0 +1,14 @@
#!/usr/bin/env node
import { main } from "../src/main.js";
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js";
setEcoSystem(ECOSYSTEM_JS);
const packageManagerName = "rush";
initializePackageManager(packageManagerName);
(async () => {
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

View file

@ -0,0 +1,14 @@
#!/usr/bin/env node
import { main } from "../src/main.js";
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
import { setEcoSystem, ECOSYSTEM_JS } from "../src/config/settings.js";
setEcoSystem(ECOSYSTEM_JS);
const packageManagerName = "rushx";
initializePackageManager(packageManagerName);
(async () => {
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

View file

@ -1,5 +1,11 @@
#!/usr/bin/env node
// Strip PKG_EXECPATH from the environment so any child process safe-chain
// spawns (npm, uv, pip, …) doesn't inherit it. If it leaks into a subsequent
// safe-chain invocation (e.g. via a shim) the yao-pkg bootstrap would treat
// argv[1] as a script path and fail with MODULE_NOT_FOUND.
delete process.env.PKG_EXECPATH;
import chalk from "chalk";
import { ui } from "../src/environment/userInteraction.js";
import { setup } from "../src/shell-integration/setup.js";
@ -15,7 +21,7 @@ import { main } from "../src/main.js";
import path from "path";
import { fileURLToPath } from "url";
import fs from "fs";
import { knownAikidoTools } from "../src/shell-integration/helpers.js";
import { knownAikidoTools, getPackageManagerList } from "../src/shell-integration/helpers.js";
import { getInstalledSafeChainDir } from "../src/installLocation.js";
/** @type {string} */
@ -108,7 +114,7 @@ function writeHelp() {
ui.writeInformation(
`- ${chalk.cyan(
"safe-chain setup",
)}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, bun, bunx, pip and pip3.`,
)}: This will setup your shell to wrap safe-chain around ${getPackageManagerList()}.`,
);
ui.writeInformation(
`- ${chalk.cyan(

View file

@ -13,6 +13,8 @@
"aikido-yarn": "bin/aikido-yarn.js",
"aikido-pnpm": "bin/aikido-pnpm.js",
"aikido-pnpx": "bin/aikido-pnpx.js",
"aikido-rush": "bin/aikido-rush.js",
"aikido-rushx": "bin/aikido-rushx.js",
"aikido-bun": "bin/aikido-bun.js",
"aikido-bunx": "bin/aikido-bunx.js",
"aikido-uv": "bin/aikido-uv.js",
@ -23,6 +25,7 @@
"aikido-python3": "bin/aikido-python3.js",
"aikido-poetry": "bin/aikido-poetry.js",
"aikido-pipx": "bin/aikido-pipx.js",
"aikido-pdm": "bin/aikido-pdm.js",
"safe-chain": "bin/safe-chain.js"
},
"type": "module",
@ -37,7 +40,7 @@
"keywords": [],
"author": "Aikido Security",
"license": "AGPL-3.0-or-later",
"description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), and [pip](https://pip.pypa.io/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, bunx, uv, uvx, or pip/pip3 from downloading or running the malware.",
"description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [rush](https://rushjs.io/), [rushx](https://rushjs.io/pages/commands/rushx/), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), [pip](https://pip.pypa.io/), and [pdm](https://pdm-project.org/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, uv, uvx, pip/pip3, or pdm from downloading or running the malware.",
"dependencies": {
"certifi": "14.5.15",
"chalk": "5.4.1",

View file

@ -13,6 +13,9 @@ import { createPipPackageManager } from "./pip/createPackageManager.js";
import { createUvPackageManager } from "./uv/createUvPackageManager.js";
import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js";
import { createPipXPackageManager } from "./pipx/createPipXPackageManager.js";
import { createPdmPackageManager } from "./pdm/createPdmPackageManager.js";
import { createRushPackageManager } from "./rush/createRushPackageManager.js";
import { createRushxPackageManager } from "./rushx/createRushxPackageManager.js";
import { createUvxPackageManager } from "./uvx/createUvxPackageManager.js";
/**
@ -67,6 +70,12 @@ export function initializePackageManager(packageManagerName, context) {
state.packageManagerName = createPoetryPackageManager();
} else if (packageManagerName === "pipx") {
state.packageManagerName = createPipXPackageManager();
} else if (packageManagerName === "pdm") {
state.packageManagerName = createPdmPackageManager();
} else if (packageManagerName === "rush") {
state.packageManagerName = createRushPackageManager();
} else if (packageManagerName === "rushx") {
state.packageManagerName = createRushxPackageManager();
} else {
throw new Error("Unsupported package manager: " + packageManagerName);
}

View file

@ -0,0 +1,72 @@
import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/**
* @returns {import("../currentPackageManager.js").PackageManager}
*/
export function createPdmPackageManager() {
return {
runCommand: (args) => runPdmCommand(args),
// MITM only approach for PDM
isSupportedCommand: () => false,
getDependencyUpdatesForCommand: () => [],
};
}
/**
* Sets CA bundle environment variables used by PDM and Python libraries.
* PDM uses httpx (via unearth) which respects SSL_CERT_FILE through Python's ssl module.
*
* @param {NodeJS.ProcessEnv} env - Environment object to modify
* @param {string} combinedCaPath - Path to the combined CA bundle
*/
function setPdmCaBundleEnvironmentVariables(env, combinedCaPath) {
// SSL_CERT_FILE: Used by Python SSL libraries and httpx (which PDM uses)
if (env.SSL_CERT_FILE) {
ui.writeWarning("Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten.");
}
env.SSL_CERT_FILE = combinedCaPath;
// REQUESTS_CA_BUNDLE: Used by the requests library (PDM plugins may use it)
if (env.REQUESTS_CA_BUNDLE) {
ui.writeWarning("Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten.");
}
env.REQUESTS_CA_BUNDLE = combinedCaPath;
// PIP_CERT: PDM may use pip internally
if (env.PIP_CERT) {
ui.writeWarning("Safe-chain: User defined PIP_CERT found in environment. It will be overwritten.");
}
env.PIP_CERT = combinedCaPath;
}
/**
* Runs a pdm command with safe-chain's certificate bundle and proxy configuration.
*
* PDM respects standard HTTP_PROXY/HTTPS_PROXY environment variables through
* httpx which it uses for package downloads.
*
* @param {string[]} args - Command line arguments to pass to pdm
* @returns {Promise<{status: number}>} Exit status of the pdm command
*/
async function runPdmCommand(args) {
try {
const env = mergeSafeChainProxyEnvironmentVariables(process.env);
const combinedCaPath = getCombinedCaBundlePath();
setPdmCaBundleEnvironmentVariables(env, combinedCaPath);
const result = await safeSpawn("pdm", args, {
stdio: "inherit",
env,
});
return { status: result.status };
} catch (/** @type any */ error) {
return reportCommandExecutionFailure(error, "pdm");
}
}

View file

@ -0,0 +1,14 @@
import { test } from "node:test";
import assert from "node:assert";
import { createPdmPackageManager } from "./createPdmPackageManager.js";
test("createPdmPackageManager", async (t) => {
await t.test("should create package manager with required interface", () => {
const pm = createPdmPackageManager();
assert.ok(pm);
assert.strictEqual(typeof pm.runCommand, "function");
assert.strictEqual(typeof pm.isSupportedCommand, "function");
assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function");
});
});

View file

@ -0,0 +1,64 @@
import { runRushCommand } from "./runRushCommand.js";
import { resolvePackageVersion } from "../../api/npmApi.js";
import { parsePackagesFromRushAddArgs } from "./parsing/parsePackagesFromRushAddArgs.js";
/**
* @returns {import("../currentPackageManager.js").PackageManager}
*/
export function createRushPackageManager() {
return {
runCommand: (args) => runRushCommand("rush", args),
// We pre-scan rush add commands and rely on MITM for install/update flows.
isSupportedCommand: (args) => getRushCommand(args) === "add",
getDependencyUpdatesForCommand: scanRushAddCommand,
};
}
/**
* @param {string[]} args
* @returns {Promise<import("../currentPackageManager.js").GetDependencyUpdatesResult[]>}
*/
async function scanRushAddCommand(args) {
if (getRushCommand(args) !== "add") {
return [];
}
const parsedSpecs = parsePackagesFromRushAddArgs(args.slice(1));
const resolvedVersions = await Promise.all(
parsedSpecs.map(async (parsed) => {
const exactVersion = await resolvePackageVersion(parsed.name, parsed.version);
return {
parsed,
exactVersion,
};
}),
);
const changes = [];
for (const resolved of resolvedVersions) {
if (!resolved.exactVersion) {
continue;
}
changes.push({
name: resolved.parsed.name,
version: resolved.exactVersion,
type: "add",
});
}
return changes;
}
/**
* @param {string[]} args
* @returns {string | undefined}
*/
function getRushCommand(args) {
if (!args || args.length === 0) {
return undefined;
}
return args[0]?.toLowerCase();
}

View file

@ -0,0 +1,66 @@
import { test, mock } from "node:test";
import assert from "node:assert";
test("createRushPackageManager", async (t) => {
mock.module("../../api/npmApi.js", {
namedExports: {
resolvePackageVersion: async (name, version) => {
if (name === "safe-chain-test") {
return "0.0.1-security";
}
if (name === "@scope/tool") {
return version || "2.0.0";
}
return null;
},
},
});
try {
const { createRushPackageManager } = await import("./createRushPackageManager.js");
await t.test("should create package manager with required interface", () => {
const pm = createRushPackageManager();
assert.ok(pm);
assert.strictEqual(typeof pm.runCommand, "function");
assert.strictEqual(typeof pm.isSupportedCommand, "function");
assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function");
});
await t.test("should scan rush add commands", () => {
const pm = createRushPackageManager();
assert.strictEqual(pm.isSupportedCommand(["add", "--package", "safe-chain-test"]), true);
assert.strictEqual(pm.isSupportedCommand(["install"]), false);
});
await t.test("should parse rush add package specs and resolve versions", async () => {
const pm = createRushPackageManager();
const changes = await pm.getDependencyUpdatesForCommand([
"add",
"--package",
"safe-chain-test",
"--package=@scope/tool@1.2.3",
]);
assert.deepStrictEqual(changes, [
{ name: "safe-chain-test", version: "0.0.1-security", type: "add" },
{ name: "@scope/tool", version: "1.2.3", type: "add" },
]);
});
await t.test("should return no changes for non-add commands", async () => {
const pm = createRushPackageManager();
const changes = await pm.getDependencyUpdatesForCommand(["install"]);
assert.deepStrictEqual(changes, []);
});
} finally {
mock.reset();
}
});

View file

@ -0,0 +1,71 @@
/**
* @param {string[]} args
* @returns {{name: string, version: string | null}[]}
*/
export function parsePackagesFromRushAddArgs(args) {
const packageSpecs = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (!arg) {
continue;
}
if (arg === "--package" || arg === "-p") {
const next = args[i + 1];
if (next && !next.startsWith("-")) {
packageSpecs.push(next);
i += 1;
}
continue;
}
if (arg.startsWith("--package=")) {
const value = arg.slice("--package=".length);
if (value) {
packageSpecs.push(value);
}
}
}
return packageSpecs
.map((spec) => parsePackageSpec(spec))
.filter((spec) => spec !== null);
}
/**
* @param {string} spec
* @returns {{name: string, version: string | null} | null}
*/
function parsePackageSpec(spec) {
const value = removeAlias(spec.trim());
if (!value) {
return null;
}
const lastAtIndex = value.lastIndexOf("@");
if (lastAtIndex > 0) {
return {
name: value.slice(0, lastAtIndex),
version: value.slice(lastAtIndex + 1),
};
}
return {
name: value,
version: null,
};
}
/**
* @param {string} spec
* @returns {string}
*/
function removeAlias(spec) {
const aliasIndex = spec.indexOf("@npm:");
if (aliasIndex !== -1) {
return spec.slice(aliasIndex + 5);
}
return spec;
}

View file

@ -0,0 +1,49 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { parsePackagesFromRushAddArgs } from "./parsePackagesFromRushAddArgs.js";
describe("parsePackagesFromRushAddArgs", () => {
it("returns an empty array when no packages are provided", () => {
const result = parsePackagesFromRushAddArgs([]);
assert.deepEqual(result, []);
});
it("parses packages from --package arguments", () => {
const result = parsePackagesFromRushAddArgs([
"--package",
"axios@1.9.0",
"--package",
"@scope/tool@2.0.0",
]);
assert.deepEqual(result, [
{ name: "axios", version: "1.9.0" },
{ name: "@scope/tool", version: "2.0.0" },
]);
});
it("parses packages from -p arguments", () => {
const result = parsePackagesFromRushAddArgs(["-p", "axios"]);
assert.deepEqual(result, [{ name: "axios", version: null }]);
});
it("parses packages from --package=value arguments", () => {
const result = parsePackagesFromRushAddArgs(["--package=axios@^1.9.0"]);
assert.deepEqual(result, [{ name: "axios", version: "^1.9.0" }]);
});
it("ignores positional packages because rush add requires --package", () => {
const result = parsePackagesFromRushAddArgs(["axios", "--dev"]);
assert.deepEqual(result, []);
});
it("parses aliases", () => {
const result = parsePackagesFromRushAddArgs(["--package", "server@npm:axios@1.9.0"]);
assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]);
});
});

View file

@ -0,0 +1,21 @@
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { safeSpawn } from "../../utils/safeSpawn.js";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/**
* @param {"rush" | "rushx"} executableName
* @param {string[]} args
* @returns {Promise<{status: number}>}
*/
export async function runRushCommand(executableName, args) {
try {
const result = await safeSpawn(executableName, args, {
stdio: "inherit",
env: mergeSafeChainProxyEnvironmentVariables(process.env),
});
return { status: result.status };
} catch (/** @type any */ error) {
return reportCommandExecutionFailure(error, executableName);
}
}

View file

@ -0,0 +1,109 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
describe("runRushCommand", () => {
let runRushCommand;
let safeSpawnMock;
let mergeCalls;
let mergeResultEnv;
let nextSpawnStatus;
let nextSpawnError;
beforeEach(async () => {
mergeCalls = [];
mergeResultEnv = null;
nextSpawnStatus = 0;
nextSpawnError = null;
safeSpawnMock = mock.fn(async () => {
if (nextSpawnError) {
const error = nextSpawnError;
nextSpawnError = null;
throw error;
}
return { status: nextSpawnStatus };
});
mock.module("../../utils/safeSpawn.js", {
namedExports: {
safeSpawn: safeSpawnMock,
},
});
mock.module("../../registryProxy/registryProxy.js", {
namedExports: {
mergeSafeChainProxyEnvironmentVariables: (env) => {
mergeCalls.push(env);
if (mergeResultEnv) {
return mergeResultEnv;
}
return {
...env,
HTTPS_PROXY: "http://localhost:8080",
};
},
},
});
// commandErrors reports through ui on failures, so provide a no-op mock
mock.module("../../environment/userInteraction.js", {
namedExports: {
ui: {
writeError: () => {},
},
},
});
const mod = await import("./runRushCommand.js");
runRushCommand = mod.runRushCommand;
});
afterEach(() => {
mock.reset();
});
it("spawns rush with merged proxy env", async () => {
const res = await runRushCommand("rush", ["install"]);
assert.strictEqual(res.status, 0);
assert.strictEqual(safeSpawnMock.mock.calls.length, 1);
const [command, args, options] = safeSpawnMock.mock.calls[0].arguments;
assert.strictEqual(command, "rush");
assert.deepStrictEqual(args, ["install"]);
assert.strictEqual(options.stdio, "inherit");
assert.strictEqual(options.env.HTTPS_PROXY, "http://localhost:8080");
assert.ok(mergeCalls.length >= 1, "proxy env merge should be called");
});
it("returns spawn result status", async () => {
nextSpawnStatus = 7;
const res = await runRushCommand("rush", ["update"]);
assert.strictEqual(res.status, 7);
});
it("reports failures with rush target", async () => {
nextSpawnError = Object.assign(new Error("spawn failed"), {
code: "ENOENT",
});
const res = await runRushCommand("rush", ["install"]);
assert.strictEqual(res.status, 1);
});
it("does not mutate merged env object", async () => {
mergeResultEnv = {
HTTPS_PROXY: "http://localhost:8080",
};
await runRushCommand("rush", ["install"]);
assert.deepStrictEqual(mergeResultEnv, {
HTTPS_PROXY: "http://localhost:8080",
});
});
});

View file

@ -0,0 +1,18 @@
import { runRushCommand } from "../rush/runRushCommand.js";
/**
* @returns {import("../currentPackageManager.js").PackageManager}
*/
export function createRushxPackageManager() {
return {
/**
* @param {string[]} args
*/
runCommand: (args) => {
return runRushCommand("rushx", args);
},
// For rushx, rely solely on MITM.
isSupportedCommand: () => false,
getDependencyUpdatesForCommand: () => [],
};
}

View file

@ -0,0 +1,14 @@
import { test } from "node:test";
import assert from "node:assert";
import { createRushxPackageManager } from "./createRushxPackageManager.js";
test("createRushxPackageManager returns valid package manager interface", () => {
const pm = createRushxPackageManager();
assert.ok(pm);
assert.strictEqual(typeof pm.runCommand, "function");
assert.strictEqual(typeof pm.isSupportedCommand, "function");
assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function");
assert.strictEqual(pm.isSupportedCommand(), false);
assert.deepStrictEqual(pm.getDependencyUpdatesForCommand(), []);
});

View file

@ -6,6 +6,23 @@ export { parsePipMetadataUrl, isPipPackageInfoUrl } from "./parsePipPackageUrl.j
import { getPipMetadataContentType, logSuppressedVersion } from "./pipMetadataResponseUtils.js";
import { modifyPipJsonResponse } from "./modifyPipJsonResponse.js";
/**
* Strip conditional GET headers so PyPI always returns a full 200 response
* with a body we can rewrite. Without this, pip sends If-None-Match /
* If-Modified-Since, PyPI responds 304 Not Modified (empty body), and
* safe-chain cannot rewrite it leaving pip with a cached index that still
* lists too-young versions. Those versions are then blocked at direct-download
* time with a hard 403, preventing dependency resolution from completing.
*
* @param {NodeJS.Dict<string | string[]>} headers
* @returns {NodeJS.Dict<string | string[]>}
*/
export function modifyPipInfoRequestHeaders(headers) {
delete headers["if-none-match"];
delete headers["if-modified-since"];
return headers;
}
// 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 =

View file

@ -9,6 +9,7 @@ import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache.
import { interceptRequests } from "../interceptorBuilder.js";
import { isExcludedFromMinimumPackageAge } from "../minimumPackageAgeExclusions.js";
import {
modifyPipInfoRequestHeaders,
modifyPipInfoResponse,
parsePipMetadataUrl,
} from "./modifyPipInfo.js";
@ -61,6 +62,7 @@ function createPipRequestHandler(registry) {
!isExcludedFromMinimumPackageAge(metadataPackageName)
) {
const newPackagesDatabase = await openNewPackagesDatabase();
reqContext.modifyRequestHeaders(modifyPipInfoRequestHeaders);
reqContext.modifyBody((body, headers) =>
modifyPipInfoResponse(
body,

View file

@ -129,6 +129,28 @@ describe("pipInterceptor minimum package age", async () => {
newlyReleasedPackageResponse = false;
});
it("strips If-None-Match and If-Modified-Since from metadata requests to prevent 304 cache bypass", async () => {
const url = "https://pypi.org/simple/foo-bar/";
newlyReleasedPackageResponse = true;
const interceptor = pipInterceptorForUrl(url);
const result = await interceptor.handleRequest(url);
const headers = {
"if-none-match": '"some-etag"',
"if-modified-since": "Thu, 01 Jan 2026 00:00:00 GMT",
accept: "*/*",
};
result.modifyRequestHeaders(headers);
assert.equal(headers["if-none-match"], undefined, "If-None-Match must be stripped");
assert.equal(headers["if-modified-since"], undefined, "If-Modified-Since must be stripped");
assert.equal(headers.accept, "*/*", "unrelated headers must be preserved");
newlyReleasedPackageResponse = false;
});
it("should not block newly released package downloads when a dot-name package matches a hyphen exclusion", async () => {
const url =
"https://files.pythonhosted.org/packages/xx/yy/foo.bar-2.0.0.tar.gz";

View file

@ -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;

View file

@ -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();
});
});
}
});
});

View file

@ -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,45 +38,44 @@ function normalizePackageName(name) {
return name;
}
export async function openMalwareDatabase() {
if (cachedMalwareDatabase) {
return cachedMalwareDatabase;
}
export function openMalwareDatabase() {
if (!cachedMalwareDatabasePromise) {
cachedMalwareDatabasePromise = getMalwareDatabase().then((malwareDatabase) => {
/**
* @param {string} name
* @param {string} version
* @returns {string}
*/
function getPackageStatus(name, version) {
const normalizedName = normalizePackageName(name);
const packageData = malwareDatabase.find(
(pkg) => {
const normalizedPkgName = normalizePackageName(pkg.package_name);
return normalizedPkgName === normalizedName &&
(pkg.version === version || pkg.version === "*");
}
);
const malwareDatabase = await getMalwareDatabase();
if (!packageData) {
return MALWARE_STATUS_OK;
}
/**
* @param {string} name
* @param {string} version
* @returns {string}
*/
function getPackageStatus(name, version) {
const normalizedName = normalizePackageName(name);
const packageData = malwareDatabase.find(
(pkg) => {
const normalizedPkgName = normalizePackageName(pkg.package_name);
return normalizedPkgName === normalizedName &&
(pkg.version === version || pkg.version === "*");
return packageData.reason;
}
);
if (!packageData) {
return MALWARE_STATUS_OK;
}
return packageData.reason;
return {
getPackageStatus,
isMalware: (/** @type {string} */ name, /** @type {string} */ version) => {
const status = getPackageStatus(name, version);
return isMalwareStatus(status);
},
};
}).catch((error) => {
cachedMalwareDatabasePromise = null;
throw error;
});
}
// This implicitly caches the malware database
// that's closed over by the getPackageStatus function
cachedMalwareDatabase = {
getPackageStatus,
isMalware: (name, version) => {
const status = getPackageStatus(name, version);
return isMalwareStatus(status);
},
};
return cachedMalwareDatabase;
return cachedMalwareDatabasePromise;
}
/**

View file

@ -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;
export function openNewPackagesDatabase() {
if (!cachedNewPackagesDatabasePromise) {
cachedNewPackagesDatabasePromise = getNewPackagesList()
.then((newPackagesList) => buildNewPackagesDatabase(newPackagesList))
.catch((/** @type {any} */ error) => {
warnOnceAboutUnavailableDatabase(error);
cachedNewPackagesDatabasePromise = null;
return { isNewlyReleasedPackage: () => false };
});
}
/** @type {import("../api/aikido.js").NewPackageEntry[]} */
let newPackagesList;
try {
newPackagesList = await getNewPackagesList();
} catch (/** @type {any} */ error) {
warnOnceAboutUnavailableDatabase(error);
cachedNewPackagesDatabase = { isNewlyReleasedPackage: () => false };
return cachedNewPackagesDatabase;
}
cachedNewPackagesDatabase = buildNewPackagesDatabase(newPackagesList);
return cachedNewPackagesDatabase;
return cachedNewPackagesDatabasePromise;
}
/**

View file

@ -48,6 +48,18 @@ export const knownAikidoTools = [
ecoSystem: ECOSYSTEM_JS,
internalPackageManagerName: "pnpx",
},
{
tool: "rush",
aikidoCommand: "aikido-rush",
ecoSystem: ECOSYSTEM_JS,
internalPackageManagerName: "rush",
},
{
tool: "rushx",
aikidoCommand: "aikido-rushx",
ecoSystem: ECOSYSTEM_JS,
internalPackageManagerName: "rushx",
},
{
tool: "bun",
aikidoCommand: "aikido-bun",
@ -108,6 +120,12 @@ export const knownAikidoTools = [
ecoSystem: ECOSYSTEM_PY,
internalPackageManagerName: "pipx",
},
{
tool: "pdm",
aikidoCommand: "aikido-pdm",
ecoSystem: ECOSYSTEM_PY,
internalPackageManagerName: "pdm",
},
// When adding a new tool here, also update the documentation for the new tool in the README.md
];

View file

@ -20,7 +20,10 @@ remove_shim_from_path() {
}
if command -v safe-chain >/dev/null 2>&1; then
# Remove shim directory from PATH when calling {{AIKIDO_COMMAND}} to prevent infinite loops
# Remove shim directory from PATH when calling {{AIKIDO_COMMAND}} to prevent infinite loops.
# Unset PKG_EXECPATH so the yao-pkg bootstrap inside the safe-chain binary doesn't
# mistake argv[1] for a script path and try to resolve "{{PACKAGE_MANAGER}}" against cwd.
unset PKG_EXECPATH
PATH=$(remove_shim_from_path) exec safe-chain {{PACKAGE_MANAGER}} "$@"
else
# safe-chain is not reachable — warn the user so they know protection is inactive

View file

@ -0,0 +1,60 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..", "..");
describe("PKG_EXECPATH cleanup", () => {
it("unix shim template unsets PKG_EXECPATH before invoking safe-chain", () => {
const file = path.join(
repoRoot,
"src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh",
);
const content = fs.readFileSync(file, "utf-8");
assert.match(
content,
/unset PKG_EXECPATH[\s\S]*exec safe-chain/,
"unix-wrapper.template.sh must `unset PKG_EXECPATH` before `exec safe-chain`",
);
});
it("posix shell function unsets PKG_EXECPATH before invoking safe-chain", () => {
const file = path.join(
repoRoot,
"src/shell-integration/startup-scripts/init-posix.sh",
);
const content = fs.readFileSync(file, "utf-8");
// Scoped subshell so we don't mutate the user's interactive env.
assert.match(
content,
/\(unset PKG_EXECPATH;\s*safe-chain "\$@"\)/,
"init-posix.sh must invoke safe-chain in a subshell that unsets PKG_EXECPATH",
);
});
it("fish shell function unsets PKG_EXECPATH before invoking safe-chain", () => {
const file = path.join(
repoRoot,
"src/shell-integration/startup-scripts/init-fish.fish",
);
const content = fs.readFileSync(file, "utf-8");
assert.match(
content,
/env -u PKG_EXECPATH safe-chain/,
"init-fish.fish must invoke safe-chain via `env -u PKG_EXECPATH`",
);
});
it("safe-chain entry point deletes PKG_EXECPATH from process.env", () => {
const file = path.join(repoRoot, "bin/safe-chain.js");
const content = fs.readFileSync(file, "utf-8");
assert.match(
content,
/delete process\.env\.PKG_EXECPATH/,
"bin/safe-chain.js must delete process.env.PKG_EXECPATH so spawned children don't inherit it",
);
});
});

View file

@ -19,6 +19,14 @@ function pnpx
wrapSafeChainCommand "pnpx" $argv
end
function rush
wrapSafeChainCommand "rush" $argv
end
function rushx
wrapSafeChainCommand "rushx" $argv
end
function bun
wrapSafeChainCommand "bun" $argv
end
@ -76,6 +84,10 @@ function pipx
wrapSafeChainCommand "pipx" $argv
end
function pdm
wrapSafeChainCommand "pdm" $argv
end
function printSafeChainWarning
set original_cmd $argv[1]
@ -112,8 +124,10 @@ function wrapSafeChainCommand
end
if type -q safe-chain
# If the safe-chain command is available, just run it with the provided arguments
safe-chain $original_cmd $cmd_args
# If the safe-chain command is available, just run it with the provided arguments.
# Unset PKG_EXECPATH for this invocation so the yao-pkg bootstrap inside the
# safe-chain binary doesn't mistake argv[1] for a script path to resolve against cwd.
env -u PKG_EXECPATH safe-chain $original_cmd $cmd_args
else
# If the safe-chain command is not available, print a warning and run the original command
printSafeChainWarning $original_cmd

View file

@ -28,6 +28,14 @@ function pnpx() {
wrapSafeChainCommand "pnpx" "$@"
}
function rush() {
wrapSafeChainCommand "rush" "$@"
}
function rushx() {
wrapSafeChainCommand "rushx" "$@"
}
function bun() {
wrapSafeChainCommand "bun" "$@"
}
@ -81,6 +89,10 @@ function pipx() {
wrapSafeChainCommand "pipx" "$@"
}
function pdm() {
wrapSafeChainCommand "pdm" "$@"
}
function printSafeChainWarning() {
# \033[43;30m is used to set the background color to yellow and text color to black
# \033[0m is used to reset the text formatting
@ -101,8 +113,10 @@ function wrapSafeChainCommand() {
fi
if command -v safe-chain > /dev/null 2>&1; then
# If the aikido command is available, just run it with the provided arguments
safe-chain "$@"
# If the aikido command is available, just run it with the provided arguments.
# Unset PKG_EXECPATH so the yao-pkg bootstrap inside the safe-chain binary doesn't
# mistake argv[1] for a script path and try to resolve it against cwd.
(unset PKG_EXECPATH; safe-chain "$@")
else
# If the aikido command is not available, print a warning and run the original command
printSafeChainWarning "$original_cmd"

View file

@ -22,6 +22,14 @@ function pnpx {
Invoke-WrappedCommand "pnpx" $args $MyInvocation.Line $MyInvocation.OffsetInLine
}
function rush {
Invoke-WrappedCommand "rush" $args $MyInvocation.Line $MyInvocation.OffsetInLine
}
function rushx {
Invoke-WrappedCommand "rushx" $args $MyInvocation.Line $MyInvocation.OffsetInLine
}
function bun {
Invoke-WrappedCommand "bun" $args $MyInvocation.Line $MyInvocation.OffsetInLine
}
@ -75,6 +83,10 @@ function pipx {
Invoke-WrappedCommand "pipx" $args $MyInvocation.Line $MyInvocation.OffsetInLine
}
function pdm {
Invoke-WrappedCommand "pdm" $args $MyInvocation.Line $MyInvocation.OffsetInLine
}
function Write-SafeChainWarning {
param([string]$Command)

View file

@ -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);

View file

@ -25,6 +25,7 @@ ARG NODE_VERSION=latest
ARG NPM_VERSION=latest
ARG YARN_VERSION=latest
ARG PNPM_VERSION=latest
ARG RUSH_VERSION=latest
ARG PYTHON_VERSION=3
SHELL ["/bin/bash", "-c"]
@ -46,6 +47,7 @@ RUN volta install node@${NODE_VERSION}
RUN volta install npm@${NPM_VERSION}
RUN volta install yarn@${YARN_VERSION}
RUN volta install pnpm@${PNPM_VERSION}
RUN volta install @microsoft/rush@${RUSH_VERSION}
# Install Bun
RUN curl -fsSL https://bun.sh/install | bash
@ -77,6 +79,10 @@ RUN apt-get update && apt-get install -y pipx && \
pipx install poetry && \
ln -sf /root/.local/bin/poetry /usr/local/bin/poetry
# Install PDM
RUN pipx install pdm && \
ln -sf /root/.local/bin/pdm /usr/local/bin/pdm
# Copy and install Safe chain
COPY --from=builder /app/*.tgz /pkgs/
RUN npm install -g /pkgs/*.tgz
@ -84,3 +90,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

View file

@ -46,8 +46,9 @@ describe("E2E: bun coverage", () => {
var result = await shell.runCommand("bun install");
assert.ok(
result.output.includes("blocked 1 malicious package downloads"),
assert.match(
result.output,
/blocked [1-9]\d* malicious package downloads/,
`Output did not include expected text. Output was:\n${result.output}`
);
assert.ok(
@ -65,8 +66,9 @@ describe("E2E: bun coverage", () => {
const result = await shell.runCommand("bunx safe-chain-test");
assert.ok(
result.output.includes("blocked 1 malicious package downloads"),
assert.match(
result.output,
/blocked [1-9]\d* malicious package downloads/,
`Output did not include expected text. Output was:\n${result.output}`
);
assert.ok(

View file

@ -70,8 +70,9 @@ describe("E2E: npm coverage", () => {
var result = await shell.runCommand("npm install");
assert.ok(
result.output.includes("blocked 1 malicious package downloads"),
assert.match(
result.output,
/blocked [1-9]\d* malicious package downloads/,
`Output did not include expected text. Output was:\n${result.output}`
);
assert.ok(

300
test/e2e/pdm.e2e.spec.js Normal file
View file

@ -0,0 +1,300 @@
import { describe, it, before, beforeEach, afterEach } from "node:test";
import { DockerTestContainer } from "./DockerTestContainer.js";
import assert from "node:assert";
describe("E2E: pdm coverage", () => {
let container;
before(async () => {
DockerTestContainer.buildImage();
});
beforeEach(async () => {
// Run a new Docker container for each test
container = new DockerTestContainer();
await container.start();
const installationShell = await container.openShell("zsh");
await installationShell.runCommand("safe-chain setup");
// Clear pdm cache
await installationShell.runCommand("command pdm cache clear");
});
afterEach(async () => {
// Stop and clean up the container after each test
if (container) {
await container.stop();
container = null;
}
});
it(`successfully installs known safe packages with pdm add`, async () => {
const shell = await container.openShell("zsh");
// Initialize a new pdm project
await shell.runCommand("mkdir /tmp/test-pdm-project && cd /tmp/test-pdm-project");
await shell.runCommand("cd /tmp/test-pdm-project && pdm init --non-interactive");
// Add a safe package
const result = await shell.runCommand(
"cd /tmp/test-pdm-project && pdm add requests"
);
assert.ok(
result.output.includes("no malware found.") || result.output.includes("Installing"),
`Output did not include expected text. Output was:\n${result.output}`
);
});
it(`pdm add with specific version`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-pdm-version && cd /tmp/test-pdm-version");
await shell.runCommand("cd /tmp/test-pdm-version && pdm init --non-interactive");
const result = await shell.runCommand(
"cd /tmp/test-pdm-version && pdm add requests==2.32.3"
);
assert.ok(
result.output.includes("no malware found.") || result.output.includes("Installing"),
`Output did not include expected text. Output was:\n${result.output}`
);
});
it(`safe-chain blocks installation of malicious Python packages via pdm`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-pdm-malware && cd /tmp/test-pdm-malware");
await shell.runCommand("cd /tmp/test-pdm-malware && pdm init --non-interactive");
const result = await shell.runCommand(
"cd /tmp/test-pdm-malware && pdm add numpy==2.4.4"
);
assert.ok(
result.output.includes("blocked") && result.output.includes("malicious package downloads"),
`Expected malware to be blocked. Output was:\n${result.output}`
);
assert.ok(
result.output.includes("Exiting without installing malicious packages."),
`Expected exit message. Output was:\n${result.output}`
);
});
it(`pdm install installs dependencies from pyproject.toml`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-pdm-install && cd /tmp/test-pdm-install");
await shell.runCommand("cd /tmp/test-pdm-install && pdm init --non-interactive");
await shell.runCommand("cd /tmp/test-pdm-install && pdm add requests");
// Now remove the virtualenv and run install
await shell.runCommand("cd /tmp/test-pdm-install && rm -rf .venv");
const result = await shell.runCommand(
"cd /tmp/test-pdm-install && pdm install"
);
assert.ok(
result.output.includes("no malware found.") || result.output.includes("Installing"),
`Output did not include expected text. Output was:\n${result.output}`
);
});
it(`pdm update with specific packages`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-pdm-update-specific && cd /tmp/test-pdm-update-specific");
await shell.runCommand("cd /tmp/test-pdm-update-specific && pdm init --non-interactive");
await shell.runCommand("cd /tmp/test-pdm-update-specific && pdm add requests certifi");
const result = await shell.runCommand(
"cd /tmp/test-pdm-update-specific && pdm update requests"
);
assert.ok(
result.output.includes("no malware found.") || result.output.includes("Updating"),
`Output did not include expected text. Output was:\n${result.output}`
);
});
it(`pdm add with multiple packages`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-pdm-multi && cd /tmp/test-pdm-multi");
await shell.runCommand("cd /tmp/test-pdm-multi && pdm init --non-interactive");
const result = await shell.runCommand(
"cd /tmp/test-pdm-multi && pdm add requests certifi"
);
assert.ok(
result.output.includes("no malware found.") || result.output.includes("Installing"),
`Output did not include expected text. Output was:\n${result.output}`
);
});
it(`pdm add with extras`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-pdm-extras && cd /tmp/test-pdm-extras");
await shell.runCommand("cd /tmp/test-pdm-extras && pdm init --non-interactive");
// Use quotes to prevent shell expansion of square brackets
const result = await shell.runCommand(
'cd /tmp/test-pdm-extras && pdm add "requests[security]"'
);
assert.ok(
result.output.includes("no malware found.") || result.output.includes("Installing"),
`Output did not include expected text. Output was:\n${result.output}`
);
});
it(`pdm add with development group`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-pdm-dev && cd /tmp/test-pdm-dev");
await shell.runCommand("cd /tmp/test-pdm-dev && pdm init --non-interactive");
const result = await shell.runCommand(
"cd /tmp/test-pdm-dev && pdm add -dG dev pytest"
);
assert.ok(
result.output.includes("no malware found.") || result.output.includes("Installing"),
`Output did not include expected text. Output was:\n${result.output}`
);
});
it(`pdm lock creates/updates lock file`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-pdm-lock && cd /tmp/test-pdm-lock");
await shell.runCommand("cd /tmp/test-pdm-lock && pdm init --non-interactive");
await shell.runCommand("cd /tmp/test-pdm-lock && pdm add requests");
await shell.runCommand("cd /tmp/test-pdm-lock && rm pdm.lock");
const result = await shell.runCommand(
"cd /tmp/test-pdm-lock && pdm lock"
);
assert.ok(
result.output.includes("no malware found.") || result.output.includes("Resolving") || result.output.includes("lock file"),
`Output did not include expected text. Output was:\n${result.output}`
);
});
it(`pdm remove does not download packages`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-pdm-remove && cd /tmp/test-pdm-remove");
await shell.runCommand("cd /tmp/test-pdm-remove && pdm init --non-interactive");
await shell.runCommand("cd /tmp/test-pdm-remove && pdm add requests");
const result = await shell.runCommand(
"cd /tmp/test-pdm-remove && pdm remove requests"
);
// Remove should succeed - it doesn't download packages, just modifies pyproject.toml
assert.ok(
!result.output.includes("blocked"),
`Remove command should not trigger downloads. Output was:\n${result.output}`
);
});
it(`blocks malware during pdm install`, async () => {
const shell = await container.openShell("zsh");
// Create a project with malware in dependencies
await shell.runCommand("mkdir /tmp/test-pdm-install-malware && cd /tmp/test-pdm-install-malware");
await shell.runCommand("cd /tmp/test-pdm-install-malware && pdm init --non-interactive");
// Add malware package - this will create lock file and attempt download
const result = await shell.runCommand(
"cd /tmp/test-pdm-install-malware && pdm add numpy==2.4.4 2>&1"
);
assert.ok(
result.output.includes("blocked") && result.output.includes("malicious package downloads"),
`Expected malware to be blocked during add (which triggers install). Output was:\n${result.output}`
);
assert.ok(
result.output.includes("Exiting without installing malicious packages."),
`Expected exit message. Output was:\n${result.output}`
);
});
it(`blocks malware when adding malicious dependency alongside safe one`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-pdm-batch && cd /tmp/test-pdm-batch");
await shell.runCommand("cd /tmp/test-pdm-batch && pdm init --non-interactive");
// Try to add malware alongside safe package
const result = await shell.runCommand(
"cd /tmp/test-pdm-batch && pdm add numpy==2.4.4 requests 2>&1"
);
assert.ok(
result.output.includes("blocked") && result.output.includes("malicious package downloads"),
`Expected malware to be blocked. Output was:\n${result.output}`
);
assert.ok(
result.output.includes("Exiting without installing malicious packages."),
`Expected exit message. Output was:\n${result.output}`
);
// Verify safe package was also not installed due to malware in batch
const listResult = await shell.runCommand("cd /tmp/test-pdm-batch && pdm list");
assert.ok(
!listResult.output.includes("requests"),
`Safe package should not be installed when batch includes malware. Output was:\n${listResult.output}`
);
});
it(`pdm non-network commands work correctly`, async () => {
const shell = await container.openShell("zsh");
await shell.runCommand("mkdir /tmp/test-pdm-nonnetwork && cd /tmp/test-pdm-nonnetwork");
await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm init --non-interactive");
await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm add requests");
// Test pdm --version
const versionResult = await shell.runCommand("pdm --version");
assert.ok(
versionResult.output.includes("PDM") || versionResult.output.includes("pdm"),
`Expected version output. Output was:\n${versionResult.output}`
);
// Test pdm list (list installed packages)
const listResult = await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm list");
assert.ok(
listResult.output.includes("requests"),
`Expected to see installed package. Output was:\n${listResult.output}`
);
// Test pdm info (show project info)
const infoResult = await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm info");
assert.ok(
infoResult.output.includes("PDM") || infoResult.output.includes("Python") || infoResult.output.includes("Project"),
`Expected project info. Output was:\n${infoResult.output}`
);
// Test pdm config (show configuration)
const configResult = await shell.runCommand("pdm config");
assert.ok(
configResult.output.length > 0,
`Expected configuration output. Output was:\n${configResult.output}`
);
// Test pdm run (execute command in virtualenv) - non-network command
const runResult = await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm run python --version");
assert.ok(
runResult.output.includes("Python"),
`Expected Python version output. Output was:\n${runResult.output}`
);
});
});

View file

@ -0,0 +1,168 @@
import { describe, it, before, beforeEach, afterEach } from "node:test";
import assert from "node:assert";
import { DockerTestContainer } from "./DockerTestContainer.js";
describe("E2E: pip minimum package age", () => {
let container;
before(async () => {
DockerTestContainer.buildImage();
});
beforeEach(async () => {
container = new DockerTestContainer();
await container.start();
const installationShell = await container.openShell("zsh");
await installationShell.runCommand("safe-chain setup");
await installationShell.runCommand("pip3 cache purge");
});
afterEach(async () => {
if (container) {
await container.stop();
container = null;
}
});
it("falls back to an older PyPI version for flexible constraints", async () => {
const shell = await container.openShell("zsh");
const latestVersion = await getLatestPackageVersion(shell, "openai");
const tooYoungTimestamps = getTooYoungReleaseTimestamps();
await startFeedServer(container, [
{
source: "pypi",
package_name: "openai",
version: latestVersion,
...tooYoungTimestamps,
},
]);
const installResult = await shell.runCommand(
'SAFE_CHAIN_MALWARE_LIST_BASE_URL=http://127.0.0.1:8123 pip3 install --break-system-packages "openai>=2.8.0,<3" --safe-chain-logging=verbose'
);
assert.ok(
installResult.output.includes(`openai@${latestVersion} is newer than 48 hours and was removed`),
`Expected Safe Chain to suppress the latest openai version. Output was:\n${installResult.output}`
);
assert.ok(
!installResult.output.includes("blocked by safe-chain direct download minimum package age"),
`Expected fallback during resolution, not a direct-download block. Output was:\n${installResult.output}`
);
assert.ok(
installResult.output.includes("Successfully installed"),
`Expected pip install to succeed after fallback. Output was:\n${installResult.output}`
);
const installedVersion = await getInstalledVersion(shell, "openai");
assert.notEqual(
installedVersion,
latestVersion,
`Expected fallback to an older openai version, but installed ${latestVersion}.`
);
});
it("fails cleanly for exact pinned too-young PyPI versions", async () => {
const shell = await container.openShell("zsh");
const latestVersion = await getLatestPackageVersion(shell, "openai");
const tooYoungTimestamps = getTooYoungReleaseTimestamps();
await startFeedServer(container, [
{
source: "pypi",
package_name: "openai",
version: latestVersion,
...tooYoungTimestamps,
},
]);
const installResult = await shell.runCommand(
`SAFE_CHAIN_MALWARE_LIST_BASE_URL=http://127.0.0.1:8123 pip3 install --break-system-packages openai==${latestVersion} --safe-chain-logging=verbose`
);
assert.ok(
installResult.output.includes(`openai@${latestVersion} is newer than 48 hours and was removed`),
`Expected Safe Chain to suppress the pinned openai version. Output was:\n${installResult.output}`
);
assert.ok(
installResult.output.includes(`No matching distribution found for openai==${latestVersion}`) ||
installResult.output.includes(`Could not find a version that satisfies the requirement openai==${latestVersion}`),
`Expected pip to fail because the exact version was suppressed. Output was:\n${installResult.output}`
);
assert.ok(
!installResult.output.includes("blocked by safe-chain direct download minimum package age"),
`Expected resolver failure for an exact pin, not a direct-download block. Output was:\n${installResult.output}`
);
});
});
async function getLatestPackageVersion(shell, packageName) {
const result = await shell.runCommand(`/usr/bin/pip3 index versions ${packageName}`);
const version = result.output.match(new RegExp(`${packageName} \\(([^)]+)\\)`))?.[1];
assert.ok(
version,
`Could not determine latest ${packageName} version from pip output:\n${result.output}`
);
return version;
}
async function getInstalledVersion(shell, packageName) {
const result = await shell.runCommand(
`python3 - <<'PY'
import importlib.metadata
print(importlib.metadata.version("${packageName}"))
PY`
);
return result.output.trim();
}
async function startFeedServer(container, releases) {
const shell = await container.openShell("bash");
const releasesJson = JSON.stringify(releases, null, 2);
await shell.runCommand(`mkdir -p /tmp/safe-chain-feed/releases
cat > /tmp/safe-chain-feed/malware_pypi.json <<'EOF'
[]
EOF
cat > /tmp/safe-chain-feed/releases/pypi.json <<'EOF'
${releasesJson}
EOF`);
container.dockerExec(
"nohup python3 -m http.server 8123 -d /tmp/safe-chain-feed >/tmp/safe-chain-feed.log 2>&1 </dev/null &",
true
);
const readinessResult = await shell.runCommand(`i=0
while [ "$i" -lt 100 ]; do
if curl -fsS http://127.0.0.1:8123/releases/pypi.json >/dev/null; then
break
fi
sleep 0.1
i=$((i + 1))
done
if [ "$i" -ge 100 ]; then
echo "feed server did not become ready" >&2
cat /tmp/safe-chain-feed.log >&2 || true
fi`);
assert.equal(
readinessResult.output.includes("feed server did not become ready"),
false,
`Expected local feed server to become ready. Output was:\n${readinessResult.output}`
);
}
function getTooYoungReleaseTimestamps() {
const now = Math.floor(Date.now() / 1000);
return {
released_on: now,
scraped_on: now,
};
}

View file

@ -128,15 +128,16 @@ 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(
result.output.includes("blocked 1 malicious package downloads:"),
assert.match(
result.output,
/blocked [1-9]\d* 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}`
);
assert.ok(
@ -146,7 +147,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}`
);
});

View file

@ -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(

View file

@ -70,8 +70,9 @@ describe("E2E: pnpm coverage", () => {
var result = await shell.runCommand("pnpm install");
assert.ok(
result.output.includes("blocked 1 malicious package downloads"),
assert.match(
result.output,
/blocked [1-9]\d* malicious package downloads/,
`Output did not include expected text. Output was:\n${result.output}`
);
assert.ok(

View file

@ -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(

148
test/e2e/rush.e2e.spec.js Normal file
View file

@ -0,0 +1,148 @@
import { describe, it, before, beforeEach, afterEach } from "node:test";
import { DockerTestContainer } from "./DockerTestContainer.js";
import {
buildRushConfig,
resolveRushVersions,
writeTextFile,
} from "./utils/rushtestutils.mjs";
import assert from "node:assert";
// These tests cover safe-chain's Rush wrapper: pre-scanning `rush add` and
// blocking malicious packages downloaded during `rush update` via the MITM
// proxy. They use a single Rush-internal package manager (pnpm) — see
// `utils/rushtestutils.mjs` for why this suite isn't parameterised over the
// CI matrix's NPM_VERSION/PNPM_VERSION/YARN_VERSION values.
describe("E2E: rush coverage", () => {
let container;
/** @type {{ rushVersion: string, pnpmVersion: string } | undefined} */
let resolvedVersions;
before(async () => {
DockerTestContainer.buildImage();
});
beforeEach(async () => {
container = new DockerTestContainer();
await container.start();
const installationShell = await container.openShell("zsh");
await installationShell.runCommand("safe-chain setup");
if (!resolvedVersions) {
resolvedVersions = await resolveRushVersions(installationShell);
}
await setupRushWorkspace(installationShell, { resolvedVersions });
});
afterEach(async () => {
if (container) {
await container.stop();
container = null;
}
});
it("safe-chain successfully adds safe packages", async () => {
const shell = await container.openShell("zsh");
const result = await shell.runCommand(
"cd /testapp/apps/test-app && rush add --package axios@1.13.0 --exact --skip-update --safe-chain-logging=verbose"
);
assert.ok(
result.output.includes("no malware found."),
`Output did not include expected text. Output was:\n${result.output}`
);
});
it("safe-chain blocks rush add of malicious packages", async () => {
const shell = await container.openShell("zsh");
const result = await shell.runCommand(
"cd /testapp/apps/test-app && rush add --package safe-chain-test --skip-update"
);
assert.ok(
result.output.includes("Malicious changes detected:"),
`Output did not include expected text. Output was:\n${result.output}`
);
assert.ok(
result.output.includes("- safe-chain-test"),
`Output did not include expected text. Output was:\n${result.output}`
);
assert.ok(
result.output.includes("Exiting without installing malicious packages."),
`Output did not include expected text. Output was:\n${result.output}`
);
const packageJson = await shell.runCommand(
"cat /testapp/apps/test-app/package.json"
);
assert.ok(
!packageJson.output.includes("safe-chain-test"),
`Malicious package was added despite safe-chain protection. Output was:\n${packageJson.output}`
);
});
it("safe-chain proxy blocks malicious package downloads during rush update", async () => {
const shell = await container.openShell("zsh");
await setupRushWorkspace(shell, {
resolvedVersions,
packageJson: `{
"name": "test-app",
"version": "1.0.0",
"dependencies": {
"safe-chain-test": "0.0.1-security"
}
}`,
});
// `--safe-chain-skip-minimum-package-age` is needed because Rush's
// internal pnpm bootstrap (`npm install pnpm@<resolvedVersion>`) goes
// through the safe-chain proxy. When the CI matrix selects pnpm
// `latest`, the just-released version can be below the minimum age
// threshold and Rush's install would otherwise be blocked before our
// malicious-download assertion is reached.
const result = await shell.runCommand(
"cd /testapp/apps/test-app && rush update --safe-chain-skip-minimum-package-age"
);
assert.match(
result.output,
/blocked [1-9]\d* malicious package downloads/,
`Output did not include expected text. Output was:\n${result.output}`
);
assert.ok(
result.output.includes("- safe-chain-test"),
`Output did not include expected text. Output was:\n${result.output}`
);
assert.ok(
result.output.includes("Exiting without installing malicious packages."),
`Output did not include expected text. Output was:\n${result.output}`
);
});
});
async function setupRushWorkspace(shell, { resolvedVersions, packageJson }) {
const rushConfig = buildRushConfig({
rushVersion: resolvedVersions.rushVersion,
pnpmVersion: resolvedVersions.pnpmVersion,
});
await shell.runCommand("rm -rf /testapp/common /testapp/apps/test-app");
await shell.runCommand("mkdir -p /testapp/apps/test-app");
await writeTextFile(
shell,
"/testapp/rush.json",
JSON.stringify(rushConfig, null, 2)
);
await writeTextFile(
shell,
"/testapp/apps/test-app/package.json",
packageJson ??
`{
"name": "test-app",
"version": "1.0.0"
}`
);
}

100
test/e2e/rushx.e2e.spec.js Normal file
View file

@ -0,0 +1,100 @@
import { describe, it, before, beforeEach, afterEach } from "node:test";
import { DockerTestContainer } from "./DockerTestContainer.js";
import {
buildRushConfig,
resolveRushVersions,
writeTextFile,
} from "./utils/rushtestutils.mjs";
import assert from "node:assert";
describe("E2E: rushx coverage", () => {
let container;
/** @type {{ rushVersion: string, pnpmVersion: string } | undefined} */
let resolvedVersions;
before(async () => {
DockerTestContainer.buildImage();
});
beforeEach(async () => {
container = new DockerTestContainer();
await container.start();
const installationShell = await container.openShell("zsh");
await installationShell.runCommand("safe-chain setup");
if (!resolvedVersions) {
resolvedVersions = await resolveRushVersions(installationShell);
}
await setupRushWorkspace(installationShell, { resolvedVersions });
});
afterEach(async () => {
if (container) {
await container.stop();
container = null;
}
});
it("safe-chain successfully scans safe package downloads from rushx scripts", async () => {
const shell = await container.openShell("zsh");
const result = await shell.runCommand(
"cd /testapp/apps/test-app && rushx install-safe --safe-chain-logging=verbose"
);
assert.ok(
result.output.includes("no malware found."),
`Output did not include expected text. Output was:\n${result.output}`
);
});
it("safe-chain blocks malicious package downloads from rushx scripts", async () => {
const shell = await container.openShell("zsh");
const result = await shell.runCommand(
"cd /testapp/apps/test-app && rushx install-malicious"
);
assert.match(
result.output,
/blocked [1-9]\d* malicious package downloads/,
`Output did not include expected text. Output was:\n${result.output}`
);
assert.ok(
result.output.includes("- safe-chain-test"),
`Output did not include expected text. Output was:\n${result.output}`
);
assert.ok(
result.output.includes("Exiting without installing malicious packages."),
`Output did not include expected text. Output was:\n${result.output}`
);
});
});
async function setupRushWorkspace(shell, { resolvedVersions }) {
const rushConfig = buildRushConfig({
rushVersion: resolvedVersions.rushVersion,
pnpmVersion: resolvedVersions.pnpmVersion,
});
await shell.runCommand(
"mkdir -p /testapp/common/config/rush /testapp/apps/test-app"
);
await writeTextFile(
shell,
"/testapp/rush.json",
JSON.stringify(rushConfig, null, 2)
);
await writeTextFile(
shell,
"/testapp/apps/test-app/package.json",
`{
"name": "test-app",
"version": "1.0.0",
"scripts": {
"install-safe": "npm install axios@1.13.0",
"install-malicious": "npm install safe-chain-test@0.0.1-security"
}
}`
);
}

View file

@ -97,11 +97,12 @@ 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(
result.output.includes("blocked 1 malicious package downloads"),
assert.match(
result.output,
/blocked [1-9]\d* malicious package downloads/,
`Should have blocked malware. Output was:\n${result.output}`
);
});

View 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;
}

View file

@ -0,0 +1,69 @@
// Helpers for the Rush E2E suites.
//
// What these suites actually test: that safe-chain's shim intercepts `rush`
// and `rushx` invocations correctly. The contents of `rush.json` are just
// fixture noise needed to make Rush run at all — Rush's schema requires
// exact semver for `rushVersion`/`pnpmVersion` and refuses dist-tags like
// "latest", so we read both back from the binaries baked into the image.
//
// * `rushVersion` ← `rush --version` (image installs
// `@microsoft/rush@${RUSH_VERSION:-latest}`).
// * `pnpmVersion` ← `pnpm --version` (image installs
// `pnpm@${PNPM_VERSION:-latest}`). Rush downloads its own copy of this
// into `~/.rush/...`; using the same exact version as the system pnpm
// just keeps the fixture in lockstep with whatever the CI matrix picks.
/** Resolves the versions to put into `rush.json`. */
export async function resolveRushVersions(shell) {
// Sequential: the helper drives a single PTY shell.
const rushVersion = await getInstalledVersion(shell, "rush");
const pnpmVersion = await getInstalledVersion(shell, "pnpm");
return { rushVersion, pnpmVersion };
}
/** Builds the standard `rush.json` body for the e2e fixtures. */
export function buildRushConfig({ rushVersion, pnpmVersion, projects }) {
return {
$schema:
"https://developer.microsoft.com/json-schemas/rush/v5/rush.schema.json",
rushVersion,
pnpmVersion,
nodeSupportedVersionRange: ">=18.0.0",
projectFolderMinDepth: 1,
projectFolderMaxDepth: 2,
gitPolicy: {},
repository: {
url: "https://example.com/testapp.git",
defaultBranch: "main",
},
eventHooks: {
preRushInstall: [],
postRushInstall: [],
preRushBuild: [],
postRushBuild: [],
},
projects: projects ?? [
{ packageName: "test-app", projectFolder: "apps/test-app" },
],
};
}
/**
* Writes a UTF-8 text file inside the container, base64-encoding the payload
* to avoid shell escaping issues for arbitrary content.
*/
export async function writeTextFile(shell, filePath, content) {
const encoded = Buffer.from(content).toString("base64");
await shell.runCommand(`printf '%s' '${encoded}' | base64 -d > ${filePath}`);
}
async function getInstalledVersion(shell, command) {
const { output } = await shell.runCommand(`${command} --version`);
const match = output.match(/\b(\d+\.\d+\.\d+)\b/);
if (!match) {
throw new Error(
`Could not determine installed ${command} version. Output was:\n${output}`
);
}
return match[1];
}

View file

@ -126,15 +126,16 @@ 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(
result.output.includes("blocked 1 malicious package downloads:"),
assert.match(
result.output,
/blocked [1-9]\d* 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}`
);
assert.ok(
@ -144,7 +145,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,15 +414,16 @@ 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(
result.output.includes("blocked 1 malicious package downloads:"),
assert.match(
result.output,
/blocked [1-9]\d* 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}`
);
assert.ok(
@ -445,14 +447,15 @@ 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:"),
assert.match(
result.output,
/blocked [1-9]\d* 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,11 +485,12 @@ 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(
result.output.includes("blocked 1 malicious package downloads:"),
assert.match(
result.output,
/blocked [1-9]\d* malicious package downloads:/,
`Output did not include expected text. Output was:\n${result.output}`
);
});

View file

@ -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(

View file

@ -70,8 +70,9 @@ describe("E2E: yarn coverage", () => {
var result = await shell.runCommand("yarn");
assert.ok(
result.output.includes("blocked 1 malicious package downloads"),
assert.match(
result.output,
/blocked [1-9]\d* malicious package downloads/,
`Output did not include expected text. Output was:\n${result.output}`
);
assert.ok(